unofficial mirror of bug-gnu-emacs@gnu.org 
 help / color / mirror / code / Atom feed
From: "J.P." <jp@neverwas.me>
To: 29108@debbugs.gnu.org
Cc: emacs-erc@gnu.org, bandali@gnu.org
Subject: bug#29108: 25.3; ERC SASL support
Date: Sun, 13 Nov 2022 22:45:03 -0800	[thread overview]
Message-ID: <87a64unifk.fsf@neverwas.me> (raw)
In-Reply-To: <87o7taoohd.fsf@neverwas.me> (J. P.'s message of "Sun, 13 Nov 2022 07:36:46 -0800")

[-- Attachment #1: Type: text/plain, Size: 287 bytes --]

"J.P." <jp@neverwas.me> writes:

> v6. Added some sweeping changes that are still pretty raw, which
> probably means a delay of a couple days, at least. Apologies for the
> hold up.

v7. Fixed some sloppiness involving mode activation. Restored misplaced
compat hunk to rightful patch.


[-- Warning: decoded text below may be mangled, UTF-8 assumed --]
[-- Attachment #2: 0000-v6-v7.diff --]
[-- Type: text/x-patch, Size: 11828 bytes --]

From ba6fae5c2851e2926e20e21c8dc962977c94987a Mon Sep 17 00:00:00 2001
From: "F. Jason Park" <jp@neverwas.me>
Date: Sun, 13 Nov 2022 22:38:13 -0800
Subject: [PATCH 0/5] *** NOT A PATCH ***

*** BLURB HERE ***

F. Jason Park (5):
  Add GS2 authorization to sasl-scram-rfc
  Don't set erc-networks--id until network is known
  Support local ERC modules in erc-mode buffers
  Call erc-login indirectly via new generic wrapper
  Add non-IRCv3 SASL module to ERC

 doc/misc/erc.texi                             | 148 +++++-
 etc/ERC-NEWS                                  |  21 +-
 lisp/erc/erc-backend.el                       |  15 +-
 lisp/erc/erc-common.el                        |  56 ++-
 lisp/erc/erc-compat.el                        | 116 +++++
 lisp/erc/erc-goodies.el                       |   1 +
 lisp/erc/erc-networks.el                      |  39 +-
 lisp/erc/erc-sasl.el                          | 433 ++++++++++++++++++
 lisp/erc/erc.el                               |  85 ++--
 lisp/net/sasl-scram-rfc.el                    |  21 +-
 test/lisp/erc/erc-sasl-tests.el               | 319 +++++++++++++
 test/lisp/erc/erc-scenarios-sasl.el           | 208 +++++++++
 test/lisp/erc/erc-tests.el                    |  63 +++
 test/lisp/erc/resources/sasl/external.eld     |  33 ++
 test/lisp/erc/resources/sasl/plain-failed.eld |  16 +
 test/lisp/erc/resources/sasl/plain.eld        |  39 ++
 test/lisp/erc/resources/sasl/scram-sha-1.eld  |  47 ++
 .../lisp/erc/resources/sasl/scram-sha-256.eld |  47 ++
 18 files changed, 1621 insertions(+), 86 deletions(-)
 create mode 100644 lisp/erc/erc-sasl.el
 create mode 100644 test/lisp/erc/erc-sasl-tests.el
 create mode 100644 test/lisp/erc/erc-scenarios-sasl.el
 create mode 100644 test/lisp/erc/resources/sasl/external.eld
 create mode 100644 test/lisp/erc/resources/sasl/plain-failed.eld
 create mode 100644 test/lisp/erc/resources/sasl/plain.eld
 create mode 100644 test/lisp/erc/resources/sasl/scram-sha-1.eld
 create mode 100644 test/lisp/erc/resources/sasl/scram-sha-256.eld

Interdiff:
diff --git a/doc/misc/erc.texi b/doc/misc/erc.texi
index 79f8c92719..8eb33c8e80 100644
--- a/doc/misc/erc.texi
+++ b/doc/misc/erc.texi
@@ -390,8 +390,15 @@ Modules
 
 There is a spiffy customize interface, which may be reached by typing
 @kbd{M-x customize-option @key{RET} erc-modules @key{RET}}.
-Alternatively, set @code{erc-modules} manually and then call
-@code{erc-update-modules}.
+Alternatively, set @code{erc-modules} manually, and ERC will load them
+and run their setup code during buffer initialization.  Third-party
+code may need to call the function @code{erc-update-modules}
+explicitly, although this is typically unnecessary.
+
+All modules operate as minor modes under the hood, and some newer ones
+are defined as buffer-local.  For everyday use, the only practical
+difference is that local modules can only be enabled in ERC buffers,
+and their toggle commands never mutate @code{erc-modules}.
 
 The following is a list of available modules.
 
@@ -1026,7 +1033,7 @@ SASL
 
 Otherwise, if you set this option to @code{nil} (or the empty string)
 or if an auth-source lookup has failed, ERC will try a non-@code{nil}
-``server password'', likely whatever you gave as the @var{password}
+``server password,'' likely whatever you gave as the @var{password}
 argument to @code{erc-tls}.  This fallback behavior may change,
 however, so please don't rely on it.  As a last resort, ERC will
 prompt you for input.
diff --git a/etc/ERC-NEWS b/etc/ERC-NEWS
index 5cabb9b015..f5b14376ad 100644
--- a/etc/ERC-NEWS
+++ b/etc/ERC-NEWS
@@ -48,10 +48,9 @@ hell.  For some, auth-source may provide a workaround in the form of
 nonstandard server passwords.  See the "Connection" node in the manual
 under the subheading "Password".
 
-If you require SASL immediately, please participate in ERC development
-by volunteering to try (and give feedback on) edge features, one of
-which is SASL.  All known external offerings, past and present, are
-valiant efforts whose use is nevertheless discouraged.
+** Rudimentary SASL support has arrived.
+A new module, 'erc-sasl', now ships with ERC 5.5.  See the SASL
+section in the manual for details.
 
 ** Username argument for entry-point commands.
 Commands 'erc' and 'erc-tls' now accept a ':user' keyword argument,
@@ -97,6 +96,20 @@ messages during periods of heavy traffic no longer disappear.
 Although rare, server passwords containing white space are now handled
 correctly.
 
+** Local modules and ERC-mode hooks are more useful.
+The 'local-p' parameter of 'define-erc-module' now affects more than
+the scope of a module's minor-mode.  This currently has little direct
+impact on the user experience, but third-party packages may wish to
+take note.
+
+More importantly, the function 'erc-update-modules' now supports an
+optional argument to defer the enabling of local modules and instead
+return their mode-activation commands.  'erc-open' leverages this new
+functionality to delay their activation, as well as that of all
+'erc-mode-hook' members, until most of ERC's mode-related variables
+have been initialized.  This does not include connection-specific
+variables defined in erc-backend, however.
+
 ** Miscellaneous behavioral changes in the library API.
 A number of core macros and other definitions have been moved to a new
 file called erc-common.el.  This was done to further lessen the
diff --git a/lisp/erc/erc-common.el b/lisp/erc/erc-common.el
index a300cfc4fa..e5fabdc67f 100644
--- a/lisp/erc/erc-common.el
+++ b/lisp/erc/erc-common.el
@@ -173,17 +173,17 @@ define-erc-module
                   name)
          (interactive)
          ,@(unless local-p `((cl-pushnew ',mod erc-modules)))
-         ,@(macroexp-unprogn
-            `(,@(if local-p '(when (eq major-mode 'erc-mode)) '(progn))
-              (setq ,mode t)
-              ,@enable-body)))
+         ,@(if local-p
+               `((when (setq ,mode (and (derived-mode-p 'erc-mode) t))
+                   ,@enable-body))
+             `((setq ,mode t) ,@enable-body)))
        (defun ,disable ()
          ,(format "Disable ERC %S mode."
                   name)
          (interactive)
          ,@(unless local-p `((setq erc-modules (delq ',mod erc-modules))))
          ,@(macroexp-unprogn
-            `(,@(if local-p `(when ,mode) '(progn))
+            `(,@(if local-p '(when (derived-mode-p 'erc-mode)) '(progn))
               (setq ,mode nil)
               ,@disable-body)))
        ,(when (and alias (not (eq name alias)))
diff --git a/lisp/erc/erc-compat.el b/lisp/erc/erc-compat.el
index 6d4ef21383..d4a2e312be 100644
--- a/lisp/erc/erc-compat.el
+++ b/lisp/erc/erc-compat.el
@@ -272,7 +272,7 @@ erc-compat--with-memoization
     `(cl--generic-with-memoization ,table ,@forms))
    (t `(progn ,@forms))))
 
-(defun erc-compat--local-minor-modes ()
+(defun erc-compat--local-module-modes ()
   (delq nil
         (if (boundp 'local-minor-modes)
             (mapcar (lambda (m)
diff --git a/lisp/erc/erc-sasl.el b/lisp/erc/erc-sasl.el
index ac2646051c..a9d7ed235d 100644
--- a/lisp/erc/erc-sasl.el
+++ b/lisp/erc/erc-sasl.el
@@ -223,17 +223,18 @@ erc-sasl--create-client
   (let ((sasl-mechanism-alist (copy-sequence sasl-mechanism-alist))
         (sasl-mechanisms sasl-mechanisms)
         (name (upcase (symbol-name mechanism)))
-        (feature (intern (concat "erc-sasl-" (symbol-name mechanism))))
+        (feature (intern-soft (concat "erc-sasl-" (symbol-name mechanism))))
         client)
-    (setf (alist-get name sasl-mechanism-alist nil nil #'equal) `(,feature))
-    (cl-pushnew name sasl-mechanisms :test #'equal)
-    (setq client (sasl-make-client (sasl-find-mechanism `(,name))
-                                   (or (alist-get 'user erc-sasl--options)
-                                       (erc-downcase (erc-current-nick)))
-                                   "N/A" "N/A"))
-    (sasl-client-set-property client 'authenticator-name
-                              (alist-get 'authzid erc-sasl--options))
-    client))
+    (when feature
+      (setf (alist-get name sasl-mechanism-alist nil nil #'equal) `(,feature))
+      (cl-pushnew name sasl-mechanisms :test #'equal)
+      (setq client (sasl-make-client (sasl-find-mechanism `(,name))
+                                     (or (alist-get 'user erc-sasl--options)
+                                         (erc-downcase (erc-current-nick)))
+                                     "N/A" "N/A"))
+      (sasl-client-set-property client 'authenticator-name
+                                (alist-get 'authzid erc-sasl--options))
+      client)))
 
 (cl-defmethod erc-sasl--create-client ((_m (eql plain)))
   "Create and return a new PLAIN client object."
@@ -296,7 +297,9 @@ erc-sasl--init
 (defun erc-sasl--on-connection-established (&rest _)
   (setf (alist-get erc-networks--id erc-sasl--session-options nil nil
                    #'erc-networks--id-equal-p)
-        erc-sasl--options))
+        erc-sasl--options
+        ;;
+        erc-sasl--options nil))
 
 (defun erc-sasl--mechanism-offered-p (offered)
   "Return non-nil when OFFERED appears among a list of mechanisms."
@@ -318,7 +321,8 @@ erc-sasl--authenticate-handler
     (when (string= "+" response)
       (setq response ""))
     (setf response (base64-decode-string
-                    (concat (erc-sasl--state-pending erc-sasl--state) response))
+                    (concat (erc-sasl--state-pending erc-sasl--state)
+                            response))
           (erc-sasl--state-pending erc-sasl--state) nil)
     ;; The server is done sending, so our turn
     (let ((client (erc-sasl--state-client erc-sasl--state))
@@ -357,11 +361,14 @@ sasl
      (let* ((mech (alist-get 'mechanism erc-sasl--options))
             (client (erc-sasl--create-client mech)))
        (unless client
-         (erc-display-error-notice nil (format "Unknown mechanism: %s" mech))
-         (erc-error "Unknown mechanism: %s" mech))
+         (erc-display-error-notice
+          nil (format "Unknown SASL mechanism: %s" mech))
+         (erc-error "Unknown SASL mechanism: %s" mech))
        (setf (erc-sasl--state-client erc-sasl--state) client))))
   ((remove-hook 'erc-server-AUTHENTICATE-functions
                 #'erc-sasl--authenticate-handler t)
+   (setf (alist-get erc-networks--id erc-sasl--session-options nil t) nil)
+   (kill-local-variable 'erc-sasl--state)
    (kill-local-variable 'erc-sasl--options))
   'local)
 
@@ -410,11 +417,13 @@ erc-sasl--destroy
 
 (cl-defmethod erc--register-connection (&context (erc-sasl-mode (eql t)))
   "Send speculative/pipelined CAP and AUTHENTICATE and hope for the best."
-  (erc-server-send "CAP REQ :sasl")
-  (erc-login)
-  (let* ((c (erc-sasl--state-client erc-sasl--state))
-         (m (sasl-mechanism-name (sasl-client-mechanism c))))
-    (erc-server-send (format "AUTHENTICATE %s" m))))
+  (if-let* ((c (erc-sasl--state-client erc-sasl--state))
+            (m (sasl-mechanism-name (sasl-client-mechanism c))))
+      (progn
+        (erc-server-send "CAP REQ :sasl")
+        (erc-login)
+        (erc-server-send (format "AUTHENTICATE %s" m)))
+    (erc-sasl--destroy erc-server-process)))
 
 (provide 'erc-sasl)
 ;;; erc-sasl.el ends here
diff --git a/lisp/erc/erc.el b/lisp/erc/erc.el
index a703f903ec..c5989dbc7e 100644
--- a/lisp/erc/erc.el
+++ b/lisp/erc/erc.el
@@ -1871,7 +1871,7 @@ erc-update-modules
 introduced in ERC 5.5."
   (let ((local-modes
          (when (and defer-locals (derived-mode-p 'erc-mode))
-           (erc-compat--local-minor-modes))))
+           (erc-compat--local-module-modes))))
     (dolist (module erc-modules (and defer-locals local-modes))
       (require (or (alist-get module erc--modules-to-features)
                    (intern (concat "erc-" (symbol-name module))))
-- 
2.38.1


[-- Warning: decoded text below may be mangled, UTF-8 assumed --]
[-- Attachment #3: 0001-Add-GS2-authorization-to-sasl-scram-rfc.patch --]
[-- Type: text/x-patch, Size: 3030 bytes --]

From a7177b08ef8a0fe055d1e09045aaa95a8ba66ceb Mon Sep 17 00:00:00 2001
From: "F. Jason Park" <jp@neverwas.me>
Date: Mon, 19 Sep 2022 21:28:52 -0700
Subject: [PATCH 1/5] Add GS2 authorization to sasl-scram-rfc

* lisp/net/sasl-scram-rfc.el (sasl-scram-gs2-header-function,
sasl-scram-construct-gs2-header): Add new variable and default
function for determining a SCRAM GSS-API message header.  This is
mainly intended for other libraries rather than end users.
(sasl-scram-client-first-message): Use gs2-header function.
(sasl-scram--client-final-message): Use dedicated gs2-header function.
Also remove whitespace when base64-encoding, as per RFC 5802.
(Bug#57956.)
---
 lisp/net/sasl-scram-rfc.el | 21 ++++++++++++++-------
 1 file changed, 14 insertions(+), 7 deletions(-)

diff --git a/lisp/net/sasl-scram-rfc.el b/lisp/net/sasl-scram-rfc.el
index ee52ed6e07..f7a2e42541 100644
--- a/lisp/net/sasl-scram-rfc.el
+++ b/lisp/net/sasl-scram-rfc.el
@@ -45,14 +45,21 @@
 
 ;;; Generic for SCRAM-*
 
+(defvar sasl-scram-gs2-header-function 'sasl-scram-construct-gs2-header
+  "Function to create GS2 header.
+See https://www.rfc-editor.org/rfc/rfc5801#section-4.")
+
+(defun sasl-scram-construct-gs2-header (client)
+  ;; The "n," means the client doesn't support channel binding, and
+  ;; the trailing comma is included as per RFC 5801.
+  (let ((authzid (sasl-client-property client 'authenticator-name)))
+    (concat "n," (and authzid "a=") authzid ",")))
+
 (defun sasl-scram-client-first-message (client _step)
   (let ((c-nonce (sasl-unique-id)))
     (sasl-client-set-property client 'c-nonce c-nonce))
   (concat
-   ;; n = client doesn't support channel binding
-   "n,"
-   ;; TODO: where would we get authorization id from?
-   ","
+   (funcall sasl-scram-gs2-header-function client)
    (sasl-scram--client-first-message-bare client)))
 
 (defun sasl-scram--client-first-message-bare (client)
@@ -77,11 +84,11 @@ sasl-scram--client-final-message
 
 	 (c-nonce (sasl-client-property client 'c-nonce))
 	 ;; no channel binding, no authorization id
-	 (cbind-input "n,,"))
+         (cbind-input (funcall sasl-scram-gs2-header-function client)))
     (unless (string-prefix-p c-nonce nonce)
       (sasl-error "Invalid nonce from server"))
     (let* ((client-final-message-without-proof
-	    (concat "c=" (base64-encode-string cbind-input) ","
+            (concat "c=" (base64-encode-string cbind-input t) ","
 		    "r=" nonce))
 	   (password
 	    ;; TODO: either apply saslprep or disallow non-ASCII characters
@@ -113,7 +120,7 @@ sasl-scram--client-final-message
 	   (client-proof (funcall string-xor client-key client-signature))
 	   (client-final-message
 	    (concat client-final-message-without-proof ","
-		    "p=" (base64-encode-string client-proof))))
+                    "p=" (base64-encode-string client-proof t))))
       (sasl-client-set-property client 'auth-message auth-message)
       (sasl-client-set-property client 'salted-password salted-password)
       client-final-message)))
-- 
2.38.1


[-- Warning: decoded text below may be mangled, UTF-8 assumed --]
[-- Attachment #4: 0002-Don-t-set-erc-networks-id-until-network-is-known.patch --]
[-- Type: text/x-patch, Size: 7501 bytes --]

From 665eb8627e3b2ba1befeb64cbff0caf217a28089 Mon Sep 17 00:00:00 2001
From: "F. Jason Park" <jp@neverwas.me>
Date: Sun, 13 Nov 2022 01:52:48 -0800
Subject: [PATCH 2/5] Don't set erc-networks--id until network is known

* lisp/erc/erc-networks.el (erc-networks--id-given): Accept a null
argument.
(erc-networks--id-on-connect): Remove unused function.
(erc-networks--id-equal-p): Add method for comparing initialized and
unset IDs.
(erc-networks--update-server-identity): Ensure `erc-networks--id' is
set before continuing search.
(erc-networks--init-identity): Don't assume `erc-networks--id' is
non-nil.

* lisp/erc/erc.el (erc-open): For continued sessions, try copying over
the last network ID.
(erc--auth-source-determine-params-default): Don't expect a network ID
to have been initialized.

* lisp/erc/erc-backend.el (erc-server-NICK, erc-server-433): Unless
already connected, clear network ID when server rejects or mandates a
nick change.
---
 lisp/erc/erc-backend.el  |  7 ++++++-
 lisp/erc/erc-networks.el | 39 ++++++++++++++++-----------------------
 lisp/erc/erc.el          | 13 ++++++++-----
 3 files changed, 30 insertions(+), 29 deletions(-)

diff --git a/lisp/erc/erc-backend.el b/lisp/erc/erc-backend.el
index 026b34849a..2c8c4dcb28 100644
--- a/lisp/erc/erc-backend.el
+++ b/lisp/erc/erc-backend.el
@@ -1525,7 +1525,7 @@ define-erc-response-handler
         (cl-pushnew (erc-server-buffer) bufs)
         (erc-set-current-nick nn)
         ;; Rename session, possibly rename server buf and all targets
-        (when (erc-network)
+        (when erc-server-connected
           (erc-networks--id-reload erc-networks--id proc parsed))
         (erc-update-mode-line)
         (setq erc-nick-change-attempt-count 0)
@@ -1535,6 +1535,9 @@ define-erc-response-handler
          'NICK-you ?n nick ?N nn)
         (run-hook-with-args 'erc-nick-changed-functions nn nick))
        (t
+        (unless (or erc-server-connected
+                    (erc-networks--id-given erc-networks--id))
+          (setq erc-networks--id nil))
         (erc-handle-user-status-change 'nick (list nick login host) (list nn))
         (erc-display-message parsed 'notice bufs 'NICK ?n nick
                              ?u login ?h host ?N nn))))))
@@ -2161,6 +2164,8 @@ erc-server-322-message
 
 (define-erc-response-handler (433)
   "Login-time \"nick in use\"." nil
+  (unless (or erc-server-connected (erc-networks--id-given erc-networks--id))
+    (setq erc-networks--id nil))
   (erc-nickname-in-use (cadr (erc-response.command-args parsed))
                        "already in use"))
 
diff --git a/lisp/erc/erc-networks.el b/lisp/erc/erc-networks.el
index dba6ead073..aa90bb8479 100644
--- a/lisp/erc/erc-networks.el
+++ b/lisp/erc/erc-networks.el
@@ -826,12 +826,11 @@ erc-networks--id
 
 ;; For now, please use this instead of `erc-networks--id-fixed-p'.
 (cl-defgeneric erc-networks--id-given (net-id)
-  "Return the preassigned identifier for a network presence, if any.
-This may have originated from an `:id' arg to entry-point commands
-`erc-tls' or `erc'.")
+  "Return the preassigned identifier for a network context, if any.
+When non-nil, assume NET-ID originated from an `:id' argument to
+entry-point commands `erc-tls' or `erc'.")
 
-(cl-defmethod erc-networks--id-given ((_ erc-networks--id))
-  nil)
+(cl-defmethod erc-networks--id-given (_) nil) ; _ may be nil
 
 (cl-defmethod erc-networks--id-given ((nid erc-networks--id-fixed))
   (erc-networks--id-symbol nid))
@@ -866,22 +865,15 @@ erc-networks--id-create
   ((_ symbol) &context (erc-obsolete-var erc-reuse-buffers null))
   (erc-networks--id-fixed-create (intern (buffer-name))))
 
-(cl-defgeneric erc-networks--id-on-connect (net-id)
-  "Update NET-ID `erc-networks--id' after connection params known.
-This is typically during or just after MOTD.")
-
-(cl-defmethod erc-networks--id-on-connect ((_ erc-networks--id))
-  nil)
-
-(cl-defmethod erc-networks--id-on-connect ((id erc-networks--id-qualifying))
-  (erc-networks--id-qualifying-update id (erc-networks--id-qualifying-create)))
-
 (cl-defgeneric erc-networks--id-equal-p (self other)
-  "Return non-nil when two network identities exhibit underlying equality.
-SELF and OTHER are `erc-networks--id' struct instances.  This
-should normally be used only for ID recovery or merging, after
-which no two identities should be `equal' (timestamps aside) that
-aren't also `eq'.")
+  "Return non-nil when two network IDs exhibit underlying equality.
+Expect SELF and OTHER to be `erc-networks--id' struct instances
+and that this will only be called for ID recovery or merging,
+after which no two identities should be `equal' (timestamps
+aside) that aren't also `eq'.")
+
+(cl-defmethod erc-networks--id-equal-p ((_ null) (_ erc-networks--id)) nil)
+(cl-defmethod erc-networks--id-equal-p ((_ erc-networks--id) (_ null)) nil)
 
 (cl-defmethod erc-networks--id-equal-p ((self erc-networks--id)
                                         (other erc-networks--id))
@@ -1381,7 +1373,8 @@ erc-networks--update-server-identity
   (let* ((identity erc-networks--id)
          (buffer (current-buffer))
          (f (lambda ()
-              (unless (or (eq (current-buffer) buffer)
+              (unless (or (not erc-networks--id)
+                          (eq (current-buffer) buffer)
                           (eq erc-networks--id identity))
                 (if (erc-networks--id-equal-p identity erc-networks--id)
                     (throw 'buffer erc-networks--id)
@@ -1400,8 +1393,8 @@ erc-networks--init-identity
   "Update identity with real network name."
   ;; Initialize identity for real now that we know the network
   (cl-assert erc-network)
-  (unless (erc-networks--id-symbol erc-networks--id) ; unless just reconnected
-    (erc-networks--id-on-connect erc-networks--id))
+  (unless erc-networks--id
+    (setq erc-networks--id (erc-networks--id-create nil)))
   ;; Find duplicate identities or other conflicting ones and act
   ;; accordingly.
   (erc-networks--update-server-identity)
diff --git a/lisp/erc/erc.el b/lisp/erc/erc.el
index 6b14cf87e2..63379af141 100644
--- a/lisp/erc/erc.el
+++ b/lisp/erc/erc.el
@@ -2008,10 +2008,12 @@ erc-open
     (setq erc-default-nicks (if (consp erc-nick) erc-nick (list erc-nick)))
     ;; client certificate (only useful if connecting over TLS)
     (setq erc-session-client-certificate client-certificate)
-    (setq erc-networks--id (if connect
-                               (erc-networks--id-create id)
-                             (buffer-local-value 'erc-networks--id
-                                                 old-buffer)))
+    (setq erc-networks--id
+          (if connect
+              (or (and continued-session
+                       (buffer-local-value 'erc-networks--id old-buffer))
+                  (and id (erc-networks--id-create id)))
+            (buffer-local-value 'erc-networks--id old-buffer)))
     ;; debug output buffer
     (setq erc-dbuf
           (when erc-log-p
@@ -3171,7 +3173,8 @@ erc-auth-source-join-function
                  function))
 
 (defun erc--auth-source-determine-params-defaults ()
-  (let* ((net (and-let* ((esid (erc-networks--id-symbol erc-networks--id))
+  (let* ((net (and-let* ((erc-networks--id)
+                         (esid (erc-networks--id-symbol erc-networks--id))
                          ((symbol-name esid)))))
          (localp (and erc--target (erc--target-channel-local-p erc--target)))
          (hosts (if localp
-- 
2.38.1


[-- Warning: decoded text below may be mangled, UTF-8 assumed --]
[-- Attachment #5: 0003-Support-local-ERC-modules-in-erc-mode-buffers.patch --]
[-- Type: text/x-patch, Size: 17364 bytes --]

From 21145f307c90c0231b8564e7f6517d2782a8cf17 Mon Sep 17 00:00:00 2001
From: "F. Jason Park" <jp@neverwas.me>
Date: Mon, 12 Jul 2021 03:44:28 -0700
Subject: [PATCH 3/5] Support local ERC modules in erc-mode buffers

* doc/misc/erc.texi: Mention local modules in Modules Chapter.

* lisp/erc/erc-compat.el (erc-compat--local-module-modes): Add helper
for finding local modules active in an ERC buffer.

* lisp/erc/erc.el (erc-migrate-modules): Add some missing mappings.
(erc-update-modules): Change return value from nil to a list of
minor-mode commands for local modules.  Use `custom-variable-p' to
detect flavor.  Currently, all modules are global and so are their
accompanying minor modes.
(erc-open): Defer enabling of local modules via `erc-update-modules'
until after buffer is initialized with other local vars.  Also defer
major mode hooks so they can detect things like whether the buffer is
a server or target buffer.  Also ensure local module setup code can
detect when `erc-open' was called with a non-nil
`erc--server-reconnecting'.  It's reset to nil by
`erc-server-connect'.

* lisp/erc/erc-common.el (erc--module-name-migrations,
erc--features-to-modules, erc--modules-to-features): Add alists of
old-to-new module names to support module-name migrations.
(define-erc-modules): Don't enable local modules (minor modes) unless
`erc-mode' is the major mode.  And don't disable them unless the minor
mode is actually active.  Also, don't mutate `erc-modules' when
dealing with a local module.
(erc--normalize-module-symbol): Add helper for `erc-migrate-modules'.

* lisp/erc/erc-goodies.el: Require cl-lib.
* test/lisp/erc/erc-tests.el (erc-migrate-modules,
erc-update-modules): Add rudimentary unit tests.  (Bug#57955.)
---
 doc/misc/erc.texi          | 11 +++++-
 etc/ERC-NEWS               | 14 +++++++
 lisp/erc/erc-common.el     | 56 ++++++++++++++++++++++++----
 lisp/erc/erc-compat.el     | 12 ++++++
 lisp/erc/erc-goodies.el    |  1 +
 lisp/erc/erc.el            | 75 ++++++++++++++++++++------------------
 test/lisp/erc/erc-tests.el | 58 +++++++++++++++++++++++++++++
 7 files changed, 182 insertions(+), 45 deletions(-)

diff --git a/doc/misc/erc.texi b/doc/misc/erc.texi
index 3db83197f9..5049710b32 100644
--- a/doc/misc/erc.texi
+++ b/doc/misc/erc.texi
@@ -389,8 +389,15 @@ Modules
 
 There is a spiffy customize interface, which may be reached by typing
 @kbd{M-x customize-option @key{RET} erc-modules @key{RET}}.
-Alternatively, set @code{erc-modules} manually and then call
-@code{erc-update-modules}.
+Alternatively, set @code{erc-modules} manually, and ERC will load them
+and run their setup code during buffer initialization.  Third-party
+code may need to call the function @code{erc-update-modules}
+explicitly, although this is typically unnecessary.
+
+All modules operate as minor modes under the hood, and some newer ones
+are defined as buffer-local.  For everyday use, the only practical
+difference is that local modules can only be enabled in ERC buffers,
+and their toggle commands never mutate @code{erc-modules}.
 
 The following is a list of available modules.
 
diff --git a/etc/ERC-NEWS b/etc/ERC-NEWS
index 5cabb9b015..e14cd3492a 100644
--- a/etc/ERC-NEWS
+++ b/etc/ERC-NEWS
@@ -97,6 +97,20 @@ messages during periods of heavy traffic no longer disappear.
 Although rare, server passwords containing white space are now handled
 correctly.
 
+** Local modules and ERC-mode hooks are more useful.
+The 'local-p' parameter of 'define-erc-module' now affects more than
+the scope of a module's minor-mode.  This currently has little direct
+impact on the user experience, but third-party packages may wish to
+take note.
+
+More importantly, the function 'erc-update-modules' now supports an
+optional argument to defer the enabling of local modules and instead
+return their mode-activation commands.  'erc-open' leverages this new
+functionality to delay their activation, as well as that of all
+'erc-mode-hook' members, until most of ERC's mode-related variables
+have been initialized.  This does not include connection-specific
+variables defined in erc-backend, however.
+
 ** Miscellaneous behavioral changes in the library API.
 A number of core macros and other definitions have been moved to a new
 file called erc-common.el.  This was done to further lessen the
diff --git a/lisp/erc/erc-common.el b/lisp/erc/erc-common.el
index d8aac36eab..e5fabdc67f 100644
--- a/lisp/erc/erc-common.el
+++ b/lisp/erc/erc-common.el
@@ -85,6 +85,41 @@ erc--target
   (contents "" :type string)
   (tags '() :type list))
 
+;; TODO move goodies modules here after 29 is released.
+(defconst erc--features-to-modules
+  '((erc-pcomplete completion pcomplete)
+    (erc-capab capab-identify)
+    (erc-join autojoin)
+    (erc-page page ctcp-page)
+    (erc-sound sound ctcp-sound)
+    (erc-stamp stamp timestamp)
+    (erc-services services nickserv))
+  "Migration alist mapping a library feature to module names.
+Keys need not be unique: a library may define more than one
+module.  Sometimes a module's downcased alias will be its
+canonical name.")
+
+(defconst erc--modules-to-features
+  (let (pairs)
+    (pcase-dolist (`(,feature . ,names) erc--features-to-modules)
+      (dolist (name names)
+        (push (cons name feature) pairs)))
+    (nreverse pairs))
+  "Migration alist mapping a module's name to its home library feature.")
+
+(defconst erc--module-name-migrations
+  (let (pairs)
+    (pcase-dolist (`(,_ ,canonical . ,rest) erc--features-to-modules)
+      (dolist (obsolete rest)
+        (push (cons obsolete canonical) pairs)))
+    pairs)
+  "Association list of obsolete module names to canonical names.")
+
+(defun erc--normalize-module-symbol (symbol)
+  "Return preferred SYMBOL for `erc-modules'."
+  (setq symbol (intern (downcase (symbol-name symbol))))
+  (or (cdr (assq symbol erc--module-name-migrations)) symbol))
+
 (defmacro define-erc-module (name alias doc enable-body disable-body
                                   &optional local-p)
   "Define a new minor mode using ERC conventions.
@@ -98,7 +133,9 @@ define-erc-module
 
 This will define a minor mode called erc-NAME-mode, possibly
 an alias erc-ALIAS-mode, as well as the helper functions
-erc-NAME-enable, and erc-NAME-disable.
+erc-NAME-enable, and erc-NAME-disable.  Beware that for global
+modules, these helpers, as well as the minor-mode toggle, all mutate
+the user option `erc-modules'.
 
 Example:
 
@@ -111,6 +148,7 @@ define-erc-module
                   #\\='erc-replace-insert)))"
   (declare (doc-string 3) (indent defun))
   (let* ((sn (symbol-name name))
+         (mod (erc--normalize-module-symbol name))
          (mode (intern (format "erc-%s-mode" (downcase sn))))
          (group (intern (format "erc-%s" (downcase sn))))
          (enable (intern (format "erc-%s-enable" (downcase sn))))
@@ -134,16 +172,20 @@ define-erc-module
          ,(format "Enable ERC %S mode."
                   name)
          (interactive)
-         (add-to-list 'erc-modules (quote ,name))
-         (setq ,mode t)
-         ,@enable-body)
+         ,@(unless local-p `((cl-pushnew ',mod erc-modules)))
+         ,@(if local-p
+               `((when (setq ,mode (and (derived-mode-p 'erc-mode) t))
+                   ,@enable-body))
+             `((setq ,mode t) ,@enable-body)))
        (defun ,disable ()
          ,(format "Disable ERC %S mode."
                   name)
          (interactive)
-         (setq erc-modules (delq (quote ,name) erc-modules))
-         (setq ,mode nil)
-         ,@disable-body)
+         ,@(unless local-p `((setq erc-modules (delq ',mod erc-modules))))
+         ,@(macroexp-unprogn
+            `(,@(if local-p '(when (derived-mode-p 'erc-mode)) '(progn))
+              (setq ,mode nil)
+              ,@disable-body)))
        ,(when (and alias (not (eq name alias)))
           `(defalias
              ',(intern
diff --git a/lisp/erc/erc-compat.el b/lisp/erc/erc-compat.el
index 03bd8f1352..96b862c8c5 100644
--- a/lisp/erc/erc-compat.el
+++ b/lisp/erc/erc-compat.el
@@ -168,6 +168,18 @@ erc-compat--with-memoization
     `(cl--generic-with-memoization ,table ,@forms))
    (t `(progn ,@forms))))
 
+(defun erc-compat--local-module-modes ()
+  (delq nil
+        (if (boundp 'local-minor-modes)
+            (mapcar (lambda (m)
+                      (and (string-prefix-p "erc-" (symbol-name m)) m))
+                    local-minor-modes)
+          (mapcar (pcase-lambda (`(,k . _))
+                    (and (string-prefix-p "erc-" (symbol-name k))
+                         (string-suffix-p "-mode" (symbol-name k))
+                         k))
+                  (buffer-local-variables)))))
+
 (provide 'erc-compat)
 
 ;;; erc-compat.el ends here
diff --git a/lisp/erc/erc-goodies.el b/lisp/erc/erc-goodies.el
index 59b5f01f23..1af83b58ba 100644
--- a/lisp/erc/erc-goodies.el
+++ b/lisp/erc/erc-goodies.el
@@ -31,6 +31,7 @@
 
 ;;; Imenu support
 
+(eval-when-compile (require 'cl-lib))
 (require 'erc-common)
 
 (defvar erc-controls-highlight-regexp)
diff --git a/lisp/erc/erc.el b/lisp/erc/erc.el
index 63379af141..3d8afe8df6 100644
--- a/lisp/erc/erc.el
+++ b/lisp/erc/erc.el
@@ -1784,10 +1784,7 @@ erc-migrate-modules
   "Migrate old names of ERC modules to new ones."
   ;; modify `transforms' to specify what needs to be changed
   ;; each item is in the format '(old . new)
-  (let ((transforms '((pcomplete . completion))))
-    (delete-dups
-     (mapcar (lambda (m) (or (cdr (assoc m transforms)) m))
-             mods))))
+  (delete-dups (mapcar #'erc--normalize-module-symbol mods)))
 
 (defcustom erc-modules '(netsplit fill button match track completion readonly
                                   networks ring autojoin noncommands irccontrols
@@ -1865,28 +1862,25 @@ erc-modules
     (repeat :tag "Others" :inline t symbol))
   :group 'erc)
 
-(defun erc-update-modules ()
-  "Run this to enable erc-foo-mode for all modules in `erc-modules'."
-  (let (req)
-    (dolist (mod erc-modules)
-      (setq req (concat "erc-" (symbol-name mod)))
-      (cond
-       ;; yuck. perhaps we should bring the filenames into sync?
-       ((string= req "erc-capab-identify")
-        (setq req "erc-capab"))
-       ((string= req "erc-completion")
-        (setq req "erc-pcomplete"))
-       ((string= req "erc-pcomplete")
-        (setq mod 'completion))
-       ((string= req "erc-autojoin")
-        (setq req "erc-join")))
-      (condition-case nil
-          (require (intern req))
-        (error nil))
-      (let ((sym (intern-soft (concat "erc-" (symbol-name mod) "-mode"))))
-        (if (fboundp sym)
-            (funcall sym 1)
-          (error "`%s' is not a known ERC module" mod))))))
+(defun erc-update-modules (&optional defer-locals)
+  "Enable global minor mode for all global modules in `erc-modules'.
+With DEFER-LOCALS, return minor-mode commands for all local
+modules, possibly for deferred invocation, as done by `erc-open'
+whenever a new ERC buffer is created.  Local modules were
+introduced in ERC 5.5."
+  (let ((local-modes
+         (when (and defer-locals (derived-mode-p 'erc-mode))
+           (erc-compat--local-module-modes))))
+    (dolist (module erc-modules (and defer-locals local-modes))
+      (require (or (alist-get module erc--modules-to-features)
+                   (intern (concat "erc-" (symbol-name module))))
+               nil 'noerror) ; some modules don't have a corresponding feature
+      (let ((mode (intern-soft (concat "erc-" (symbol-name module) "-mode"))))
+        (unless (and mode (fboundp mode))
+          (error "`%s' is not a known ERC module" module))
+        (if (and defer-locals (not (custom-variable-p mode)))
+            (cl-pushnew mode local-modes)
+          (funcall mode 1))))))
 
 (defun erc-setup-buffer (buffer)
   "Consults `erc-join-buffer' to find out how to display `BUFFER'."
@@ -1942,18 +1936,24 @@ erc-open
   (let* ((target (and channel (erc--target-from-string channel)))
          (buffer (erc-get-buffer-create server port nil target id))
          (old-buffer (current-buffer))
-         old-point
+         (old-recon-count erc-server-reconnect-count)
+         (old-point nil)
+         (delayed-modules nil)
          (continued-session (and erc--server-reconnecting
                                  (with-suppressed-warnings
                                      ((obsolete erc-reuse-buffers))
-                                   erc-reuse-buffers))))
+                                   erc-reuse-buffers)
+                                 erc-networks--id)))
     (when connect (run-hook-with-args 'erc-before-connect server port nick))
-    (erc-update-modules)
     (set-buffer buffer)
     (setq old-point (point))
-    (let ((old-recon-count erc-server-reconnect-count))
-      (erc-mode)
-      (setq erc-server-reconnect-count old-recon-count))
+    (setq delayed-modules (erc-update-modules 'defer-locals))
+
+    (delay-mode-hooks (erc-mode))
+
+    (setq erc-server-reconnect-count old-recon-count
+          erc--server-reconnecting continued-session)
+
     (when (setq erc-server-connected (not connect))
       (setq erc-server-announced-name
             (buffer-local-value 'erc-server-announced-name old-buffer)))
@@ -2010,14 +2010,19 @@ erc-open
     (setq erc-session-client-certificate client-certificate)
     (setq erc-networks--id
           (if connect
-              (or (and continued-session
-                       (buffer-local-value 'erc-networks--id old-buffer))
+              (or erc--server-reconnecting
                   (and id (erc-networks--id-create id)))
             (buffer-local-value 'erc-networks--id old-buffer)))
     ;; debug output buffer
     (setq erc-dbuf
           (when erc-log-p
             (get-buffer-create (concat "*ERC-DEBUG: " server "*"))))
+
+    (erc-determine-parameters server port nick full-name user passwd)
+
+    (save-excursion (run-mode-hooks))
+    (dolist (mod delayed-modules) (funcall mod +1))
+
     ;; set up prompt
     (unless continued-session
       (goto-char (point-max))
@@ -2029,8 +2034,6 @@ erc-open
       (erc-display-prompt)
       (goto-char (point-max)))
 
-    (erc-determine-parameters server port nick full-name user passwd)
-
     ;; Saving log file on exit
     (run-hook-with-args 'erc-connect-pre-hook buffer)
 
diff --git a/test/lisp/erc/erc-tests.el b/test/lisp/erc/erc-tests.el
index c88dd9888d..d074b36c8b 100644
--- a/test/lisp/erc/erc-tests.el
+++ b/test/lisp/erc/erc-tests.el
@@ -953,4 +953,62 @@ erc-message
     (kill-buffer "ExampleNet")
     (kill-buffer "#chan")))
 
+(ert-deftest erc-migrate-modules ()
+  (should (equal (erc-migrate-modules '(autojoin timestamp button))
+                 '(autojoin stamp button)))
+  ;; Default unchanged
+  (should (equal (erc-migrate-modules erc-modules) erc-modules)))
+
+(ert-deftest erc-update-modules ()
+  (let (calls
+        erc-modules
+        erc-kill-channel-hook erc-kill-server-hook erc-kill-buffer-hook)
+    (cl-letf (((symbol-function 'require)
+               (lambda (s &rest _) (push s calls)))
+
+              ;; Local modules
+              ((symbol-function 'erc-fake-bar-mode)
+               (lambda (n) (push (cons 'fake-bar n) calls)))
+
+              ;; Global modules
+              ((symbol-function 'erc-fake-foo-mode)
+               (lambda (n) (push (cons 'fake-foo n) calls)))
+              ((get 'erc-fake-foo-mode 'standard-value) 'ignore)
+              ((symbol-function 'erc-autojoin-mode)
+               (lambda (n) (push (cons 'autojoin n) calls)))
+              ((get 'erc-autojoin-mode 'standard-value) 'ignore)
+              ((symbol-function 'erc-networks-mode)
+               (lambda (n) (push (cons 'networks n) calls)))
+              ((get 'erc-networks-mode 'standard-value) 'ignore)
+              ((symbol-function 'erc-completion-mode)
+               (lambda (n) (push (cons 'completion n) calls)))
+              ((get 'erc-completion-mode 'standard-value) 'ignore))
+
+      (ert-info ("Local modules")
+        (setq erc-modules '(fake-foo fake-bar))
+        (should (equal (erc-update-modules t) '(erc-fake-bar-mode)))
+        ;; Bar the feature is still required but the mode is not activated
+        (should (equal (nreverse calls)
+                       '(erc-fake-foo (fake-foo . 1) erc-fake-bar)))
+        (setq calls nil))
+
+      (ert-info ("Module name overrides")
+        (setq erc-modules '(completion autojoin networks))
+        (should-not (erc-update-modules t)) ; no locals
+        (should (equal (nreverse calls) '( erc-pcomplete (completion . 1)
+                                           erc-join (autojoin . 1)
+                                           erc-networks (networks . 1))))
+        (setq calls nil))
+
+      (ert-info ("Reenabling of local minor modes by `erc-open'")
+        (with-temp-buffer
+          (erc-mode)
+          (setq erc-modules '(completion autojoin networks))
+          (let ((local-minor-modes '(font-lock-mode erc-fake-bar-mode)))
+            (should (equal (erc-update-modules t) '(erc-fake-bar-mode))))
+          (should (equal (nreverse calls)
+                         '( erc-pcomplete (completion . 1)
+                            erc-join (autojoin . 1)
+                            erc-networks (networks . 1)))))))))
+
 ;;; erc-tests.el ends here
-- 
2.38.1


[-- Warning: decoded text below may be mangled, UTF-8 assumed --]
[-- Attachment #6: 0004-Call-erc-login-indirectly-via-new-generic-wrapper.patch --]
[-- Type: text/x-patch, Size: 1981 bytes --]

From d7a7309214089aee49ce547816ba39c1ae0672ce Mon Sep 17 00:00:00 2001
From: "F. Jason Park" <jp@neverwas.me>
Date: Sun, 18 Sep 2022 01:49:23 -0700
Subject: [PATCH 4/5] Call erc-login indirectly via new generic wrapper

* lisp/erc/erc-backend (erc--register-connection): Add new generic
function that defers to `erc-login' by default.
(erc-process-sentinel, erc-server-connect): Call
`erc--register-connection' instead of `erc-login'.
---
 lisp/erc/erc-backend.el | 8 ++++++--
 1 file changed, 6 insertions(+), 2 deletions(-)

diff --git a/lisp/erc/erc-backend.el b/lisp/erc/erc-backend.el
index 2c8c4dcb28..37a3da8b66 100644
--- a/lisp/erc/erc-backend.el
+++ b/lisp/erc/erc-backend.el
@@ -625,6 +625,10 @@ erc-open-network-stream
   (let ((p (plist-put parameters :nowait t)))
     (apply #'open-network-stream name buffer host service p)))
 
+(cl-defmethod erc--register-connection ()
+  "Perform opening IRC protocol exchange with server."
+  (erc-login))
+
 (defun erc-server-connect (server port buffer &optional client-certificate)
   "Perform the connection and login using the specified SERVER and PORT.
 We will store server variables in the buffer given by BUFFER.
@@ -673,7 +677,7 @@ erc-server-connect
         ;; waiting for a non-blocking connect - keep the user informed
         (erc-display-message nil nil buffer "Opening connection..\n")
       (message "%s...done" msg)
-      (erc-login)) ))
+      (erc--register-connection))))
 
 (defun erc-server-reconnect ()
   "Reestablish the current IRC connection.
@@ -851,7 +855,7 @@ erc-process-sentinel
                   cproc (process-status cproc) event erc-server-quitting))
         (if (string-match "^open" event)
             ;; newly opened connection (no wait)
-            (erc-login)
+            (erc--register-connection)
           ;; assume event is 'failed
           (erc-with-all-buffers-of-server cproc nil
                                           (setq erc-server-connected nil))
-- 
2.38.1


[-- Warning: decoded text below may be mangled, UTF-8 assumed --]
[-- Attachment #7: 0005-Add-non-IRCv3-SASL-module-to-ERC.patch --]
[-- Type: text/x-patch, Size: 73110 bytes --]

From ba6fae5c2851e2926e20e21c8dc962977c94987a Mon Sep 17 00:00:00 2001
From: "F. Jason Park" <jp@neverwas.me>
Date: Mon, 12 Jul 2021 03:44:28 -0700
Subject: [PATCH 5/5] Add non-IRCv3 SASL module to ERC

* lisp/erc/erc-compat.el (erc-compat--sasl-scram-construct-gs2-header,
erc-compat--sasl-scram-client-first-message,
erc-compat--sasl-scram--client-final-message): Add minimal
authorization support via own variant of
`sasl-scram--client-final-message' and supporting sasl-scram-rfc
functions introduced in Emacs 29.

* lisp/erc/erc.el (erc-modules): Add `sasl'.
* lisp/erc/erc-sasl.el: New file (bug#29108).
* test/lisp/erc/erc-sasl-tests.el: New file.
* test/lisp/erc/erc-scenarios-sasl.el: New file.
* test/lisp/erc/resources/sasl/plain-failed.eld: New file.
* test/lisp/erc/resources/sasl/plain.eld: New file.
* test/lisp/erc/resources/sasl/scram-sha-1.eld: New file.
* test/lisp/erc/resources/sasl/scram-sha-256.eld: New file.
* test/lisp/erc/resources/sasl/external.eld: New file.
---
 doc/misc/erc.texi                             | 137 +++++-
 etc/ERC-NEWS                                  |   7 +-
 lisp/erc/erc-compat.el                        | 104 +++++
 lisp/erc/erc-sasl.el                          | 433 ++++++++++++++++++
 lisp/erc/erc.el                               |   1 +
 test/lisp/erc/erc-sasl-tests.el               | 319 +++++++++++++
 test/lisp/erc/erc-scenarios-sasl.el           | 208 +++++++++
 test/lisp/erc/erc-tests.el                    |   9 +-
 test/lisp/erc/resources/sasl/external.eld     |  33 ++
 test/lisp/erc/resources/sasl/plain-failed.eld |  16 +
 test/lisp/erc/resources/sasl/plain.eld        |  39 ++
 test/lisp/erc/resources/sasl/scram-sha-1.eld  |  47 ++
 .../lisp/erc/resources/sasl/scram-sha-256.eld |  47 ++
 13 files changed, 1393 insertions(+), 7 deletions(-)
 create mode 100644 lisp/erc/erc-sasl.el
 create mode 100644 test/lisp/erc/erc-sasl-tests.el
 create mode 100644 test/lisp/erc/erc-scenarios-sasl.el
 create mode 100644 test/lisp/erc/resources/sasl/external.eld
 create mode 100644 test/lisp/erc/resources/sasl/plain-failed.eld
 create mode 100644 test/lisp/erc/resources/sasl/plain.eld
 create mode 100644 test/lisp/erc/resources/sasl/scram-sha-1.eld
 create mode 100644 test/lisp/erc/resources/sasl/scram-sha-256.eld

diff --git a/doc/misc/erc.texi b/doc/misc/erc.texi
index 5049710b32..8eb33c8e80 100644
--- a/doc/misc/erc.texi
+++ b/doc/misc/erc.texi
@@ -78,6 +78,7 @@ Top
 Advanced Usage
 
 * Connecting::                  Ways of connecting to an IRC server.
+* SASL::                        Authenticating via SASL.
 * Sample Configuration::        An example configuration file.
 * Options::                     Options that are available for ERC.
 
@@ -485,6 +486,10 @@ Modules
 @item ring
 Enable an input history
 
+@cindex modules, sasl
+@item sasl
+Enable SASL authentication
+
 @cindex modules, scrolltobottom
 @item scrolltobottom
 Scroll to the bottom of the buffer
@@ -532,6 +537,7 @@ Advanced Usage
 
 @menu
 * Connecting::                  Ways of connecting to an IRC server.
+* SASL::                        Authenticating via SASL
 * Sample Configuration::        An example configuration file.
 * Options::                     Options that are available for ERC.
 @end menu
@@ -849,6 +855,7 @@ Connecting
 @noindent
 For details, @pxref{Top,,auth-source, auth, Emacs auth-source Library}.
 
+@anchor{ERC auth-source functions}
 @defopt erc-auth-source-server-function
 @end defopt
 @defopt erc-auth-source-services-function
@@ -861,7 +868,8 @@ Connecting
 @code{:user} is the ``desired'' nickname rather than the current one.
 Generalized names, like @code{:user} and @code{:host}, are always used
 over back-end specific ones, like @code{:login} or @code{:machine}.
-ERC expects a string to use as the secret or nil, if the search fails.
+ERC expects a string to use as the secret or @code{nil}, if the search
+fails.
 
 @findex erc-auth-source-search
 The default value for all three options is the function
@@ -922,6 +930,133 @@ Connecting
 make the most sense, but any reasonably printable object is
 acceptable.
 
+@node SASL
+@section Authenticating via SASL
+@cindex SASL
+
+@strong{Warning:} ERC's SASL offering is currently limited by a lack
+of support for proper IRCv3 capability negotiation.  In most cases,
+this shouldn't affect your ability to authenticate.  If you run into
+trouble, please contact us (@pxref{Getting Help and Reporting Bugs}).
+
+Regardless of the mechanism or the network, you'll likely have to be
+registered before first use.  Please refer to the network's own
+instructions for details.  If you're new to IRC and using a bouncer,
+know that you almost certainly won't be needing SASL for the
+@samp{client -> bouncer} connection.
+
+Note that @code{sasl} is a ``local'' ERC module, which various library
+functions, like @code{erc-update-modules}, may treat differently than
+global modules in user code.  However, this should not affect everyday
+client use.  To get started, just add @code{sasl} to
+@code{erc-modules} like any other module.  But before that, please
+explore all custom options pertaining to your chosen mechanism.
+
+@defopt erc-sasl-mechanism
+The name of an SASL subprotocol type as a @emph{lowercase} symbol.
+
+@var{plain} and @var{scram} (``password-based''):
+
+@indentedblock
+Here, ``password'' refers to your account password, which is usually
+your @samp{NickServ} password.  This often differs from any connection
+(server) password given to @code{erc-tls} via its @code{:password}
+parameter.  To make this work, customize both @code{erc-sasl-user} and
+@code{erc-sasl-password} or bind them when invoking @code{erc-tls}.
+@end indentedblock
+
+@var{external} (via Client TLS Certificate):
+
+@indentedblock
+You'll want to specify the @code{:client-certificate} param when
+opening a new connection, which is typically done by calling
+@code{emacs-tls}.  But before that, ensure you've registered your
+fingerprint with the network.  The fingerprint is usually a SHA1 or
+SHA256 digest in either "normalized" or "openssl" forms.  The first is
+lowercase without delims (@samp{deadbeef}) and the second uppercase
+with colon seps (@samp{DE:AD:BE:EF}).
+
+Additional considerations:
+@enumerate
+@item
+There's no reason to send your password after registering.
+@item
+Most IRCds will allow you to authenticate with a client cert but
+without the hassle of SASL (meaning you may not need this module).
+@item
+Technically, @var{EXTERNAL} merely indicates that an out-of-band mode
+of authentication is in effect (being deferred to), so depending on
+the specific application or service, there's an off chance client
+certs aren't involved.
+@end enumerate
+@end indentedblock
+
+@var{ecdsa-nist256p-challenge}:
+
+@indentedblock
+This mechanism is quite complicated and currently requires the
+external @samp{openssl} executable, so please use something else if at
+all possible.  Ignoring that, specify your key file (e.g.,
+@samp{~/pki/mykey.pem}) as the value of @code{erc-sasl-password}, and
+then configure your network settings.  On servers running Atheme
+services, you can add your public key with @samp{NickServ} like so:
+
+@example
+ERC> /msg NickServ set property \
+     pubkey AgGZmlYTUjJlea/BVz7yrjJ6gysiAPaQxzeUzTH4hd5j
+
+@end example
+(You may be able to omit the @samp{property} subcommand.)
+@end indentedblock
+
+@end defopt
+
+@defopt erc-sasl-user
+This should be your network account name, typically the same one
+registered with nickname services.  Specify this when your
+@samp{NickServ} account name differs from the nick you're connecting
+with.
+@end defopt
+
+@defopt erc-sasl-password
+For ``password-based'' mechanisms, ERC sends any nonempty string as
+the authentication password.
+
+If you instead give a non-@code{nil} symbol, like @samp{Libera.Chat},
+ERC will use it for the @code{:host} field in an auth-source query.
+Actually, the same goes for when this option is @code{nil} but an
+explicit session ID is already on file (@pxref{Network Identifier}).
+For all such queries, ERC specifies the value of @code{erc-sasl-user}
+for the @code{:user} (@code{:login}) param.  Keep in mind that none of
+this matters unless @code{erc-sasl-auth-source-function} holds a
+function (it's @code{nil} by default).
+
+Otherwise, if you set this option to @code{nil} (or the empty string)
+or if an auth-source lookup has failed, ERC will try a non-@code{nil}
+``server password,'' likely whatever you gave as the @var{password}
+argument to @code{erc-tls}.  This fallback behavior may change,
+however, so please don't rely on it.  As a last resort, ERC will
+prompt you for input.
+
+Also, if your mechanism is @code{ecdsa-nist256p-challenge}, this
+option should instead hold the file name of your key.
+@end defopt
+
+@defopt erc-sasl-auth-source-function
+This is nearly identical to the other ERC @samp{auth-source} function
+options (@pxref{ERC auth-source functions}) except that the default
+value here is @code{nil}, meaning you have to set it to something like
+@code{erc-auth-source-search} for queries to be performed.
+@end defopt
+
+@defopt erc-sasl-authzid
+In the rarest of circumstances, a network may want you to specify a
+specific role or assume an alternate identity.  In most cases, this
+happens because the server is buggy or misconfigured.  If you suspect
+such a thing, please contact your network operator.  Otherwise, just
+leave this set to @code{nil}.
+@end defopt
+
 
 @node Sample Configuration
 @section Sample Configuration
diff --git a/etc/ERC-NEWS b/etc/ERC-NEWS
index e14cd3492a..f5b14376ad 100644
--- a/etc/ERC-NEWS
+++ b/etc/ERC-NEWS
@@ -48,10 +48,9 @@ hell.  For some, auth-source may provide a workaround in the form of
 nonstandard server passwords.  See the "Connection" node in the manual
 under the subheading "Password".
 
-If you require SASL immediately, please participate in ERC development
-by volunteering to try (and give feedback on) edge features, one of
-which is SASL.  All known external offerings, past and present, are
-valiant efforts whose use is nevertheless discouraged.
+** Rudimentary SASL support has arrived.
+A new module, 'erc-sasl', now ships with ERC 5.5.  See the SASL
+section in the manual for details.
 
 ** Username argument for entry-point commands.
 Commands 'erc' and 'erc-tls' now accept a ':user' keyword argument,
diff --git a/lisp/erc/erc-compat.el b/lisp/erc/erc-compat.el
index 96b862c8c5..d4a2e312be 100644
--- a/lisp/erc/erc-compat.el
+++ b/lisp/erc/erc-compat.el
@@ -157,6 +157,110 @@ erc-subseq
 	       res))))))
 
 
+;;;; SASL
+
+(declare-function sasl-step-data "sasl" (step))
+(declare-function sasl-error "sasl" (datum))
+(declare-function sasl-client-property "sasl" (client property))
+(declare-function sasl-client-set-property "sasl" (client property value))
+(declare-function sasl-mechanism-name "sasl" (mechanism))
+(declare-function sasl-client-name "sasl" (client))
+(declare-function sasl-client-mechanism "sasl" (client))
+(declare-function sasl-read-passphrase "sasl" (prompt))
+(declare-function sasl-unique-id "sasl" nil)
+(declare-function decode-hex-string "hex-util" (string))
+(declare-function rfc2104-hash "rfc2104" (hash block-length hash-length
+                                               key text))
+(declare-function sasl-scram--client-first-message-bare "sasl-scram-rfc"
+                  (client))
+(declare-function cl-mapcar "cl-lib" (cl-func cl-x &rest cl-rest))
+
+(defun erc-compat--sasl-scram-construct-gs2-header (client)
+  ;; The "n," means the client doesn't support channel binding, and
+  ;; the trailing comma is included as per RFC 5801.
+  (let ((authzid (sasl-client-property client 'authenticator-name)))
+    (concat "n," (and authzid "a=") authzid ",")))
+
+(defun erc-compat--sasl-scram-client-first-message (client _step)
+  (let ((c-nonce (sasl-unique-id)))
+    (sasl-client-set-property client 'c-nonce c-nonce))
+  (concat (erc-compat--sasl-scram-construct-gs2-header client)
+          (sasl-scram--client-first-message-bare client)))
+
+;; This is `sasl-scram--client-final-message' from sasl-scram-rfc,
+;; with the NO-LINE-BREAK argument of `base64-encode-string' set to t
+;; because https://www.rfc-editor.org/rfc/rfc5802#section-2.1 says:
+;;
+;;  > The use of base64 in SCRAM is restricted to the canonical form
+;;  > with no whitespace.
+;;
+;; Unfortunately, simply advising `base64-encode-string' won't work
+;; since the byte compiler precomputes the result when all inputs are
+;; constants, as they are in the original version.
+;;
+;; The only other substantial change is the addition of authz support.
+;; This can be dropped if adopted by Emacs 29 and `compat'.  Changes
+;; proposed for 29 are marked with a "; *n", comment below.  See older
+;; versions of lisp/erc/erc-v3-sasl.el (bug#49860) if needing a true
+;; side-by-side diff.  This also inlines the internal function
+;; `sasl-scram--client-first-message-bare' and takes various liberties
+;; with formatting.
+
+(defun erc-compat--sasl-scram--client-final-message
+    (hash-fun block-length hash-length client step)
+  (unless (string-match
+           "^r=\\([^,]+\\),s=\\([^,]+\\),i=\\([0-9]+\\)\\(?:$\\|,\\)"
+           (sasl-step-data step))
+    (sasl-error "Unexpected server response"))
+  (let* ((hmac-fun
+          (lambda (text key)
+            (decode-hex-string
+             (rfc2104-hash hash-fun block-length hash-length key text))))
+         (step-data (sasl-step-data step))
+         (nonce (match-string 1 step-data))
+         (salt-base64 (match-string 2 step-data))
+         (iteration-count (string-to-number (match-string 3 step-data)))
+         (c-nonce (sasl-client-property client 'c-nonce))
+         (cbind-input
+          (if (string-prefix-p c-nonce nonce)
+              (erc-compat--sasl-scram-construct-gs2-header client) ; *1
+            (sasl-error "Invalid nonce from server")))
+         (client-final-message-without-proof
+          (concat "c=" (base64-encode-string cbind-input t) "," ; *2
+                  "r=" nonce))
+         (password
+          (sasl-read-passphrase
+           (format "%s passphrase for %s: "
+                   (sasl-mechanism-name (sasl-client-mechanism client))
+                   (sasl-client-name client))))
+         (salt (base64-decode-string salt-base64))
+         (string-xor (lambda (a b)
+                       (apply #'unibyte-string (cl-mapcar #'logxor a b))))
+         (salted-password (let ((digest (concat salt (string 0 0 0 1)))
+                                (xored nil))
+                            (dotimes (_i iteration-count xored)
+                              (setq digest (funcall hmac-fun digest password))
+                              (setq xored (if (null xored)
+                                              digest
+                                            (funcall string-xor xored
+                                                     digest))))))
+         (client-key (funcall hmac-fun "Client Key" salted-password))
+         (stored-key (decode-hex-string (funcall hash-fun client-key)))
+         (auth-message (concat "n=" (sasl-client-name client)
+                               ",r=" c-nonce "," step-data
+                               "," client-final-message-without-proof))
+         (client-signature (funcall hmac-fun
+                                    (encode-coding-string auth-message 'utf-8)
+                                    stored-key))
+         (client-proof (funcall string-xor client-key client-signature))
+         (client-final-message
+          (concat client-final-message-without-proof ","
+                  "p=" (base64-encode-string client-proof t)))) ; *3
+    (sasl-client-set-property client 'auth-message auth-message)
+    (sasl-client-set-property client 'salted-password salted-password)
+    client-final-message))
+
+
 ;;;; Misc 29.1
 
 (defmacro erc-compat--with-memoization (table &rest forms)
diff --git a/lisp/erc/erc-sasl.el b/lisp/erc/erc-sasl.el
new file mode 100644
index 0000000000..a9d7ed235d
--- /dev/null
+++ b/lisp/erc/erc-sasl.el
@@ -0,0 +1,433 @@
+;;; erc-sasl.el --- SASL for ERC -*- lexical-binding: t -*-
+
+;; Copyright (C) 2022 Free Software Foundation, Inc.
+;;
+;; This file is part of GNU Emacs.
+;;
+;; GNU Emacs is free software: you can redistribute it and/or modify
+;; it under the terms of the GNU General Public License as published
+;; by the Free Software Foundation, either version 3 of the License,
+;; or (at your option) any later version.
+;;
+;; GNU Emacs is distributed in the hope that it will be useful, but
+;; WITHOUT ANY WARRANTY; without even the implied warranty of
+;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+;; General Public License for more details.
+;;
+;; You should have received a copy of the GNU General Public License
+;; along with GNU Emacs.  If not, see <https://www.gnu.org/licenses/>.
+
+;;; Commentary:
+
+;; This "non-IRCv3" implementation resembles others that have surfaced
+;; over the years, the first possibly being from Joseph Gay:
+;;
+;; https://lists.gnu.org/archive/html/erc-discuss/2012-02/msg00001.html
+;;
+;; See options and Info manual for usage.
+;;
+;; TODO:
+;;
+;; - Find a way to obfuscate the password in memory (via something
+;;   like `auth-source--obfuscate'); it's currently visible in
+;;   backtraces and bug reports.
+;;
+;; - Implement a proxy mechanism that chooses the strongest available
+;;   mechanism for you.  Requires CAP 3.2 (see bug#49860).
+;;
+;; - Integrate with whatever solution ERC eventually settles on to
+;;   handle user options for different network contexts.  At the
+;;   moment, this does its own thing for stashing and restoring
+;;   session options, but ERC should make abstractions available for
+;;   all local modules to use, possibly based on connection-local
+;;   variables.
+
+;;; Code:
+(require 'erc)
+(require 'rx)
+(require 'sasl)
+(require 'sasl-scram-rfc)
+(require 'sasl-scram-sha256 nil t) ; not present in Emacs 27
+
+(defgroup erc-sasl nil
+  "SASL for ERC."
+  :group 'erc
+  :package-version '(ERC . "5.4.1")) ; FIXME increment on next release
+
+(defcustom erc-sasl-mechanism 'plain
+  "SASL mechanism to connect with.
+Note that any value other than nil or `external' likely requires
+`erc-sasl-user' and `erc-sasl-password'."
+  :type '(choice (const plain)
+                 (const external)
+                 (const scram-sha-1)
+                 (const scram-sha-256)
+                 (const scram-sha-512)
+                 (const ecdsa-nist256p-challenge)))
+
+(defcustom erc-sasl-user nil
+  "Optional account username to send when authenticating.
+This is also referred to as the authentication identity, or
+\"authcid\".  When nil, applicable mechanisms will use the
+session's current nick."
+  :type '(choice string (const nil)))
+
+(defcustom erc-sasl-password nil
+  "Optional account password to send when authenticating.
+When the value is a string, ERC will use it unconditionally for
+most mechanisms.  Otherwise, when `erc-sasl-auth-source-function'
+is a function, ERC will attempt an auth-source query, possibly
+using a non-nil symbol for the suggested `:host' parameter if set
+as this option's value or passed as an `:id' to `erc-tls'.
+Failing that, ERC will try a non-nil \"session password\" if one
+is on file, typically from a `:password' argument supplied to
+`erc-tls'.  As a last resort, ERC will prompt for input.
+
+Note that when `erc-sasl-mechanism' is set to
+`ecdsa-nist256p-challenge', this option should hold the file name
+of the key."
+  :type '(choice (const nil) string symbol))
+
+(defcustom erc-sasl-auth-source-function nil
+  "Function to query auth-source for an SASL password.
+Called with keyword params known to `auth-source-search', which
+may include a non-nil `erc-sasl-user' for the `:user' field
+and a non-nil `erc-sasl-password' for the `:host' field, when
+the latter option is a symbol instead of a string.  In return,
+ERC expects a string to send as the SASL password, or nil, to
+move on to the next approach, as described in the doc string for
+the option `erc-sasl-password'.  See info node `(erc)
+Connecting' for details on ERC's auth-source integration."
+  :type '(choice (function-item erc-auth-source-search)
+                 (const nil)
+                 function))
+
+(defcustom erc-sasl-authzid nil
+  "SASL authorization identity, likely unneeded for everyday use."
+  :type '(choice (const nil) string))
+
+
+;; Analogous to what erc-backend does to persist opening params.
+(defvar-local erc-sasl--options nil)
+
+;; In the future, ERC will hopefully use connection-local variables to
+;; handle such bookkeeping transparently.
+(defvar erc-sasl--session-options nil
+  "An alist associating network-IDs to `erc-sasl--options'.
+This is for persisting user options captured at entry-point
+invocation throughout an Emacs session.")
+
+;; Session-local (server buffer) SASL subproto state
+(defvar-local erc-sasl--state nil)
+
+(cl-defstruct erc-sasl--state
+  "Holder for client object and subproto state."
+  (client nil :type vector)
+  (step nil :type vector)
+  (pending nil :type string))
+
+(defun erc-sasl--read-password (prompt)
+  "Return configured option or server password.
+PROMPT is passed to `read-passwd' if necessary."
+  (let* ((pass (alist-get 'password erc-sasl--options))
+         (found
+          (or (and (stringp pass) (not (string-empty-p pass)) pass)
+              (and erc-sasl-auth-source-function
+                   (let ((user (alist-get 'user erc-sasl--options))
+                         (host (or pass
+                                   (erc-networks--id-given erc-networks--id))))
+                     (apply erc-sasl-auth-source-function
+                            `(,@(and user (list :user user))
+                              ,@(and host (list :host (symbol-name host)))))))
+              erc-session-password)))
+    (if found
+        (copy-sequence found)
+      (read-passwd prompt))))
+
+(defun erc-sasl--plain-response (client steps)
+  (let ((sasl-read-passphrase #'erc-sasl--read-password))
+    (sasl-plain-response client steps)))
+
+(declare-function erc-compat--sasl-scram--client-final-message "erc-compat"
+                  (hash-fun block-length hash-length client step))
+
+(defun erc-sasl--scram-sha-hack-client-final-message (&rest args)
+  ;; In the future (29+), we'll hopefully be able to call
+  ;; `sasl-scram--client-final-message' directly
+  (require 'erc-compat)
+  (let ((sasl-read-passphrase #'erc-sasl--read-password))
+    (apply #'erc-compat--sasl-scram--client-final-message args)))
+
+(defun erc-sasl--scram-sha-1-client-final-message (client step)
+  (erc-sasl--scram-sha-hack-client-final-message 'sha1 64 20 client step))
+
+(defun erc-sasl--scram-sha-256-client-final-message (client step)
+  (erc-sasl--scram-sha-hack-client-final-message 'sasl-scram-sha256 64 32
+                                                 client step))
+
+(defun erc-sasl--scram-sha512 (object &optional start end binary)
+  (secure-hash 'sha512 object start end binary))
+
+(defun erc-sasl--scram-sha-512-client-final-message (client step)
+  (erc-sasl--scram-sha-hack-client-final-message #'erc-sasl--scram-sha512
+                                                 128 64 client step))
+
+(defun erc-sasl--scram-sha-512-authenticate-server (client step)
+  (sasl-scram--authenticate-server #'erc-sasl--scram-sha512
+                                   128 64 client step))
+
+(defun erc-sasl--ecdsa-first (client _step)
+  "Return CLIENT name."
+  (sasl-client-name client))
+
+;; FIXME do this with gnutls somehow
+(defun erc-sasl--ecdsa-sign (client step)
+  "Return signed challenge for CLIENT and current STEP."
+  (let ((challenge (sasl-step-data step)))
+    (with-temp-buffer
+      (set-buffer-multibyte nil)
+      (insert challenge)
+      (call-process-region (point-min) (point-max)
+                           "openssl" 'delete t nil "pkeyutl" "-inkey"
+                           (sasl-client-property client 'ecdsa-keyfile)
+                           "-sign")
+      (buffer-string))))
+
+(pcase-dolist
+    (`(,name . ,steps)
+     '(("PLAIN"
+        erc-sasl--plain-response)
+       ("EXTERNAL"
+        ignore)
+       ("SCRAM-SHA-1"
+        erc-compat--sasl-scram-client-first-message
+        erc-sasl--scram-sha-1-client-final-message
+        sasl-scram-sha-1-authenticate-server)
+       ("SCRAM-SHA-256"
+        erc-compat--sasl-scram-client-first-message
+        erc-sasl--scram-sha-256-client-final-message
+        sasl-scram-sha-256-authenticate-server)
+       ("SCRAM-SHA-512"
+        erc-compat--sasl-scram-client-first-message
+        erc-sasl--scram-sha-512-client-final-message
+        erc-sasl--scram-sha-512-authenticate-server)
+       ("ECDSA-NIST256P-CHALLENGE"
+        erc-sasl--ecdsa-first
+        erc-sasl--ecdsa-sign)))
+  (let ((feature (intern (concat "erc-sasl-" (downcase name)))))
+    (put feature 'sasl-mechanism (sasl-make-mechanism name steps))
+    (provide feature)))
+
+(cl-defgeneric erc-sasl--create-client (mechanism)
+  "Create and return a new SASL client object for MECHANISM."
+  (let ((sasl-mechanism-alist (copy-sequence sasl-mechanism-alist))
+        (sasl-mechanisms sasl-mechanisms)
+        (name (upcase (symbol-name mechanism)))
+        (feature (intern-soft (concat "erc-sasl-" (symbol-name mechanism))))
+        client)
+    (when feature
+      (setf (alist-get name sasl-mechanism-alist nil nil #'equal) `(,feature))
+      (cl-pushnew name sasl-mechanisms :test #'equal)
+      (setq client (sasl-make-client (sasl-find-mechanism `(,name))
+                                     (or (alist-get 'user erc-sasl--options)
+                                         (erc-downcase (erc-current-nick)))
+                                     "N/A" "N/A"))
+      (sasl-client-set-property client 'authenticator-name
+                                (alist-get 'authzid erc-sasl--options))
+      client)))
+
+(cl-defmethod erc-sasl--create-client ((_m (eql plain)))
+  "Create and return a new PLAIN client object."
+  ;; https://tools.ietf.org/html/rfc4616#section-2.
+  (let* ((sans (remq (assoc "PLAIN" sasl-mechanism-alist)
+                     sasl-mechanism-alist))
+         (sasl-mechanism-alist (cons '("PLAIN" erc-sasl-plain) sans))
+         (authc (or (alist-get 'user erc-sasl--options)
+                    (erc-downcase (erc-current-nick))))
+         (port (if (numberp erc-session-port)
+                   (number-to-string erc-session-port)
+                 "0"))
+         ;; In most cases, `erc-server-announced-name' won't be known.
+         (host (or erc-server-announced-name erc-session-server))
+         (mech (sasl-find-mechanism '("PLAIN")))
+         (client (sasl-make-client mech authc port host)))
+    (sasl-client-set-property client 'authenticator-name
+                              (alist-get 'authzid erc-sasl--options))
+    client))
+
+(cl-defmethod erc-sasl--create-client ((m (eql scram-sha-256)))
+  "Create and return a new SCRAM-SHA-256 client."
+  (unless (featurep 'sasl-scram-sha256)
+    (user-error "SASL mechanism %s unsupported" m))
+  (cl-call-next-method))
+
+(cl-defmethod erc-sasl--create-client ((m (eql scram-sha-512)))
+  "Create and return a new SCRAM-SHA-512 client."
+  (unless (featurep 'sasl-scram-sha256)
+    (user-error "SASL mechanism %s unsupported" m))
+  (cl-call-next-method))
+
+(cl-defmethod erc-sasl--create-client ((_ (eql ecdsa-nist256p-challenge)))
+  "Create and return a new ECDSA-NIST256P-CHALLENGE client."
+  (unless (executable-find "openssl")
+    (user-error "Could not find openssl command-line utility"))
+  (let ((keyfile (cdr (assq 'password erc-sasl--options))))
+    (unless (and keyfile (file-exists-p keyfile))
+      (user-error "`erc-sasl-password' does not point to ECDSA keyfile"))
+    (let ((client (cl-call-next-method)))
+      (sasl-client-set-property client 'ecdsa-keyfile keyfile)
+      client)))
+
+;; This stands alone because it's also used by bug#49860.
+(defun erc-sasl--init ()
+  ;; When reconnecting, try to recover stashed parameters.
+  (let ((existing (assoc erc-networks--id erc-sasl--session-options
+                         #'erc-networks--id-equal-p)))
+    ;; This likely only runs when `erc' was called with an :id keyword.
+    (when (and existing (not erc--server-reconnecting))
+      (setq erc-sasl--session-options (delq existing erc-sasl--session-options)
+            existing nil))
+    (setq erc-sasl--state (make-erc-sasl--state)
+          erc-sasl--options (or (cdr existing)
+                                `((user . ,erc-sasl-user)
+                                  (password . ,erc-sasl-password)
+                                  (mechanism . ,erc-sasl-mechanism)
+                                  (authzid . ,erc-sasl-authzid))))))
+
+(defun erc-sasl--on-connection-established (&rest _)
+  (setf (alist-get erc-networks--id erc-sasl--session-options nil nil
+                   #'erc-networks--id-equal-p)
+        erc-sasl--options
+        ;;
+        erc-sasl--options nil))
+
+(defun erc-sasl--mechanism-offered-p (offered)
+  "Return non-nil when OFFERED appears among a list of mechanisms."
+  (string-match-p (rx-to-string
+                   `(: (| bot ",")
+                       ,(symbol-name
+                         (alist-get 'mechanism erc-sasl--options))
+                       (| eot ",")))
+                  (downcase offered)))
+
+(defun erc-sasl--authenticate-handler (_proc parsed)
+  "Handle PARSED `erc-response' from server.
+Maybe transition to next state."
+  (if-let* ((response (car (erc-response.command-args parsed)))
+            ((= 400 (length response))))
+      (cl-callf (lambda (s) (concat s response))
+          (erc-sasl--state-pending erc-sasl--state))
+    (cl-assert response t)
+    (when (string= "+" response)
+      (setq response ""))
+    (setf response (base64-decode-string
+                    (concat (erc-sasl--state-pending erc-sasl--state)
+                            response))
+          (erc-sasl--state-pending erc-sasl--state) nil)
+    ;; The server is done sending, so our turn
+    (let ((client (erc-sasl--state-client erc-sasl--state))
+          (step (erc-sasl--state-step erc-sasl--state))
+          data)
+      (when step
+        (sasl-step-set-data step response))
+      (setq step (setf (erc-sasl--state-step erc-sasl--state)
+                       (sasl-next-step client step))
+            data (sasl-step-data step))
+      (when (string= data "")
+        (setq data nil))
+      (when data
+        (setq data (base64-encode-string data t)))
+      ;; No need for : because no spaces (right?)
+      (erc-server-send (concat "AUTHENTICATE " (or data "+"))))))
+
+(erc-define-catalog
+ 'english
+ '((s902 . "ERR_NICKLOCKED nick %n unavailable: %s")
+   (s904 . "ERR_SASLFAIL (authentication failed) %s")
+   (s905 . "ERR SASLTOOLONG (credentials too long) %s")
+   (s906 . "ERR_SASLABORTED (authentication aborted) %s")
+   (s907 . "ERR_SASLALREADY (already authenticated) %s")
+   (s908 . "RPL_SASLMECHS (unsupported mechanism %m) %s")))
+
+(define-erc-module sasl nil
+  "Non-IRCv3 SASL support for ERC.
+This doesn't solicit or validate a suite of supported mechanisms."
+  ;; See bug#49860 for a full, CAP 3.2-aware implementation, currently
+  ;; a WIP as of ERC 5.5.
+  ((unless erc--target
+     (add-hook 'erc-server-AUTHENTICATE-functions
+               #'erc-sasl--authenticate-handler 0 t)
+     (erc-sasl--init)
+     (let* ((mech (alist-get 'mechanism erc-sasl--options))
+            (client (erc-sasl--create-client mech)))
+       (unless client
+         (erc-display-error-notice
+          nil (format "Unknown SASL mechanism: %s" mech))
+         (erc-error "Unknown SASL mechanism: %s" mech))
+       (setf (erc-sasl--state-client erc-sasl--state) client))))
+  ((remove-hook 'erc-server-AUTHENTICATE-functions
+                #'erc-sasl--authenticate-handler t)
+   (setf (alist-get erc-networks--id erc-sasl--session-options nil t) nil)
+   (kill-local-variable 'erc-sasl--state)
+   (kill-local-variable 'erc-sasl--options))
+  'local)
+
+;; FIXME use generic mechanism instead of hooks after bug#49860.
+(define-erc-response-handler (AUTHENTICATE)
+  "Maybe authenticate to server." nil)
+
+(defun erc-sasl--destroy (proc)
+  (run-hook-with-args 'erc-quit-hook proc)
+  (delete-process proc)
+  (erc-error "Disconnected from %s; please review SASL settings" proc))
+
+(define-erc-response-handler (902)
+  "Handle a ERR_NICKLOCKED response." nil
+  (erc-display-message parsed '(notice error) 'active 's902
+                       ?n (car (erc-response.command-args parsed))
+                       ?s (erc-response.contents parsed))
+  (erc-sasl--destroy proc))
+
+(define-erc-response-handler (903)
+  "Handle a RPL_SASLSUCCESS response." nil
+  (when erc-sasl-mode
+    (unless erc-server-connected
+      (erc-server-send "CAP END")))
+  (add-hook 'erc-after-connect #'erc-sasl--on-connection-established 0 t)
+  (erc-handle-unknown-server-response proc parsed))
+
+(define-erc-response-handler (907)
+  "Handle a RPL_SASLALREADY response." nil
+  (erc-display-message parsed '(notice error) 'active 's907
+                       ?s (erc-response.contents parsed)))
+
+(define-erc-response-handler (904 905 906)
+  "Handle various SASL-related error responses." nil
+  (erc-display-message parsed '(notice error) 'active
+                       (intern (format "s%s" (erc-response.command parsed)))
+                       ?s (erc-response.contents parsed))
+  (erc-sasl--destroy proc))
+
+(define-erc-response-handler (908)
+  "Handle a RPL_SASLALREADY response." nil
+  (erc-display-message parsed '(notice error) 'active 's908
+                       '?m (alist-get 'mechanism erc-sasl--options)
+                       '?s (erc-response.contents parsed))
+  (erc-sasl--destroy proc))
+
+(cl-defmethod erc--register-connection (&context (erc-sasl-mode (eql t)))
+  "Send speculative/pipelined CAP and AUTHENTICATE and hope for the best."
+  (if-let* ((c (erc-sasl--state-client erc-sasl--state))
+            (m (sasl-mechanism-name (sasl-client-mechanism c))))
+      (progn
+        (erc-server-send "CAP REQ :sasl")
+        (erc-login)
+        (erc-server-send (format "AUTHENTICATE %s" m)))
+    (erc-sasl--destroy erc-server-process)))
+
+(provide 'erc-sasl)
+;;; erc-sasl.el ends here
+;;
+;; Local Variables:
+;; generated-autoload-file: "erc-loaddefs.el"
+;; End:
diff --git a/lisp/erc/erc.el b/lisp/erc/erc.el
index 3d8afe8df6..c5989dbc7e 100644
--- a/lisp/erc/erc.el
+++ b/lisp/erc/erc.el
@@ -1846,6 +1846,7 @@ erc-modules
     (const :tag "readonly: Make displayed lines read-only" readonly)
     (const :tag "replace: Replace text in messages" replace)
     (const :tag "ring: Enable an input history" ring)
+    (const :tag "sasl: Enable SASL authentication" sasl)
     (const :tag "scrolltobottom: Scroll to the bottom of the buffer"
            scrolltobottom)
     (const :tag "services: Identify to Nickserv (IRC Services) automatically"
diff --git a/test/lisp/erc/erc-sasl-tests.el b/test/lisp/erc/erc-sasl-tests.el
new file mode 100644
index 0000000000..81db9ad948
--- /dev/null
+++ b/test/lisp/erc/erc-sasl-tests.el
@@ -0,0 +1,319 @@
+;;; erc-sasl-tests.el --- Tests for erc-sasl.  -*- lexical-binding:t -*-
+
+;; Copyright (C) 2022 Free Software Foundation, Inc.
+;;
+;; This file is part of GNU Emacs.
+;;
+;; GNU Emacs is free software: you can redistribute it and/or modify
+;; it under the terms of the GNU General Public License as published by
+;; the Free Software Foundation, either version 3 of the License, or
+;; (at your option) any later version.
+;;
+;; GNU Emacs is distributed in the hope that it will be useful,
+;; but WITHOUT ANY WARRANTY; without even the implied warranty of
+;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+;; GNU General Public License for more details.
+;;
+;; You should have received a copy of the GNU General Public License
+;; along with GNU Emacs.  If not, see <https://www.gnu.org/licenses/>.
+
+;;; Commentary:
+
+;;; Code:
+
+(require 'ert-x)
+(require 'erc-sasl)
+
+(ert-deftest erc-sasl--mechanism-offered-p ()
+  (let ((erc-sasl--options '((mechanism . external))))
+    (should (erc-sasl--mechanism-offered-p "foo,external"))
+    (should (erc-sasl--mechanism-offered-p "external,bar"))
+    (should (erc-sasl--mechanism-offered-p "foo,external,bar"))
+    (should-not (erc-sasl--mechanism-offered-p "fooexternal"))
+    (should-not (erc-sasl--mechanism-offered-p "externalbar"))))
+
+(ert-deftest erc-sasl--read-password ()
+  (ert-info ("Explicit erc-sasl-password")
+    (let ((erc-sasl--options '((password . "foo"))))
+      (should (string= (erc-sasl--read-password nil) "foo"))))
+
+  (ert-info ("Fallback to erc-session-password")
+    (let ((erc-session-password "bar")
+          (erc-networks--id (erc-networks--id-create nil)))
+      (should (string= (erc-sasl--read-password nil) "bar")))
+    (let ((erc-session-password "bar")
+          (erc-sasl--options '((user . "tester") (password)))
+          (erc-networks--id (erc-networks--id-create nil)))
+      (should (string= (erc-sasl--read-password nil) "bar"))))
+
+  (let* ((entries (list
+                   "machine FSF.chat port 6697 user bob password sesame"
+                   ;; This must come *after* ^, else *1 (below) always passes
+                   "machine GNU/chat port 6697 user bob password spam"
+                   "machine MyHost port irc password 123"))
+         (netrc-file (make-temp-file "auth-source-test" nil nil
+                                     (mapconcat 'identity entries "\n")))
+         (auth-sources (list netrc-file))
+         (erc-session-server "irc.gnu.org")
+         (erc-session-port 6697)
+         (erc-networks--id (erc-networks--id-create nil))
+         ;;
+         (erc-sasl-auth-source-function #'erc--auth-source-search)
+         erc-server-announced-name ; too early
+         auth-source-do-cache)
+
+    (unwind-protect
+        (ert-info ("Auth source")
+
+          (ert-info ("Symbol as password specifies machine")
+            (let ((erc-sasl--options '((user . "bob")
+                                       (password . FSF.chat)))
+                  (erc-networks--id (make-erc-networks--id)))
+              (should (string= (erc-sasl--read-password nil) "sesame"))))
+
+          (ert-info ("Use session ID when password empty") ; *1
+            (let ((erc-sasl--options '((user . "bob") (password)))
+                  (erc-networks--id (erc-networks--id-create 'GNU/chat)))
+              (should (string= (erc-sasl--read-password nil) "spam")))))
+
+      (delete-file netrc-file))
+
+    (ert-info ("Prompt when search fails and server password null")
+      (let ((erc-sasl-auth-source-function #'ignore))
+        (should (string= (ert-simulate-keys "baz\r"
+                           (erc-sasl--read-password "pwd:"))
+                         "baz"))))))
+
+(ert-deftest erc-sasl-create-client--plain ()
+  (let* ((erc-session-password "password123")
+         (erc-server-current-nick "tester")
+         (erc-session-port 1667)
+         (erc-session-server "localhost")
+         (client (erc-sasl--create-client 'plain))
+         (result (sasl-next-step client nil)))
+    (should (equal (format "%S" [erc-sasl--plain-response
+                                 "\0tester\0password123"])
+                   (format "%S" result)))
+    (should (string= (sasl-step-data result) "\0tester\0password123"))
+    (should-not (sasl-next-step client result)))
+  (should (equal (assoc-default "PLAIN" sasl-mechanism-alist) '(sasl-plain))))
+
+(ert-deftest erc-sasl-create-client--external ()
+  (let* ((erc-server-current-nick "tester")
+         (client (erc-sasl--create-client 'external))
+         (result (sasl-next-step client nil)))
+    (should (equal (format "%S" [ignore nil]) (format "%S" result)))
+    (should-not (sasl-step-data result))
+    (should-not (sasl-next-step client result)))
+  (should-not (member "EXTERNAL" sasl-mechanisms))
+  (should-not (assoc-default "EXTERNAL" sasl-mechanism-alist)))
+
+(ert-deftest erc-sasl-create-client--scram-sha-1 ()
+  (let* ((erc-server-current-nick "jilles")
+         (erc-session-password "sesame")
+         (erc-sasl--options '((authzid . "jilles")))
+         (mock-rvs (list "c5RqLCZy0L4fGkKAZ0hujFBs" ""))
+         (sasl-unique-id-function (lambda () (pop mock-rvs)))
+         (client (erc-sasl--create-client 'scram-sha-1))
+         (step (sasl-next-step client nil)))
+    (ert-info ("Client's initial request")
+      (let ((req "n,a=jilles,n=jilles,r=c5RqLCZy0L4fGkKAZ0hujFBs"))
+        (should (equal (format "%S"
+                               `[erc-compat--sasl-scram-client-first-message
+                                 ,req])
+                       (format "%S" step)))
+        (should (string= (sasl-step-data step) req))))
+    (ert-info ("Server's initial response")
+      (let ((resp (concat "r=c5RqLCZy0L4fGkKAZ0hujFBsXQoKcivqCw9iDZPSpb,"
+                          "s=5mJO6d4rjCnsBU1X,"
+                          "i=4096"))
+            (req (concat "c=bixhPWppbGxlcyw=,"
+                         "r=c5RqLCZy0L4fGkKAZ0hujFBsXQoKcivqCw9iDZPSpb,"
+                         "p=OVUhgPu8wEm2cDoVLfaHzVUYPWU=")))
+        (sasl-step-set-data step resp)
+        (setq step (sasl-next-step client step))
+        (should (equal (format "%S"
+                               `[erc-sasl--scram-sha-1-client-final-message
+                                 ,req])
+                       (format "%S" step)))
+        (should (string= (sasl-step-data step) req))))
+    (ert-info ("Server's final message")
+      (let ((resp "v=ZWR23c9MJir0ZgfGf5jEtLOn6Ng="))
+        (sasl-step-set-data step resp)
+        (setq step (sasl-next-step client step))
+        (should-not (sasl-step-data step)))))
+  (should (eq sasl-unique-id-function #'sasl-unique-id-function)))
+
+(ert-deftest erc-sasl-create-client--scram-sha-256 ()
+  (unless (featurep 'sasl-scram-sha256)
+    (ert-skip "Emacs lacks sasl-scram-sha256"))
+  (let* ((erc-server-current-nick "jilles")
+         (erc-session-password "sesame")
+         (erc-sasl--options '((authzid . "jilles")))
+         (mock-rvs (list "c5RqLCZy0L4fGkKAZ0hujFBs" ""))
+         (sasl-unique-id-function (lambda () (pop mock-rvs)))
+         (client (erc-sasl--create-client 'scram-sha-256))
+         (step (sasl-next-step client nil)))
+    (ert-info ("Client's initial request")
+      (let ((req "n,a=jilles,n=jilles,r=c5RqLCZy0L4fGkKAZ0hujFBs"))
+        (should (equal (format "%S"
+                               `[erc-compat--sasl-scram-client-first-message
+                                 ,req])
+                       (format "%S" step)))
+        (should (string= (sasl-step-data step) req))))
+    (ert-info ("Server's initial response")
+      (let ((resp (concat
+                   "r=c5RqLCZy0L4fGkKAZ0hujFBse697140729d8445fb95ec94ceacb14b3,"
+                   "s=MTk2M2VkMzM5ZmU0NDRiYmI0MzIyOGVhN2YwNzYwNmI=,"
+                   "i=4096"))
+            (req (concat
+                  "c=bixhPWppbGxlcyw=,"
+                  "r=c5RqLCZy0L4fGkKAZ0hujFBse697140729d8445fb95ec94ceacb14b3,"
+                  "p=1vDesVBzJmv0lX0Ae1kHFtdVHkC6j4gISKVqaR45HFg=")))
+        (sasl-step-set-data step resp)
+        (setq step (sasl-next-step client step))
+        (should (equal (format "%S"
+                               `[erc-sasl--scram-sha-256-client-final-message
+                                 ,req])
+                       (format "%S" step)))
+        (should (string= (sasl-step-data step) req))))
+    (ert-info ("Server's final message")
+      (let ((resp "v=gUePTYSZN9xgcE06KSyKO9fUmSwH26qifoapXyEs75s="))
+        (sasl-step-set-data step resp)
+        (setq step (sasl-next-step client step))
+        (should-not (sasl-step-data step)))))
+  (should (eq sasl-unique-id-function #'sasl-unique-id-function)))
+
+(ert-deftest erc-sasl-create-client--scram-sha-256--no-authzid ()
+  (unless (featurep 'sasl-scram-sha256)
+    (ert-skip "Emacs lacks sasl-scram-sha256"))
+  (let* ((erc-server-current-nick "jilles")
+         (erc-session-password "sesame")
+         (mock-rvs (list "c5RqLCZy0L4fGkKAZ0hujFBs" ""))
+         (sasl-unique-id-function (lambda () (pop mock-rvs)))
+         (client (erc-sasl--create-client 'scram-sha-256))
+         (step (sasl-next-step client nil)))
+    (ert-info ("Client's initial request")
+      (let ((req "n,,n=jilles,r=c5RqLCZy0L4fGkKAZ0hujFBs"))
+        (should (equal (format "%S"
+                               `[erc-compat--sasl-scram-client-first-message
+                                 ,req])
+                       (format "%S" step)))
+        (should (string= (sasl-step-data step) req))))
+    (ert-info ("Server's initial response")
+      (let ((resp (concat
+                   "r=c5RqLCZy0L4fGkKAZ0hujFBsd4067f0afdb54c3dbd4fe645b84cae37,"
+                   "s=ZTg1MmE1YmFhZGI1NDcyMjk3NzYwZmRjZDM3Y2I1OTM=,"
+                   "i=4096"))
+            (req (concat
+                  "c=biws,"
+                  "r=c5RqLCZy0L4fGkKAZ0hujFBsd4067f0afdb54c3dbd4fe645b84cae37,"
+                  "p=LP4sjJrjJKp5qTsARyZCppXpKLu4FMM284hNESPvGhI=")))
+        (sasl-step-set-data step resp)
+        (setq step (sasl-next-step client step))
+        (should (equal (format "%S"
+                               `[erc-sasl--scram-sha-256-client-final-message
+                                 ,req])
+                       (format "%S" step)))
+        (should (string= (sasl-step-data step) req))))
+    (ert-info ("Server's final message")
+      (let ((resp "v=847WXfnmReGyE1qlq1And6R4bPBNROTZ7EMS/QrJtUM="))
+        (sasl-step-set-data step resp)
+        (setq step (sasl-next-step client step))
+        (should-not (sasl-step-data step)))))
+  (should (eq sasl-unique-id-function #'sasl-unique-id-function)))
+
+(ert-deftest erc-sasl-create-client--scram-sha-512--no-authzid ()
+  (unless (featurep 'sasl-scram-sha256)
+    (ert-skip "Emacs lacks sasl-scram-sha512"))
+  (let* ((erc-server-current-nick "jilles")
+         (erc-session-password "sesame")
+         (mock-rvs (list "c5RqLCZy0L4fGkKAZ0hujFBs" ""))
+         (sasl-unique-id-function (lambda () (pop mock-rvs)))
+         (client (erc-sasl--create-client 'scram-sha-512))
+         (step (sasl-next-step client nil)))
+    (ert-info ("Client's initial request")
+      (let ((req "n,,n=jilles,r=c5RqLCZy0L4fGkKAZ0hujFBs"))
+        (should (equal (format "%S"
+                               `[erc-compat--sasl-scram-client-first-message
+                                 ,req])
+                       (format "%S" step)))
+        (should (string= (sasl-step-data step) req))))
+    (ert-info ("Server's initial response")
+      (let ((resp (concat
+                   "r=c5RqLCZy0L4fGkKAZ0hujFBs54c592745ce14e559fcc3f27b15464f6,"
+                   "s=YzMzOWZiY2U0YzcwNDA0M2I4ZGE2M2ZjOTBjODExZTM=,"
+                   "i=4096"))
+            (req (concat
+                  "c=biws,"
+                  "r=c5RqLCZy0L4fGkKAZ0hujFBs54c592745ce14e559fcc3f27b15464f6,"
+                  "p=vMBb9tKxFAfBtel087/GLbo4objAIYr1wM+mFv/jYLKXE"
+                  "NUF0vynm81qQbywQE5ScqFFdAfwYMZq/lj4s0V1OA==")))
+        (sasl-step-set-data step resp)
+        (setq step (sasl-next-step client step))
+        (should (equal (format
+                        "%S" `[erc-sasl--scram-sha-512-client-final-message
+                               ,req])
+                       (format "%S" step)))
+        (should (string= (sasl-step-data step) req))))
+    (ert-info ("Server's final message")
+      (let ((resp (concat "v=Va7NIvt8wCdhvxnv+bZriSxGoto6On5EVnRHO/ece8zs0"
+                          "qpQassdqir1Zlwh3e3EmBq+kcSy+ClNCsbzBpXe/w==")))
+        (sasl-step-set-data step resp)
+        (setq step (sasl-next-step client step))
+        (should-not (sasl-step-data step)))))
+  (should (eq sasl-unique-id-function #'sasl-unique-id-function)))
+
+(defconst erc-sasl-tests-ecdsa-key-file "
+-----BEGIN EC PARAMETERS-----
+BggqhkjOPQMBBw==
+-----END EC PARAMETERS-----
+-----BEGIN EC PRIVATE KEY-----
+MHcCAQEEIIJueQ3W2IrGbe9wKdOI75yGS7PYZSj6W4tg854hlsvmoAoGCCqGSM49
+AwEHoUQDQgAEAZmaVhNSMmV5r8FXPvKuMnqDKyIA9pDHN5TNMfiF3mMeikGgK10W
+IRX9cyi2wdYg9mUUYyh9GKdBCYHGUJAiCA==
+-----END EC PRIVATE KEY-----
+")
+
+(ert-deftest erc-sasl-create-client-ecdsa ()
+  :tags '(:unstable)
+  ;; This is currently useless because it just roundtrips shelling out
+  ;; to pkeyutl.
+  (ert-skip "Placeholder")
+  (unless (executable-find "openssl")
+    (ert-skip "System lacks openssl"))
+  (ert-with-temp-file keyfile
+    :prefix "ecdsa_key"
+    :suffix ".pem"
+    :text erc-sasl-tests-ecdsa-key-file
+    (let* ((erc-server-current-nick "jilles")
+           (erc-sasl--options `((password . ,keyfile)))
+           (client (erc-sasl--create-client 'ecdsa-nist256p-challenge))
+           (step (sasl-next-step client nil)))
+      (ert-info ("Client's initial request")
+        (should (equal (format "%S" [erc-sasl--ecdsa-first "jilles"])
+                       (format "%S" step)))
+        (should (string= (sasl-step-data step) "jilles")))
+      (ert-info ("Server's initial response")
+        (let ((resp (concat "\0\1\2\3\4\5\6\7\10\11\12\13\14\15\16\17\20"
+                            "\21\22\23\24\25\26\27\30\31\32\33\34\35\36\37")))
+          (sasl-step-set-data step resp)
+          (setq step (sasl-next-step client step))
+          (ert-with-temp-file sigfile
+            :prefix "ecdsa_sig"
+            :suffix ".sig"
+            :text (sasl-step-data step)
+            (with-temp-buffer
+              (set-buffer-multibyte nil)
+              (insert resp)
+              (let ((ec (call-process-region
+                         (point-min) (point-max)
+                         "openssl" 'delete t nil "pkeyutl"
+                         "-inkey" keyfile "-sigfile" sigfile
+                         "-verify")))
+                (unless (zerop ec)
+                  (message "%s" (buffer-string)))
+                (should (zerop ec)))))))
+      (should-not (sasl-next-step client step)))))
+
+;;; erc-sasl-tests.el ends here
diff --git a/test/lisp/erc/erc-scenarios-sasl.el b/test/lisp/erc/erc-scenarios-sasl.el
new file mode 100644
index 0000000000..7970e65ec2
--- /dev/null
+++ b/test/lisp/erc/erc-scenarios-sasl.el
@@ -0,0 +1,208 @@
+;;; erc-scenarios-sasl.el --- SASL tests for ERC -*- lexical-binding: t -*-
+
+;; Copyright (C) 2022 Free Software Foundation, Inc.
+;;
+;; This file is part of GNU Emacs.
+;;
+;; This program is free software: you can redistribute it and/or
+;; modify it under the terms of the GNU General Public License as
+;; published by the Free Software Foundation, either version 3 of the
+;; License, or (at your option) any later version.
+;;
+;; This program is distributed in the hope that it will be useful, but
+;; WITHOUT ANY WARRANTY; without even the implied warranty of
+;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+;; General Public License for more details.
+;;
+;; You should have received a copy of the GNU General Public License
+;; along with this program.  If not, see
+;; <https://www.gnu.org/licenses/>.
+
+;;; Code:
+
+(require 'ert-x)
+(eval-and-compile
+  (let ((load-path (cons (ert-resource-directory) load-path)))
+    (require 'erc-scenarios-common)))
+
+(declare-function sasl-client-name "sasl" (client))
+
+(require 'erc-scenarios-common)
+(require 'erc-sasl)
+
+(ert-deftest erc-scenarios-sasl--plain ()
+  :tags '(:expensive-test)
+  (erc-scenarios-common-with-cleanup
+      ((erc-scenarios-common-dialog "sasl")
+       (erc-d-linger-secs 0.5)
+       (erc-server-flood-penalty 0.1)
+       (dumb-server (erc-d-run "localhost" t 'plain))
+       (port (process-contact dumb-server :service))
+       (erc-modules (cons 'sasl erc-modules))
+       (erc-sasl-mechanism 'plain)
+       (erc-sasl-password "password123")
+       (erc-sasl--session-options nil)
+       (inhibit-message noninteractive)
+       (expect (erc-d-t-make-expecter)))
+
+    (ert-info ("Connect")
+      (with-current-buffer (erc :server "127.0.0.1"
+                                :port port
+                                :nick "tester"
+                                :user "tester"
+                                :full-name "tester")
+        (should (string= (buffer-name) (format "127.0.0.1:%d" port)))))
+
+    (erc-d-t-wait-for 10 "server buffer ready" (get-buffer "ExampleOrg"))
+
+    (ert-info ("Notices received")
+      (with-current-buffer "ExampleOrg"
+        (funcall expect 10 "This server is in debug mode")
+        ;; Regression "\0\0\0\0 ..." caused by (fillarray passphrase 0)
+        (should (string= erc-sasl-password "password123"))))))
+
+;; This is meant to assert `erc-update-modules' and local-module
+;; behavior generally.  It only exists here for convenience because as
+;; of ERC 5.5, `sasl' is the only local module.
+(ert-deftest erc-scenarios-sasl--local-modules-reconnect ()
+  :tags '(:expensive-test)
+  (erc-scenarios-common-with-cleanup
+      ((erc-scenarios-common-dialog "sasl")
+       (erc-server-flood-penalty 0.1)
+       (dumb-server (erc-d-run "localhost" t 'plain 'plain))
+       (port (process-contact dumb-server :service))
+       (erc-sasl--session-options nil)
+       (inhibit-message noninteractive)
+       (expect (erc-d-t-make-expecter)))
+
+    (ert-info ("Connect with options let-bound")
+      (with-current-buffer
+          ;; This won't work unless the library is already loaded
+          (let ((erc-modules (cons 'sasl erc-modules))
+                (erc-sasl-mechanism 'plain)
+                (erc-sasl-password "password123"))
+            (erc :server "127.0.0.1"
+                 :port port
+                 :nick "tester"
+                 :user "tester"
+                 :full-name "tester"))
+        (should (string= (buffer-name) (format "127.0.0.1:%d" port)))))
+
+    (with-current-buffer (erc-d-t-wait-for 10 (get-buffer "ExampleOrg"))
+      (ert-info ("First connection succeeds")
+        (funcall expect 10 "This server is in debug mode")
+        (erc-cmd-QUIT "")
+        (funcall expect 10 "finished"))
+
+      (should-not erc-sasl-password) ; obviously
+      (should-not (memq 'sasl erc-modules))
+
+      (erc-d-t-wait-for 10 (not (erc-server-process-alive)))
+      (erc-cmd-RECONNECT)
+      (ert-info ("Second connection succeeds")
+        (funcall expect 10 "This server is in debug mode")
+        (erc-cmd-QUIT "")
+        (funcall expect 10 "finished")))))
+
+(ert-deftest erc-scenarios-sasl--external ()
+  :tags '(:expensive-test)
+  (erc-scenarios-common-with-cleanup
+      ((erc-scenarios-common-dialog "sasl")
+       (erc-d-linger-secs 0.5)
+       (erc-server-flood-penalty 0.1)
+       (dumb-server (erc-d-run "localhost" t 'external))
+       (port (process-contact dumb-server :service))
+       (erc-modules (cons 'sasl erc-modules))
+       (erc-sasl-mechanism 'external)
+       (erc-sasl--session-options nil)
+       (inhibit-message noninteractive)
+       (expect (erc-d-t-make-expecter)))
+
+    (ert-info ("Connect")
+      (with-current-buffer (erc :server "127.0.0.1"
+                                :port port
+                                :nick "tester"
+                                :user "tester"
+                                :full-name "tester")
+        (should (string= (buffer-name) (format "127.0.0.1:%d" port)))))
+
+    (erc-d-t-wait-for 10 "server buffer ready" (get-buffer "ExampleOrg"))
+
+    (ert-info ("Notices received")
+      (with-current-buffer "ExampleOrg"
+        (funcall expect 10 "903 * Authentication successful")
+        (funcall expect 10 "This server is in debug mode")))))
+
+(ert-deftest erc-scenarios-sasl--plain-fail ()
+  :tags '(:expensive-test)
+  (erc-scenarios-common-with-cleanup
+      ((erc-scenarios-common-dialog "sasl")
+       (erc-d-linger-secs 0.5)
+       (erc-server-flood-penalty 0.1)
+       (dumb-server (erc-d-run "localhost" t 'plain-failed))
+       (port (process-contact dumb-server :service))
+       (erc-modules (cons 'sasl erc-modules))
+       (erc-sasl-password "wrong")
+       (erc-sasl-mechanism 'plain)
+       (erc-sasl--session-options nil)
+       (inhibit-message noninteractive)
+       (expect (erc-d-t-make-expecter))
+       (buf nil))
+
+    (ert-info ("Connect")
+      (setq buf (erc :server "127.0.0.1"
+                     :port port
+                     :nick "tester"
+                     :user "tester"
+                     :full-name "tester"))
+      (let ((err (should-error
+                  (with-current-buffer buf
+                    (funcall expect 20 "Connection failed!")))))
+        (should (string-search "please review" (cadr err)))
+        (with-current-buffer buf
+          (funcall expect 10 "Opening connection")
+          (funcall expect 20 "SASL authentication failed")
+          (should-not (erc-server-process-alive)))))))
+
+(defun erc-scenarios--common--sasl (mech)
+  (erc-scenarios-common-with-cleanup
+      ((erc-scenarios-common-dialog "sasl")
+       (erc-d-linger-secs 0.5)
+       (erc-server-flood-penalty 0.1)
+       (dumb-server (erc-d-run "localhost" t mech))
+       (port (process-contact dumb-server :service))
+       (erc-modules (cons 'sasl erc-modules))
+       (erc-sasl-password "sesame")
+       (erc-sasl-mechanism mech)
+       (erc-sasl--session-options nil)
+       (mock-rvs (list "c5RqLCZy0L4fGkKAZ0hujFBs" ""))
+       (sasl-unique-id-function (lambda () (pop mock-rvs)))
+       (inhibit-message noninteractive)
+       (expect (erc-d-t-make-expecter)))
+
+    (ert-info ("Connect")
+      (with-current-buffer (erc :server "127.0.0.1"
+                                :port port
+                                :nick "jilles"
+                                :full-name "jilles")
+        (should (string= (buffer-name) (format "127.0.0.1:%d" port)))))
+
+    (erc-d-t-wait-for 10 "server buffer ready" (get-buffer "jaguar"))
+
+    (ert-info ("Notices received")
+      (with-current-buffer "jaguar"
+        (funcall expect 10 "Found your hostname")
+        (funcall expect 20 "marked as being away")))))
+
+(ert-deftest erc-scenarios-sasl--scram-sha-1 ()
+  :tags '(:expensive-test)
+  (let ((erc-sasl-authzid "jilles"))
+    (erc-scenarios--common--sasl 'scram-sha-1)))
+
+(ert-deftest erc-scenarios-sasl--scram-sha-256 ()
+  :tags '(:expensive-test)
+  (unless (featurep 'sasl-scram-sha256)
+    (ert-skip "Emacs lacks sasl-scram-sha256"))
+  (erc-scenarios--common--sasl 'scram-sha-256))
+
+;;; erc-scenarios-sasl.el ends here
diff --git a/test/lisp/erc/erc-tests.el b/test/lisp/erc/erc-tests.el
index d074b36c8b..91815b8fae 100644
--- a/test/lisp/erc/erc-tests.el
+++ b/test/lisp/erc/erc-tests.el
@@ -1004,8 +1004,13 @@ erc-update-modules
         (with-temp-buffer
           (erc-mode)
           (setq erc-modules '(completion autojoin networks))
-          (let ((local-minor-modes '(font-lock-mode erc-fake-bar-mode)))
-            (should (equal (erc-update-modules t) '(erc-fake-bar-mode))))
+          (if (< 27 emacs-major-version)
+              (let ((local-minor-modes '(font-lock-mode erc-fake-bar-mode)))
+                (should (equal (erc-update-modules t) '(erc-fake-bar-mode))))
+            (cl-letf (((symbol-function 'buffer-local-variables)
+                       (lambda (&rest _) '((font-lock-mode)
+                                           (erc-fake-bar-mode)))))
+              (should (equal (erc-update-modules t) '(erc-fake-bar-mode)))))
           (should (equal (nreverse calls)
                          '( erc-pcomplete (completion . 1)
                             erc-join (autojoin . 1)
diff --git a/test/lisp/erc/resources/sasl/external.eld b/test/lisp/erc/resources/sasl/external.eld
new file mode 100644
index 0000000000..2cd237ec4d
--- /dev/null
+++ b/test/lisp/erc/resources/sasl/external.eld
@@ -0,0 +1,33 @@
+;; -*- mode: lisp-data; -*-
+((cap-req 10 "CAP REQ :sasl"))
+((nick 1 "NICK tester"))
+((user 1 "USER tester 0 * :tester"))
+
+((auth-req 3.2 "AUTHENTICATE EXTERNAL")
+ (0.0 ":irc.example.org CAP * ACK :sasl")
+ (0.0 "AUTHENTICATE +"))
+
+((auth-noop 3.2 "AUTHENTICATE +")
+ (0.0 ":irc.example.org 900 * * tester :You are now logged in as tester")
+ (0.0 ":irc.example.org 903 * :Authentication successful"))
+
+((cap-end 3.2 "CAP END")
+ (0.0 ":irc.example.org 001 tester :Welcome to the ExampleOrg IRC Network tester")
+ (0.01 ":irc.example.org 002 tester :Your host is irc.example.org, running version oragono-2.6.1")
+ (0.01 ":irc.example.org 003 tester :This server was created Sat, 17 Jul 2021 09:06:42 UTC")
+ (0.01 ":irc.example.org 004 tester irc.example.org oragono-2.6.1 BERTZios CEIMRUabefhiklmnoqstuv Iabefhkloqv")
+ (0.0 ":irc.example.org 005 tester AWAYLEN=200 BOT=B CASEMAPPING=ascii CHANLIMIT=#:100 CHANMODES=Ibe,k,fl,CEMRUimnstu CHANNELLEN=64 CHANTYPES=# ELIST=U EXCEPTS EXTBAN=,m FORWARD=f INVEX KICKLEN=390 :are supported by this server")
+ (0.01 ":irc.example.org 005 tester MAXLIST=beI:60 MAXTARGETS=4 MODES MONITOR=100 NETWORK=ExampleOrg NICKLEN=32 PREFIX=(qaohv)~&@%+ STATUSMSG=~&@%+ TARGMAX=NAMES:1,LIST:1,KICK:1,WHOIS:1,USERHOST:10,PRIVMSG:4,TAGMSG:4,NOTICE:4,MONITOR:100 TOPICLEN=390 UTF8MAPPING=rfc8265 UTF8ONLY :are supported by this server")
+ (0.01 ":irc.example.org 005 tester draft/CHATHISTORY=100 :are supported by this server")
+ (0.0 ":irc.example.org 251 tester :There are 1 users and 0 invisible on 1 server(s)")
+ (0.0 ":irc.example.org 252 tester 0 :IRC Operators online")
+ (0.0 ":irc.example.org 253 tester 0 :unregistered connections")
+ (0.0 ":irc.example.org 254 tester 0 :channels formed")
+ (0.0 ":irc.example.org 255 tester :I have 1 clients and 0 servers")
+ (0.0 ":irc.example.org 265 tester 1 1 :Current local users 1, max 1")
+ (0.21 ":irc.example.org 266 tester 1 1 :Current global users 1, max 1")
+ (0.0 ":irc.example.org 422 tester :MOTD File is missing"))
+
+((mode-user 1.2 "MODE tester +i")
+ (0.0 ":irc.example.org 221 tester +Zi")
+ (0.0 ":irc.example.org NOTICE tester :This server is in debug mode and is logging all user I/O. If you do not wish for everything you send to be readable by the server owner(s), please disconnect."))
diff --git a/test/lisp/erc/resources/sasl/plain-failed.eld b/test/lisp/erc/resources/sasl/plain-failed.eld
new file mode 100644
index 0000000000..336700290c
--- /dev/null
+++ b/test/lisp/erc/resources/sasl/plain-failed.eld
@@ -0,0 +1,16 @@
+;; -*- mode: lisp-data; -*-
+((cap-req 10 "CAP REQ :sasl"))
+((nick 1 "NICK tester"))
+((user 1 "USER tester 0 * :tester")
+ (0.0 ":irc.foonet.org NOTICE * :*** Looking up your hostname...")
+ (0.0 ":irc.foonet.org NOTICE * :*** Found your hostname")
+ (0.0 ":irc.foonet.org CAP * ACK :cap-notify sasl"))
+
+((authenticate-plain 3.2 "AUTHENTICATE PLAIN")
+ (0.0 ":irc.foonet.org AUTHENTICATE +"))
+
+((authenticate-gimme 3.2 "AUTHENTICATE AHRlc3RlcgB3cm9uZw==")
+ (0.0 ":irc.foonet.org 900 * * tester :You are now logged in as tester")
+ (0.0 ":irc.foonet.org 904 * :SASL authentication failed: Invalid account credentials"))
+
+((cap-end 3.2 "CAP END"))
diff --git a/test/lisp/erc/resources/sasl/plain.eld b/test/lisp/erc/resources/sasl/plain.eld
new file mode 100644
index 0000000000..1341cd78e5
--- /dev/null
+++ b/test/lisp/erc/resources/sasl/plain.eld
@@ -0,0 +1,39 @@
+;; -*- mode: lisp-data; -*-
+((cap-req 10 "CAP REQ :sasl"))
+((nick 1 "NICK tester"))
+((user 1 "USER tester 0 * :tester")
+ (0.0 ":irc.example.org NOTICE * :*** Looking up your hostname...")
+ (0.0 ":irc.example.org NOTICE * :*** Found your hostname")
+ (0.0 ":irc.example.org CAP * ACK :sasl"))
+
+((authenticate-plain 3.2 "AUTHENTICATE PLAIN")
+ (0.0 ":irc.example.org AUTHENTICATE +"))
+
+((authenticate-gimme 3.2 "AUTHENTICATE AHRlc3RlcgBwYXNzd29yZDEyMw==")
+ (0.0 ":irc.example.org 900 * * tester :You are now logged in as tester")
+ (0.0 ":irc.example.org 903 * :Authentication successful"))
+
+((cap-end 3.2 "CAP END")
+ (0.0 ":irc.example.org 001 tester :Welcome to the ExampleOrg IRC Network tester")
+ (0.01 ":irc.example.org 002 tester :Your host is irc.example.org, running version oragono-2.6.1")
+ (0.01 ":irc.example.org 003 tester :This server was created Sat, 17 Jul 2021 09:06:42 UTC")
+ (0.01 ":irc.example.org 004 tester irc.example.org oragono-2.6.1 BERTZios CEIMRUabefhiklmnoqstuv Iabefhkloqv")
+ (0.0 ":irc.example.org 005 tester AWAYLEN=200 BOT=B CASEMAPPING=ascii CHANLIMIT=#:100 CHANMODES=Ibe,k,fl,CEMRUimnstu CHANNELLEN=64 CHANTYPES=# ELIST=U EXCEPTS EXTBAN=,m FORWARD=f INVEX KICKLEN=390 :are supported by this server")
+ (0.01 ":irc.example.org 005 tester MAXLIST=beI:60 MAXTARGETS=4 MODES MONITOR=100 NETWORK=ExampleOrg NICKLEN=32 PREFIX=(qaohv)~&@%+ STATUSMSG=~&@%+ TARGMAX=NAMES:1,LIST:1,KICK:1,WHOIS:1,USERHOST:10,PRIVMSG:4,TAGMSG:4,NOTICE:4,MONITOR:100 TOPICLEN=390 UTF8MAPPING=rfc8265 UTF8ONLY :are supported by this server")
+ (0.01 ":irc.example.org 005 tester draft/CHATHISTORY=100 :are supported by this server")
+ (0.0 ":irc.example.org 251 tester :There are 1 users and 0 invisible on 1 server(s)")
+ (0.0 ":irc.example.org 252 tester 0 :IRC Operators online")
+ (0.0 ":irc.example.org 253 tester 0 :unregistered connections")
+ (0.0 ":irc.example.org 254 tester 0 :channels formed")
+ (0.0 ":irc.example.org 255 tester :I have 1 clients and 0 servers")
+ (0.0 ":irc.example.org 265 tester 1 1 :Current local users 1, max 1")
+ (0.21 ":irc.example.org 266 tester 1 1 :Current global users 1, max 1")
+ (0.0 ":irc.example.org 422 tester :MOTD File is missing"))
+
+((mode-user 1.2 "MODE tester +i")
+ (0.0 ":irc.example.org 221 tester +Zi")
+ (0.0 ":irc.example.org NOTICE tester :This server is in debug mode and is logging all user I/O. If you do not wish for everything you send to be readable by the server owner(s), please disconnect."))
+
+((quit 5 "QUIT :\2ERC\2")
+ (0 ":tester!~u@yuvqisyu7m7qs.irc QUIT :Quit"))
+((drop 1 DROP))
diff --git a/test/lisp/erc/resources/sasl/scram-sha-1.eld b/test/lisp/erc/resources/sasl/scram-sha-1.eld
new file mode 100644
index 0000000000..49980e9e12
--- /dev/null
+++ b/test/lisp/erc/resources/sasl/scram-sha-1.eld
@@ -0,0 +1,47 @@
+;;; -*- mode: lisp-data -*-
+((cap-req 5.2 "CAP REQ :sasl"))
+((nick 10 "NICK jilles"))
+((user 10 "USER user 0 * :jilles")
+ (0 "NOTICE AUTH :*** Processing connection to jaguar.test")
+ (0 "NOTICE AUTH :*** Looking up your hostname...")
+ (0 "NOTICE AUTH :*** Checking Ident")
+ (0 "NOTICE AUTH :*** No Ident response")
+ (0 "NOTICE AUTH :*** Found your hostname")
+ (0 ":jaguar.test CAP jilles ACK :sasl"))
+
+((auth-init 10 "AUTHENTICATE SCRAM-SHA-1")
+ (0 "AUTHENTICATE +"))
+
+((auth-challenge 10 "AUTHENTICATE bixhPWppbGxlcyxuPWppbGxlcyxyPWM1UnFMQ1p5MEw0ZkdrS0FaMGh1akZCcw==")
+ (0 "AUTHENTICATE cj1jNVJxTENaeTBMNGZHa0tBWjBodWpGQnNYUW9LY2l2cUN3OWlEWlBTcGIscz01bUpPNmQ0cmpDbnNCVTFYLGk9NDA5Ng=="))
+
+((auth-final 10 "AUTHENTICATE Yz1iaXhoUFdwcGJHeGxjeXc9LHI9YzVScUxDWnkwTDRmR2tLQVowaHVqRkJzWFFvS2NpdnFDdzlpRFpQU3BiLHA9T1ZVaGdQdTh3RW0yY0RvVkxmYUh6VlVZUFdVPQ==")
+ (0 "AUTHENTICATE dj1aV1IyM2M5TUppcjBaZ2ZHZjVqRXRMT242Tmc9"))
+
+((auth-done 10 "AUTHENTICATE +")
+ (0 ":jaguar.test 900 jilles jilles!jilles@localhost.stack.nl jilles :You are now logged in as jilles")
+ (0 ":jaguar.test 903 jilles :SASL authentication successful"))
+
+((cap-end 10.2 "CAP END")
+ (0 ":jaguar.test 001 jilles :Welcome to the jaguar IRC Network jilles!~jilles@127.0.0.1")
+ (0 ":jaguar.test 002 jilles :Your host is jaguar.test, running version InspIRCd-3")
+ (0 ":jaguar.test 003 jilles :This server was created 09:44:05 Dec 24 2020")
+ (0 ":jaguar.test 004 jilles jaguar.test InspIRCd-3 BILRSWcghiorswz ABEFHIJLMNOQRSTXYabcefghijklmnopqrstuvz :BEFHIJLXYabefghjkloqv")
+ (0 ":jaguar.test 005 jilles ACCEPT=30 AWAYLEN=200 BOT=B CALLERID=g CASEMAPPING=rfc1459 CHANLIMIT=#:120 CHANMODES=IXbeg,k,BEFHJLfjl,AMNOQRSTcimnprstuz CHANNELLEN=64 CHANTYPES=# ELIST=CMNTU ESILENCE=CcdiNnPpTtx EXCEPTS=e :are supported by this server")
+ (0 ":jaguar.test 005 jilles EXTBAN=,ANOQRSTUacmnprz HOSTLEN=64 INVEX=I KEYLEN=32 KICKLEN=255 LINELEN=512 MAXLIST=I:100,X:100,b:100,e:100,g:100 MAXTARGETS=20 MODES=20 MONITOR=30 NAMELEN=128 NAMESX NETWORK=jaguar :are supported by this server")
+ (0 ":jaguar.test 005 jilles NICKLEN=31 PREFIX=(Yqaohv)!~&@%+ REMOVE SAFELIST SECURELIST=60 SILENCE=32 STATUSMSG=!~&@%+ TOPICLEN=307 UHNAMES USERIP USERLEN=11 USERMODES=,,s,BILRSWcghiorwz WATCH=30 :are supported by this server")
+ (0 ":jaguar.test 005 jilles :are supported by this server")
+ (0 ":jaguar.test 251 jilles :There are 740 users and 108 invisible on 11 servers")
+ (0 ":jaguar.test 252 jilles 10 :operator(s) online")
+ (0 ":jaguar.test 254 jilles 373 :channels formed")
+ (0 ":jaguar.test 255 jilles :I have 28 clients and 1 servers")
+ (0 ":jaguar.test 265 jilles :Current local users: 28  Max: 29")
+ (0 ":jaguar.test 266 jilles :Current global users: 848  Max: 879")
+ (0 ":jaguar.test 375 jilles :jaguar.test message of the day")
+ (0 ":jaguar.test 372 jilles :   ~~ some message of the day ~~")
+ (0 ":jaguar.test 372 jilles :   ~~ or rkpryyrag gb rnpu bgure ~~")
+ (0 ":jaguar.test 376 jilles :End of message of the day."))
+
+((mode-user 1.2 "MODE jilles +i")
+ (0 ":jilles!~jilles@127.0.0.1 MODE jilles :+ri")
+ (0 ":jaguar.test 306 jilles :You have been marked as being away"))
diff --git a/test/lisp/erc/resources/sasl/scram-sha-256.eld b/test/lisp/erc/resources/sasl/scram-sha-256.eld
new file mode 100644
index 0000000000..74de9a23ec
--- /dev/null
+++ b/test/lisp/erc/resources/sasl/scram-sha-256.eld
@@ -0,0 +1,47 @@
+;;; -*- mode: lisp-data -*-
+((cap-req 5.2 "CAP REQ :sasl"))
+((nick 10 "NICK jilles"))
+((user 10 "USER user 0 * :jilles")
+ (0 "NOTICE AUTH :*** Processing connection to jaguar.test")
+ (0 "NOTICE AUTH :*** Looking up your hostname...")
+ (0 "NOTICE AUTH :*** Checking Ident")
+ (0 "NOTICE AUTH :*** No Ident response")
+ (0 "NOTICE AUTH :*** Found your hostname")
+ (0 ":jaguar.test CAP jilles ACK :sasl"))
+
+((auth-init 10 "AUTHENTICATE SCRAM-SHA-256")
+ (0 "AUTHENTICATE +"))
+
+((auth-challenge 10 "AUTHENTICATE biwsbj1qaWxsZXMscj1jNVJxTENaeTBMNGZHa0tBWjBodWpGQnM=")
+ (0 "AUTHENTICATE cj1jNVJxTENaeTBMNGZHa0tBWjBodWpGQnNkNDA2N2YwYWZkYjU0YzNkYmQ0ZmU2NDViODRjYWUzNyxzPVpUZzFNbUUxWW1GaFpHSTFORGN5TWprM056WXdabVJqWkRNM1kySTFPVE09LGk9NDA5Ng=="))
+
+((auth-final 10 "AUTHENTICATE Yz1iaXdzLHI9YzVScUxDWnkwTDRmR2tLQVowaHVqRkJzZDQwNjdmMGFmZGI1NGMzZGJkNGZlNjQ1Yjg0Y2FlMzcscD1MUDRzakpyakpLcDVxVHNBUnlaQ3BwWHBLTHU0Rk1NMjg0aE5FU1B2R2hJPQ==")
+ (0 "AUTHENTICATE dj04NDdXWGZubVJlR3lFMXFscTFBbmQ2UjRiUEJOUk9UWjdFTVMvUXJKdFVNPQ=="))
+
+((auth-done 10 "AUTHENTICATE +")
+ (0 ":jaguar.test 900 jilles jilles!jilles@localhost.stack.nl jilles :You are now logged in as jilles")
+ (0 ":jaguar.test 903 jilles :SASL authentication successful"))
+
+((cap-end 10.2 "CAP END")
+ (0 ":jaguar.test 001 jilles :Welcome to the jaguar IRC Network jilles!~jilles@127.0.0.1")
+ (0 ":jaguar.test 002 jilles :Your host is jaguar.test, running version InspIRCd-3")
+ (0 ":jaguar.test 003 jilles :This server was created 09:44:05 Dec 24 2020")
+ (0 ":jaguar.test 004 jilles jaguar.test InspIRCd-3 BILRSWcghiorswz ABEFHIJLMNOQRSTXYabcefghijklmnopqrstuvz :BEFHIJLXYabefghjkloqv")
+ (0 ":jaguar.test 005 jilles ACCEPT=30 AWAYLEN=200 BOT=B CALLERID=g CASEMAPPING=rfc1459 CHANLIMIT=#:120 CHANMODES=IXbeg,k,BEFHJLfjl,AMNOQRSTcimnprstuz CHANNELLEN=64 CHANTYPES=# ELIST=CMNTU ESILENCE=CcdiNnPpTtx EXCEPTS=e :are supported by this server")
+ (0 ":jaguar.test 005 jilles EXTBAN=,ANOQRSTUacmnprz HOSTLEN=64 INVEX=I KEYLEN=32 KICKLEN=255 LINELEN=512 MAXLIST=I:100,X:100,b:100,e:100,g:100 MAXTARGETS=20 MODES=20 MONITOR=30 NAMELEN=128 NAMESX NETWORK=jaguar :are supported by this server")
+ (0 ":jaguar.test 005 jilles NICKLEN=31 PREFIX=(Yqaohv)!~&@%+ REMOVE SAFELIST SECURELIST=60 SILENCE=32 STATUSMSG=!~&@%+ TOPICLEN=307 UHNAMES USERIP USERLEN=11 USERMODES=,,s,BILRSWcghiorwz WATCH=30 :are supported by this server")
+ (0 ":jaguar.test 005 jilles :are supported by this server")
+ (0 ":jaguar.test 251 jilles :There are 740 users and 108 invisible on 11 servers")
+ (0 ":jaguar.test 252 jilles 10 :operator(s) online")
+ (0 ":jaguar.test 254 jilles 373 :channels formed")
+ (0 ":jaguar.test 255 jilles :I have 28 clients and 1 servers")
+ (0 ":jaguar.test 265 jilles :Current local users: 28  Max: 29")
+ (0 ":jaguar.test 266 jilles :Current global users: 848  Max: 879")
+ (0 ":jaguar.test 375 jilles :jaguar.test message of the day")
+ (0 ":jaguar.test 372 jilles :   ~~ some message of the day ~~")
+ (0 ":jaguar.test 372 jilles :   ~~ or rkpryyrag gb rnpu bgure ~~")
+ (0 ":jaguar.test 376 jilles :End of message of the day."))
+
+((mode-user 1.2 "MODE jilles +i")
+ (0 ":jilles!~jilles@127.0.0.1 MODE jilles :+ri")
+ (0 ":jaguar.test 306 jilles :You have been marked as being away"))
-- 
2.38.1


  parent reply	other threads:[~2022-11-14  6:45 UTC|newest]

Thread overview: 54+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2017-11-01 20:07 bug#29108: 25.3; ERC SASL support Alex Branham
2017-11-10  2:24 ` Noam Postavsky
2019-10-23  9:24   ` Lars Ingebrigtsen
2019-10-23 10:34     ` Alex Branham
2019-10-23 11:19       ` Lars Ingebrigtsen
2019-10-23 12:19         ` Stefan Kangas
2019-10-23 12:57           ` Noam Postavsky
2019-10-23 13:32             ` Stefan Kangas
2019-11-02 14:10         ` Stefan Kangas
2020-08-03  9:39           ` Lars Ingebrigtsen
2021-07-28 16:59 ` Ulrich Mueller
2021-07-28 17:21   ` Eli Zaretskii
2021-07-28 22:42   ` J.P.
2021-08-09  9:59   ` J.P.
2021-08-09 10:22     ` Ulrich Mueller
2021-08-09 10:56       ` J.P.
2021-08-09 12:39       ` J.P.
2021-08-23 13:47     ` J.P.
     [not found]     ` <87o89oi87g.fsf@neverwas.me>
2021-08-23 14:01       ` Lars Ingebrigtsen
     [not found]       ` <87zgt8s1jt.fsf@gnus.org>
2021-08-24 13:42         ` J.P.
2022-09-18 18:32 ` bug#29108: [J.P.] Add "non-IRCv3" SASL to ERC J.P.
2022-09-20  6:07   ` bug#29108: 25.3; ERC SASL support J.P.
     [not found]   ` <875yhifujk.fsf_-_@neverwas.me>
2022-09-21 13:13     ` J.P.
2022-10-14  3:05       ` J.P.
     [not found]       ` <878rljxfxs.fsf@neverwas.me>
2022-10-26 13:14         ` J.P.
     [not found]         ` <87k04m4th8.fsf@neverwas.me>
2022-11-08 14:10           ` J.P.
     [not found]           ` <87o7thlepf.fsf@neverwas.me>
2022-11-09  4:08             ` Akib Azmain Turja via Bug reports for GNU Emacs, the Swiss army knife of text editors
2022-11-09 13:49               ` J.P.
     [not found]               ` <874jv81bn2.fsf@neverwas.me>
2022-11-09 17:50                 ` Akib Azmain Turja via Bug reports for GNU Emacs, the Swiss army knife of text editors
     [not found]                 ` <87iljoqaor.fsf@disroot.org>
2022-11-10  5:28                   ` J.P.
     [not found]                   ` <87sfirml89.fsf@neverwas.me>
2022-11-10 18:04                     ` Adam Porter
2022-11-10 21:50                       ` J.P.
2022-11-11  5:51                       ` Akib Azmain Turja via Bug reports for GNU Emacs, the Swiss army knife of text editors
2022-11-14 22:28                         ` Adam Porter
     [not found]                       ` <87sfiq7a3j.fsf@neverwas.me>
2022-11-11  1:25                         ` Adam Porter
2022-11-11  5:56                         ` Akib Azmain Turja via Bug reports for GNU Emacs, the Swiss army knife of text editors
     [not found]                         ` <878rkighkn.fsf@disroot.org>
2022-11-14 22:29                           ` Adam Porter
2022-11-13 15:36             ` J.P.
     [not found]             ` <87o7taoohd.fsf@neverwas.me>
2022-11-14  6:45               ` J.P. [this message]
2022-11-14 15:20                 ` J.P.
     [not found]                 ` <87y1sdk1fg.fsf@neverwas.me>
2022-11-16 14:51                   ` J.P.
     [not found]                   ` <875yfflzps.fsf@neverwas.me>
2022-11-17  6:30                     ` J.P.
     [not found]                     ` <877czuks8k.fsf@neverwas.me>
2022-11-17 15:28                       ` J.P.
2022-11-18  2:26                     ` J.P.
     [not found]                     ` <878rk9576b.fsf@neverwas.me>
2022-11-18 14:06                       ` J.P.
     [not found]                       ` <87leo8z79j.fsf@neverwas.me>
2022-11-19 14:48                         ` J.P.
     [not found]                         ` <87tu2vroeh.fsf@neverwas.me>
2022-11-20 14:29                           ` J.P.
     [not found]                           ` <87wn7pog1l.fsf@neverwas.me>
2022-11-21 15:09                             ` J.P.
     [not found]                             ` <87y1s4mjj6.fsf@neverwas.me>
2022-11-22 14:01                               ` J.P.
     [not found]                               ` <87r0xvks03.fsf@neverwas.me>
2022-11-24  2:49                                 ` Amin Bandali
     [not found]                                 ` <87r0xtnk24.fsf@gnu.org>
2022-11-25 14:43                                   ` J.P.
     [not found]                                   ` <87wn7jgkne.fsf@neverwas.me>
2022-11-28  0:08                                     ` J.P.
2022-11-29  5:19                                     ` Amin Bandali
     [not found]                                     ` <87iliyz6at.fsf@gnu.org>
2022-11-29 15:05                                       ` J.P.

Reply instructions:

You may reply publicly to this message via plain-text email
using any one of the following methods:

* Save the following mbox file, import it into your mail client,
  and reply-to-all from there: mbox

  Avoid top-posting and favor interleaved quoting:
  https://en.wikipedia.org/wiki/Posting_style#Interleaved_style

  List information: https://www.gnu.org/software/emacs/

* Reply using the --to, --cc, and --in-reply-to
  switches of git-send-email(1):

  git send-email \
    --in-reply-to=87a64unifk.fsf@neverwas.me \
    --to=jp@neverwas.me \
    --cc=29108@debbugs.gnu.org \
    --cc=bandali@gnu.org \
    --cc=emacs-erc@gnu.org \
    /path/to/YOUR_REPLY

  https://kernel.org/pub/software/scm/git/docs/git-send-email.html

* If your mail client supports setting the In-Reply-To header
  via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line before the message body.
Code repositories for project(s) associated with this public inbox

	https://git.savannah.gnu.org/cgit/emacs.git

This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox;
as well as URLs for read-only IMAP folder(s) and NNTP newsgroup(s).