unofficial mirror of bug-gnu-emacs@gnu.org 
 help / color / mirror / code / Atom feed
* bug#29108: 25.3; ERC SASL support
@ 2017-11-01 20:07 Alex Branham
  2017-11-10  2:24 ` Noam Postavsky
                   ` (2 more replies)
  0 siblings, 3 replies; 54+ messages in thread
From: Alex Branham @ 2017-11-01 20:07 UTC (permalink / raw)
  To: 29108

Since freenode requires SASL support if you're connecting from certain networks, it would be nice if ERC supported connecting via SASL. There seems to have been some work on this a few years ago: https://github.com/jane-lx/erc-sasl

Thanks!

In GNU Emacs 25.3.1 (x86_64-pc-linux-gnu, GTK+ Version 3.22.19)
 of 2017-09-16 built on juergen
Windowing system distributor 'The X.Org Foundation', version 11.0.11905000
Configured using:
 'configure --prefix=/usr --sysconfdir=/etc --libexecdir=/usr/lib
 --localstatedir=/var --with-x-toolkit=gtk3 --with-xft --with-modules
 'CFLAGS=-march=x86-64 -mtune=generic -O2 -pipe -fstack-protector-strong
 -fno-plt' CPPFLAGS=-D_FORTIFY_SOURCE=2
 LDFLAGS=-Wl,-O1,--sort-common,--as-needed,-z,relro,-z,now'

Configured features:
XPM JPEG TIFF GIF PNG RSVG IMAGEMAGICK SOUND GPM DBUS GCONF GSETTINGS
NOTIFY ACL GNUTLS LIBXML2 FREETYPE M17N_FLT LIBOTF XFT ZLIB
TOOLKIT_SCROLL_BARS GTK3 X11 MODULES

Important settings:
  value of $LANG: en_US.UTF-8
  locale-coding-system: utf-8-unix






^ permalink raw reply	[flat|nested] 54+ messages in thread

* bug#29108: 25.3; ERC SASL support
  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
  2021-07-28 16:59 ` Ulrich Mueller
  2022-09-18 18:32 ` bug#29108: [J.P.] Add "non-IRCv3" SASL to ERC J.P.
  2 siblings, 1 reply; 54+ messages in thread
From: Noam Postavsky @ 2017-11-10  2:24 UTC (permalink / raw)
  To: Alex Branham; +Cc: 29108

severity 29108 wishlist
quit

Alex Branham <alex.branham@gmail.com> writes:

> Since freenode requires SASL support if you're connecting from certain
> networks, it would be nice if ERC supported connecting via SASL. There
> seems to have been some work on this a few years ago:
> https://github.com/jane-lx/erc-sasl

That copyright for that code may or may not be assignable to Emacs, cf
https://github.com/jane-lx/erc-sasl/issues/2.





^ permalink raw reply	[flat|nested] 54+ messages in thread

* bug#29108: 25.3; ERC SASL support
  2017-11-10  2:24 ` Noam Postavsky
@ 2019-10-23  9:24   ` Lars Ingebrigtsen
  2019-10-23 10:34     ` Alex Branham
  0 siblings, 1 reply; 54+ messages in thread
From: Lars Ingebrigtsen @ 2019-10-23  9:24 UTC (permalink / raw)
  To: Noam Postavsky; +Cc: Alex Branham, 29108

Noam Postavsky <npostavs@users.sourceforge.net> writes:

>> Since freenode requires SASL support if you're connecting from certain
>> networks, it would be nice if ERC supported connecting via SASL. There
>> seems to have been some work on this a few years ago:
>> https://github.com/jane-lx/erc-sasl
>
> That copyright for that code may or may not be assignable to Emacs, cf
> https://github.com/jane-lx/erc-sasl/issues/2.

SASL support in Emacs would be nice.  That URL 404s now, but there are
other copies of it out there.  The primary author (Joseph Gay) does have
copyright assignments on file, but does anybody know the provenance of
the file?

-- 
(domestic pets only, the antidote for overdose, milk.)
   bloggy blog: http://lars.ingebrigtsen.no





^ permalink raw reply	[flat|nested] 54+ messages in thread

* bug#29108: 25.3; ERC SASL support
  2019-10-23  9:24   ` Lars Ingebrigtsen
@ 2019-10-23 10:34     ` Alex Branham
  2019-10-23 11:19       ` Lars Ingebrigtsen
  0 siblings, 1 reply; 54+ messages in thread
From: Alex Branham @ 2019-10-23 10:34 UTC (permalink / raw)
  To: Lars Ingebrigtsen; +Cc: 29108, Noam Postavsky

On Wed 23 Oct 2019 at 11:24, Lars Ingebrigtsen <larsi@gnus.org> wrote:

> Noam Postavsky <npostavs@users.sourceforge.net> writes:
>
>>> Since freenode requires SASL support if you're connecting from certain
>>> networks, it would be nice if ERC supported connecting via SASL. There
>>> seems to have been some work on this a few years ago:
>>> https://github.com/jane-lx/erc-sasl
>>
>> That copyright for that code may or may not be assignable to Emacs, cf
>> https://github.com/jane-lx/erc-sasl/issues/2.
>
> SASL support in Emacs would be nice.  That URL 404s now, but there are
> other copies of it out there.  The primary author (Joseph Gay) does have
> copyright assignments on file, but does anybody know the provenance of
> the file?

Here's one copy: https://gitlab.com/psachin/erc-sasl/blob/master/erc-sasl.el





^ permalink raw reply	[flat|nested] 54+ messages in thread

* bug#29108: 25.3; ERC SASL support
  2019-10-23 10:34     ` Alex Branham
@ 2019-10-23 11:19       ` Lars Ingebrigtsen
  2019-10-23 12:19         ` Stefan Kangas
  2019-11-02 14:10         ` Stefan Kangas
  0 siblings, 2 replies; 54+ messages in thread
From: Lars Ingebrigtsen @ 2019-10-23 11:19 UTC (permalink / raw)
  To: Alex Branham; +Cc: 29108, Noam Postavsky

Alex Branham <alex.branham@gmail.com> writes:

> Here's one copy: https://gitlab.com/psachin/erc-sasl/blob/master/erc-sasl.el

Could somebody get in touch with the original author, or check whether
what's in there is the same as the original author wrote?

-- 
(domestic pets only, the antidote for overdose, milk.)
   bloggy blog: http://lars.ingebrigtsen.no





^ permalink raw reply	[flat|nested] 54+ messages in thread

* bug#29108: 25.3; ERC SASL support
  2019-10-23 11:19       ` Lars Ingebrigtsen
@ 2019-10-23 12:19         ` Stefan Kangas
  2019-10-23 12:57           ` Noam Postavsky
  2019-11-02 14:10         ` Stefan Kangas
  1 sibling, 1 reply; 54+ messages in thread
From: Stefan Kangas @ 2019-10-23 12:19 UTC (permalink / raw)
  To: Joseph Gay; +Cc: Alex Branham, 29108, Lars Ingebrigtsen, Noam Postavsky

Hi Joseph,

We are looking into adding SASL support to ERC in Emacs, which would
be a useful feature for our users.  We note that you have done some
previous work in this area (erc-sasl.el), and that you already have
copyright assignments for Emacs on file.

The best link we could find for erc-sasl.el was this:
https://gitlab.com/psachin/erc-sasl/blob/master/erc-sasl.el

Could you please help us by answering these two questions:

1. Would you have any objections if we included your code in Emacs?
2. Is the code on the link above the code written by you, or has it
been changed since? Are you the sole contributor to that file?

I've included our recent discussion about this below for your
information.  Thanks in advance.

Best regards,
Stefan Kangas


Den ons 23 okt. 2019 kl 13:54 skrev Lars Ingebrigtsen <larsi@gnus.org>:
>
> Alex Branham <alex.branham@gmail.com> writes:
>
> > Here's one copy: https://gitlab.com/psachin/erc-sasl/blob/master/erc-sasl.el
>
> Could somebody get in touch with the original author, or check whether
> what's in there is the same as the original author wrote?
>
> --
> (domestic pets only, the antidote for overdose, milk.)
>    bloggy blog: http://lars.ingebrigtsen.no
>
>
>





^ permalink raw reply	[flat|nested] 54+ messages in thread

* bug#29108: 25.3; ERC SASL support
  2019-10-23 12:19         ` Stefan Kangas
@ 2019-10-23 12:57           ` Noam Postavsky
  2019-10-23 13:32             ` Stefan Kangas
  0 siblings, 1 reply; 54+ messages in thread
From: Noam Postavsky @ 2019-10-23 12:57 UTC (permalink / raw)
  To: Stefan Kangas
  Cc: Alex Branham, Jane Gay, 29108, Lars Ingebrigtsen, Noam Postavsky

Stefan Kangas <stefan@marxist.se> writes:

> Hi Joseph,

I think they're called Jane now.  The original repository indeed seems
to be gone, but it was already under username jane-lx which still
exists, see <https://github.com/jane-lx/> and the linked
<https://medium.com/@jane.lx.gay>.

> We are looking into adding SASL support to ERC in Emacs, which would
> be a useful feature for our users.  We note that you have done some
> previous work in this area (erc-sasl.el), and that you already have
> copyright assignments for Emacs on file.
>
> The best link we could find for erc-sasl.el was this:
> https://gitlab.com/psachin/erc-sasl/blob/master/erc-sasl.el
>
> Could you please help us by answering these two questions:
>
> 1. Would you have any objections if we included your code in Emacs?
> 2. Is the code on the link above the code written by you, or has it
> been changed since? Are you the sole contributor to that file?
>
> I've included our recent discussion about this below for your
> information.  Thanks in advance.
>
> Best regards,
> Stefan Kangas





^ permalink raw reply	[flat|nested] 54+ messages in thread

* bug#29108: 25.3; ERC SASL support
  2019-10-23 12:57           ` Noam Postavsky
@ 2019-10-23 13:32             ` Stefan Kangas
  0 siblings, 0 replies; 54+ messages in thread
From: Stefan Kangas @ 2019-10-23 13:32 UTC (permalink / raw)
  To: Noam Postavsky
  Cc: Alex Branham, Jane Gay, 29108, Lars Ingebrigtsen, Noam Postavsky

Noam Postavsky <npostavs@gmail.com> writes:

> > Hi Joseph,
>
> I think they're called Jane now.

Thanks, sorry for the mistake.  I carelessly used what was available
in the file, which I suppose was an old copy.

Best regards,
Stefan Kangas





^ permalink raw reply	[flat|nested] 54+ messages in thread

* bug#29108: 25.3; ERC SASL support
  2019-10-23 11:19       ` Lars Ingebrigtsen
  2019-10-23 12:19         ` Stefan Kangas
@ 2019-11-02 14:10         ` Stefan Kangas
  2020-08-03  9:39           ` Lars Ingebrigtsen
  1 sibling, 1 reply; 54+ messages in thread
From: Stefan Kangas @ 2019-11-02 14:10 UTC (permalink / raw)
  To: gilleylen; +Cc: Alex Branham, 29108, Lars Ingebrigtsen, Noam Postavsky

[ysph@psy.ai bounced. Trying another email address.]

Hi,

We are looking into adding SASL support to ERC in Emacs, which would
be a useful feature for our users.  We note that you have done some
previous work in this area (erc-sasl.el), and that you already have
copyright assignments for Emacs on file.

We note that this link is dead:
https://github.com/jane-lx/erc-sasl

The best link we could find for erc-sasl.el was this:
https://gitlab.com/psachin/erc-sasl/blob/master/erc-sasl.el

Could you please help us by answering these two questions:

1. Would you have any objections if we included your code in Emacs?
2. Is the code in the second link above the code written by you, or
has it been changed since?  Are you the sole contributor to that file?

I've included our recent discussion about this below for your
information.  Thanks in advance.

Best regards,
Stefan Kangas

Lars Ingebrigtsen <larsi@gnus.org> writes:
>
> Alex Branham <alex.branham@gmail.com> writes:
>
> > Here's one copy: https://gitlab.com/psachin/erc-sasl/blob/master/erc-sasl.el
>
> Could somebody get in touch with the original author, or check whether
> what's in there is the same as the original author wrote?





^ permalink raw reply	[flat|nested] 54+ messages in thread

* bug#29108: 25.3; ERC SASL support
  2019-11-02 14:10         ` Stefan Kangas
@ 2020-08-03  9:39           ` Lars Ingebrigtsen
  0 siblings, 0 replies; 54+ messages in thread
From: Lars Ingebrigtsen @ 2020-08-03  9:39 UTC (permalink / raw)
  To: Stefan Kangas; +Cc: Alex Branham, gilleylen, 29108, Noam Postavsky

Stefan Kangas <stefan@marxist.se> writes:

> The best link we could find for erc-sasl.el was this:
> https://gitlab.com/psachin/erc-sasl/blob/master/erc-sasl.el
>
> Could you please help us by answering these two questions:
>
> 1. Would you have any objections if we included your code in Emacs?
> 2. Is the code in the second link above the code written by you, or
> has it been changed since?  Are you the sole contributor to that file?

This was more than half a year ago, so I'm guessing there won't be a
response here.  So it seems unlikely that we'll make more progress here,
and I'm closing this bug report.

-- 
(domestic pets only, the antidote for overdose, milk.)
   bloggy blog: http://lars.ingebrigtsen.no





^ permalink raw reply	[flat|nested] 54+ messages in thread

* bug#29108: 25.3; ERC SASL support
  2017-11-01 20:07 bug#29108: 25.3; ERC SASL support Alex Branham
  2017-11-10  2:24 ` Noam Postavsky
@ 2021-07-28 16:59 ` Ulrich Mueller
  2021-07-28 17:21   ` Eli Zaretskii
                     ` (2 more replies)
  2022-09-18 18:32 ` bug#29108: [J.P.] Add "non-IRCv3" SASL to ERC J.P.
  2 siblings, 3 replies; 54+ messages in thread
From: Ulrich Mueller @ 2021-07-28 16:59 UTC (permalink / raw)
  To: 29108

ERC is a little behind the times by not supporting SASL, so please
pretty please can we have this?

Not sure if it helps, but archive.org has a partial copy of the original
git repository:
https://web.archive.org/web/20180611034438if_/https://github.com/jane-lx/erc-sasl

Most importantly, it does have erc-sasl.el which AFAICS is the only
relevant file.

There also is a fork of the repository on Github:
https://github.com/suhailshergill/erc-sasl

Apparently it has no additional commits beyond the original repository
and erc-sasl.el is identical to the one found at archive.org.

Looking at commit history, there are 4 commits in 2012 (all by
Joseph Gay). Their parent is commit 9497cc9 which is also the tip of the
ERC repository at https://git.savannah.gnu.org/cgit/erc.git.

Would this be enough for inclusion in Emacs, given that erc-sasl.el has
a GPL license notice and that copyright papers for the author are on
file (or at least message #13 says so)?





^ permalink raw reply	[flat|nested] 54+ messages in thread

* bug#29108: 25.3; ERC SASL support
  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.
  2 siblings, 0 replies; 54+ messages in thread
From: Eli Zaretskii @ 2021-07-28 17:21 UTC (permalink / raw)
  To: Ulrich Mueller; +Cc: 29108

> From: Ulrich Mueller <ulm@gentoo.org>
> Date: Wed, 28 Jul 2021 18:59:34 +0200
> 
> There also is a fork of the repository on Github:
> https://github.com/suhailshergill/erc-sasl
> 
> Apparently it has no additional commits beyond the original repository
> and erc-sasl.el is identical to the one found at archive.org.
> 
> Looking at commit history, there are 4 commits in 2012 (all by
> Joseph Gay). Their parent is commit 9497cc9 which is also the tip of the
> ERC repository at https://git.savannah.gnu.org/cgit/erc.git.
> 
> Would this be enough for inclusion in Emacs, given that erc-sasl.el has
> a GPL license notice and that copyright papers for the author are on
> file (or at least message #13 says so)?

AFAIU, we'd need to hear from Joseph that he agrees to contribute his
code to Emacs.





^ permalink raw reply	[flat|nested] 54+ messages in thread

* bug#29108: 25.3; ERC SASL support
  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.
  2 siblings, 0 replies; 54+ messages in thread
From: J.P. @ 2021-07-28 22:42 UTC (permalink / raw)
  To: Ulrich Mueller; +Cc: 29108

Ulrich Mueller <ulm@gentoo.org> writes:

> ERC is a little behind the times by not supporting SASL, so please
> pretty please can we have this?

Hi Ulrich,

I have a patch for this that I'll be introducing in the next few days as
part of a larger change set bringing much needed life support to ERC
generally. It lays the foundation for moving us to IRCv3.

If you would like a preview, you can check this out [1].

> Not sure if it helps, but archive.org has a partial copy of the original
> git repository:
> https://web.archive.org/web/20180611034438if_/https://github.com/jane-lx/erc-sasl

I'm well acquainted with this patch. IMO, it's a hack, but nothing wrong
with that [2].

If you'd like to take what I have for a spin, please follow the steps
posted here [3], except change the bug number in all URLs from 48598 to
99999. The stuff there is a bit stale (many improvements since), but
I'll be updating it all shortly.

More to follow in the next 72 or so. Please stay tuned.

Thanks,
J.P.


[1] https://gitlab.com/jpneverwas/erc-v3/-/blob/master/erc-v3-sasl.el

[2] Some info posted to #erc about this a few months back:

    *** #erc was created on 2021-05-19 07:01:11
    <neverwas> Okay people, so I redid my rather horrendous joke of an
               SASL extension using the upstream sasl.el library and was
               able to implement all of the recommended mechanisms
               currently in use or soon to be. Turns out alphachat is
               running the latest atheme, so I signed up and
               successfully validated against its service (and captured
               logs) for both SCRAM-SHA-256 and
               ECDSA-NIST256P-CHALLENGE.
                      a                                        [14:07]
    <neverwas> The main ugly point right now is I'm currently shelling
               out to openssl for the latter (ecdsa). I see two possible
               ways around this. The first is adding
               gnutls_privkey_sign_hash() and anything it requires to
               src/gnutls.c. The other is shelling out to a comparable
               gnutls-based command-line tool. After a cursory search, I
               was unable to find one that does the job. Anyone here
               familiar with that suite?
    <neverwas> So once we add the basic v3 building blocks for CAP and
               its subcommands to ERC proper (and a couple tiny sasl.el
               patches I'll open soon), adding full featured SASL
               support should go pretty smoothly. Unlike how rcirc and
               Circe (and the older non-GNU ERC patches) do things, this
               approach properly delegates to a black-box service for
               the subprotocol, which is the way rfc4422 designed it.
                                                               [14:08]
    <neverwas> This makes it super easy to add other mechanisms in the
               future. All the gory details are hidden away behind the
               sasl.el-provided state machine, so you just feed it
               whatever arrives from the server, and it coughs out the
               next thing to send. Take a look if you want:
               https://gitlab.com/jpneverwas/erc-v3/-/blob/master/erc-v3-sasl.el
               (replace "blob" with "raw" for no JS)

[3] https://lists.gnu.org/archive/html/emacs-erc/2021-06/msg00019.html





^ permalink raw reply	[flat|nested] 54+ messages in thread

* bug#29108: 25.3; ERC SASL support
  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
                       ` (2 more replies)
  2 siblings, 3 replies; 54+ messages in thread
From: J.P. @ 2021-08-09  9:59 UTC (permalink / raw)
  To: Ulrich Mueller; +Cc: 29108

Hi Ulrich,

Ulrich Mueller <ulm@gentoo.org> writes:

> ERC is a little behind the times by not supporting SASL, so please
> pretty please can we have this?

If you're still interested in SASL, please try installing these patches
[1] and maybe do something like the following:

Connect to InspIRCd's testnet:

  (require 'erc)
  (erc-toggle-debug-irc-protocol)
  (push 'v3 erc-modules)
  (erc-tls :server "testnet.inspircd.org"
           :port 6697
           :nick "my-nick"
           :full-name "My Nick")

And register with anope nick services:

  ERC> /msg NickServ REGISTER password123 fake@fake.example.org

  ERC> /quit

Then connect again (the client certs should be real, in preparation for
the next demo):

  (push 'sasl erc-v3-extensions)
  (setq erc-v3-sasl-mechanism 'plain)
  (erc-tls :server "testnet.inspircd.org"
           :port 6697
           :nick "my-nick"
           :password "password123"
           :full-name "My Nick"
           :client-certificate (list "/tmp/key.pem" "/tmp/cert.pem"))

Look for this in the server buffer:

  *** Account status for my-nick: logged in as my-nick

Then do:

  ERC> /msg NickServ CERT ADD

  ERC> /quit

This time, using EXTERNAL (note the lack of a password):

  (setq erc-v3-sasl-mechanism 'external)
  (erc-tls :server "testnet.inspircd.org"
           :port 6697
           :nick "my-nick"
           :full-name "My Nick"
           :client-certificate (list "/tmp/key.pem" "/tmp/cert.pem"))

Once again, note the success message. The fancier mechanisms also work.
Let me know if you have any questions. Thanks.


[1] https://jpneverwas.gitlab.io/erc-tools/49860/patches.tar.gz

    Alternative, package.el-based option in footnote #2 of this post:

    https://lists.gnu.org/archive/html/emacs-erc/2021-08/msg00002.html





^ permalink raw reply	[flat|nested] 54+ messages in thread

* bug#29108: 25.3; ERC SASL support
  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>
  2 siblings, 2 replies; 54+ messages in thread
From: Ulrich Mueller @ 2021-08-09 10:22 UTC (permalink / raw)
  To: J.P.; +Cc: 29108

>>>>> On Mon, 09 Aug 2021, J P wrote:

> If you're still interested in SASL, please try installing these patches
> [1] and maybe do something like the following:

> [1] https://jpneverwas.gitlab.io/erc-tools/49860/patches.tar.gz

Sorry for the delay. On top of what version do these patches apply?
I have tried with current master and with the 27.2 release but patches
fail for both.





^ permalink raw reply	[flat|nested] 54+ messages in thread

* bug#29108: 25.3; ERC SASL support
  2021-08-09 10:22     ` Ulrich Mueller
@ 2021-08-09 10:56       ` J.P.
  2021-08-09 12:39       ` J.P.
  1 sibling, 0 replies; 54+ messages in thread
From: J.P. @ 2021-08-09 10:56 UTC (permalink / raw)
  To: Ulrich Mueller; +Cc: 29108

Ulrich Mueller <ulm@gentoo.org> writes:

> Sorry for the delay. On top of what version do these patches apply?
> I have tried with current master and with the 27.2 release but patches
> fail for both.

Sorry about that. This recent commit on master caught me off guard:

  commit 3b7b181bded1bddb2505eda1224a5631cbf04c1b
  Mattias Engdegård ~ 9 Aug 2021 05:50:18 -0400 (EDT)
  Use string-search instead of string-match[-p]

I have a CI rebaser job set up, but it only runs once a day. Let me do
this one manually. Can you maybe check again in a few hours? I'd also
like to incorporate some churn related to the eql specializer change.
Thanks.





^ permalink raw reply	[flat|nested] 54+ messages in thread

* bug#29108: 25.3; ERC SASL support
  2021-08-09 10:22     ` Ulrich Mueller
  2021-08-09 10:56       ` J.P.
@ 2021-08-09 12:39       ` J.P.
  1 sibling, 0 replies; 54+ messages in thread
From: J.P. @ 2021-08-09 12:39 UTC (permalink / raw)
  To: Ulrich Mueller; +Cc: 29108

Okay, they should apply cleanly now atop:

  commit aeec97fae0ccfcc4dc406a5e0e4c0a94b834cac4 (origin/master)
  Author: Mattias Engdegård <mattiase@acm.org>
  Date:   Mon Aug 9 12:09:49 2021 +0200

      Fix variable binding in calendar (bug#49945)

      * lisp/calendar/cal-tex.el (cal-tex-week-hours,
      cal-tex-daily-page): Use `let*` instead of `let`.

Same link as before. Apologies again for the false start.





^ permalink raw reply	[flat|nested] 54+ messages in thread

* bug#29108: 25.3; ERC SASL support
  2021-08-09  9:59   ` J.P.
  2021-08-09 10:22     ` Ulrich Mueller
@ 2021-08-23 13:47     ` J.P.
       [not found]     ` <87o89oi87g.fsf@neverwas.me>
  2 siblings, 0 replies; 54+ messages in thread
From: J.P. @ 2021-08-23 13:47 UTC (permalink / raw)
  To: Ulrich Mueller; +Cc: 29108, emacs-erc

Hi Ulrich,

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

> Then connect again (the client certs should be real, in preparation for
> the next demo):
>
>   (push 'sasl erc-v3-extensions)
>   (setq erc-v3-sasl-mechanism 'plain)
>   (erc-tls :server "testnet.inspircd.org"
>            :port 6697
>            :nick "my-nick"
>            :password "password123"
>             ^~~~~~~~~~~~~~~~~~~~~~~ gone
>            :full-name "My Nick"

I've changed things up a tad after realizing that appropriating the
dialed password parameter was a dumb idea. I guess in zealously adhering
to tradition (by mimicking erc-services.el, in this case), I also left
common sense at the door (yet again).

To clarify, I'm not talking about collisions with the legacy

  PASS my-nick:password123

authentication scheme, for which there still remains dwindling support
among public networks. That's mostly a nonissue because SASL supplants
that entirely.

Instead, I'm thinking of actual server (connection) passwords, even
though they're basically unheard of with public networks. And I suppose
there's also the possibility of the rare proxy wanting a piece of the
PASS action for its own wacky purposes, something like a

  PASS my-account@my-device/some-config-id:unused

preceding an SASL exchange moments later. (I haven't actually seen such
a thing in the wild, but it strikes me as plausible. Crazy?)

Anyway, since personal/enterprise IRC servers may still use actual
connection passwords, we've got to leave the `erc-tls' :password param
alone and introduce a separate SASL password option. Hope that's clear.

Also, in keeping with this policy, I've decided to discourage automatic
nick use for account user names. This also defies the ERC services API
but is nevertheless correct, IMO. So it's now

  (setq erc-v3-sasl-user "my-nick"
        erc-v3-sasl-password "password123")

or similar via M-x customize. BTW, auth source is consulted if you leave
the password out.

> This time, using EXTERNAL (note the lack of a password):
>
>   (setq erc-v3-sasl-mechanism 'external)

In other news, EXTERNAL usage hasn't changed, though I'm wondering if we
should maybe add a warning when tried in conjunction with TLS1.2 (or
lower). Any idea if sub-1.3 is even possible on a modern Emacs and if
so, whether a warning after the fact would suffice? Something like a

  (when (version< (substring (plist-get (gnutls-peer-status proc)
                                        :protocol)
                             3)
                  "1.3")
    (erc-display-error-notice nil "Warning: ..."))

except nicer perhaps? No idea. (@Lars or someone TLS savvy.)

Last side note: I'm thinking of moving the RPL_LOGGEDIN 900 handler out
of the erc-v3-sasl library or maybe instead defining a default that the
library can override when it loads. The reason is that some servers use
these numerics for confirming account authentication with the legacy
user:pass syntax noted above. And if we're trying to make sessions
account aware, this would accommodate people who for some reason still
want that user:pass stuff with v3 in lieu of SASL.

Anyway, when you have a sec, please try (again?) with these changes.
Thanks.





^ permalink raw reply	[flat|nested] 54+ messages in thread

* bug#29108: 25.3; ERC SASL support
       [not found]     ` <87o89oi87g.fsf@neverwas.me>
@ 2021-08-23 14:01       ` Lars Ingebrigtsen
       [not found]       ` <87zgt8s1jt.fsf@gnus.org>
  1 sibling, 0 replies; 54+ messages in thread
From: Lars Ingebrigtsen @ 2021-08-23 14:01 UTC (permalink / raw)
  To: J.P.; +Cc: Ulrich Mueller, 29108, emacs-erc

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

> In other news, EXTERNAL usage hasn't changed, though I'm wondering if we
> should maybe add a warning when tried in conjunction with TLS1.2 (or
> lower). Any idea if sub-1.3 is even possible on a modern Emacs and if
> so, whether a warning after the fact would suffice? Something like a
>
>   (when (version< (substring (plist-get (gnutls-peer-status proc)
>                                         :protocol)
>                              3)
>                   "1.3")
>     (erc-display-error-notice nil "Warning: ..."))
>
> except nicer perhaps? No idea. (@Lars or someone TLS savvy.)

I think that's up to the Network Security Manager level -- the user can
customise this there.

-- 
(domestic pets only, the antidote for overdose, milk.)
   bloggy blog: http://lars.ingebrigtsen.no





^ permalink raw reply	[flat|nested] 54+ messages in thread

* bug#29108: 25.3; ERC SASL support
       [not found]       ` <87zgt8s1jt.fsf@gnus.org>
@ 2021-08-24 13:42         ` J.P.
  0 siblings, 0 replies; 54+ messages in thread
From: J.P. @ 2021-08-24 13:42 UTC (permalink / raw)
  To: Lars Ingebrigtsen; +Cc: Ulrich Mueller, 29108, emacs-erc

Lars Ingebrigtsen <larsi@gnus.org> writes:

> I think that's up to the Network Security Manager level -- the user can
> customise this there.

Hi Lars, my apologies. I obviously wasn't being very clear but was
telepathically referring to client certs being transmitted in the clear
for TLS versions less than 1.3. I'm not sure any `nsm-protocol-check--*'
functions screen for this specifically because it's probably/rightly
considered less of a security issue and more of a minor personal-hygiene
matter.

And not that I'm privy to much in core IRC circles, but I suspect the
occasional smattering of attention paid to this topic can be chalked up
to bored devs just having a say about more exciting things like
dissidents and tracking. So for ERC's purposes, I guess there's little
sense in warning about what's probably just an iffy corner case in the
end. Thanks, and sorry for the distraction.





^ permalink raw reply	[flat|nested] 54+ messages in thread

* bug#29108: [J.P.] Add "non-IRCv3" SASL to ERC
  2017-11-01 20:07 bug#29108: 25.3; ERC SASL support Alex Branham
  2017-11-10  2:24 ` Noam Postavsky
  2021-07-28 16:59 ` Ulrich Mueller
@ 2022-09-18 18:32 ` J.P.
  2022-09-20  6:07   ` bug#29108: 25.3; ERC SASL support J.P.
       [not found]   ` <875yhifujk.fsf_-_@neverwas.me>
  2 siblings, 2 replies; 54+ messages in thread
From: J.P. @ 2022-09-18 18:32 UTC (permalink / raw)
  To: 29108; +Cc: emacs-erc

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

Resending (last attempt thwarted by archive police).

-------------------- Start of forwarded message --------------------
From: "J.P." <jp@neverwas.me>
To: 29108@debbugs.gnu.org
Cc: emacs-erc@gnu.org
Subject: Add "non-IRCv3" SASL to ERC
Date: Sun, 18 Sep 2022 07:09:45 -0700


[-- Attachment #2.1: Type: text/plain, Size: 988 bytes --]

Hi people,

As my prior comments on this matter may suggest, I've long been in favor
of holding SASL hostage until we can get a full CAP 3.2 implementation
in place. However, I'm ready to bow to public pressure on this and
entertain a partial (hack) implementation, in part because I now
recognize that a valid use case may yet exist for wanting SASL alone
without proper capability negotiation (even after that's eventually
introduced [1]).

I therefore offer this compromise reluctantly and still fear that going
this route will only prolong our complacency and further delay the sort
of meaningful evolution our client desperately needs.

Thanks,
J.P.

P.S. Our ELPA recipe would need updating before these changes could
land.

[1] Speaking of which, various aspects of the attached bug set may
    benefit from additional context. And for that, I'd kindly direct you
    to bug#49860, whose WIP patches can be found here:

    https://emacs-erc.gitlab.io/bugs/49860/patches.tar.gz 



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

From fd268c1302f15d19200483569d9db68d052643f6 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 1/3] Support local ERC modules in erc-mode buffers

* lisp/erc/erc.el (erc-migrate-modules): add some missing mappings.
(erc--module-name-migrations, erc--features-to-modules,
erc--modules-to-features): add alists to support simplified
module-name migrations.
(erc-update-modules): Change return value to a list of minor-mode
commands for local modules that need deferred activation, if any.  Use
`custom-variable-p' to detect flavor.  Currently, all modules are
global, meaning 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.
(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.  It's believed that the original authors
wanted this functionality.
---
 lisp/erc/erc.el            | 108 ++++++++++++++++++++++++-------------
 test/lisp/erc/erc-tests.el |  47 ++++++++++++++++
 2 files changed, 119 insertions(+), 36 deletions(-)

diff --git a/lisp/erc/erc.el b/lisp/erc/erc.el
index 151d75e7ce..89fc226599 100644
--- a/lisp/erc/erc.el
+++ b/lisp/erc/erc.el
@@ -1390,7 +1390,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:
 
@@ -1426,16 +1428,21 @@ 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 (erc--normalize-module-symbol ',name) erc-modules))
+         (when (or ,(not local-p) (eq major-mode 'erc-mode))
+           (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 (erc--normalize-module-symbol ',name)
+                                   erc-modules)))
+         (when (or ,(not local-p) ,mode)
+           (setq ,mode nil)
+           ,@disable-body))
        ,(when (and alias (not (eq name alias)))
           `(defalias
              ',(intern
@@ -2029,14 +2036,40 @@ erc-default-nicks
 (defvar-local erc-nick-change-attempt-count 0
   "Used to keep track of how many times an attempt at changing nick is made.")
 
+(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.")
+
+(defconst erc--modules-to-features
+  (cl-loop for (feature . names) in erc--features-to-modules
+           append (mapcar (lambda (name) (cons name feature)) names))
+  "Migration alist mapping a module's name to 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 (module)
+  "Canonicalize symbol MODULE for `erc-modules'."
+  (or (cdr (assq module erc--module-name-migrations)) module))
+
 (defun erc-migrate-modules (mods)
   "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
@@ -2115,27 +2148,22 @@ erc-modules
   :group 'erc)
 
 (defun erc-update-modules ()
-  "Run this to enable erc-foo-mode for all modules in `erc-modules'."
-  (let (req)
+  "Enable global minor mode for all global modules in `erc-modules'.
+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.6."
+  (let (local-modules)
     (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))
+      (require (or (alist-get mod erc--modules-to-features)
+                   (intern (concat "erc-" (symbol-name mod))))
+               nil 'noerror) ; some modules don't have a corresponding feature
       (let ((sym (intern-soft (concat "erc-" (symbol-name mod) "-mode"))))
-        (if (fboundp sym)
+        (unless (and sym (fboundp sym))
+          (error "`%s' is not a known ERC module" mod))
+        (if (custom-variable-p sym)
             (funcall sym 1)
-          (error "`%s' is not a known ERC module" mod))))))
+          (push sym local-modules))))
+    local-modules))
 
 (defun erc-setup-buffer (buffer)
   "Consults `erc-join-buffer' to find out how to display `BUFFER'."
@@ -2191,18 +2219,22 @@ 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))))
     (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))
+
+    (delay-mode-hooks (erc-mode))
+
+    (setq erc-server-reconnect-count old-recon-count)
+
     (when (setq erc-server-connected (not connect))
       (setq erc-server-announced-name
             (buffer-local-value 'erc-server-announced-name old-buffer)))
@@ -2265,6 +2297,12 @@ erc-open
     (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))
@@ -2276,8 +2314,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 55efe2fd2d..cbaa20fa67 100644
--- a/test/lisp/erc/erc-tests.el
+++ b/test/lisp/erc/erc-tests.el
@@ -975,4 +975,51 @@ 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 '(fake-foo fake-bar)))
+    (cl-letf (((symbol-function 'require)
+               (lambda (s &rest _) (push s calls)))
+              ((symbol-function 'erc-fake-foo-mode)
+               (lambda (n) (push (cons 'fake-foo n) calls)))
+              ;; Here, foo is a global module (minor mode)
+              ((get 'erc-fake-foo-mode 'standard-value) #'ignore)
+              ((symbol-function 'erc-fake-bar-mode)
+               (lambda (n) (push (cons 'fake-bar n) calls)))
+              ((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)))
+              ((symbol-function 'erc-completion-mode)
+               (lambda (n) (push (cons 'completion n) calls)))
+              ((get 'erc-completion-mode 'standard-value) #'ignore))
+
+      (ert-info ("Locals")
+        (should (equal (erc-update-modules)
+                       '(erc-fake-bar-mode)))
+        ;; Bar still required
+        (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)) ; no locals
+        (should (equal (nreverse calls)
+                       '(erc-pcomplete
+                         (completion . 1)
+                         erc-join
+                         (autojoin . 1)
+                         erc-networks
+                         (networks . 1))))
+        (setq calls nil)))))
+
 ;;; erc-tests.el ends here
-- 
2.37.2


[-- Warning: decoded text below may be mangled, UTF-8 assumed --]
[-- Attachment #2.3: 0002-Make-erc-login-generic.patch --]
[-- Type: text/x-patch, Size: 1965 bytes --]

From 2b3d432ec5210dadd91576da143825a1d6e4d190 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 2/3] Make erc-login generic

* lisp/erc/erc-backend (erc--register-connection): Add new generic
function that's just a wrapper for `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 df9efe4b0c..25c4481d1d 100644
--- a/lisp/erc/erc-backend.el
+++ b/lisp/erc/erc-backend.el
@@ -532,6 +532,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.
@@ -580,7 +584,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.
@@ -758,7 +762,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.37.2


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

From dde968397a20d7a27db2e04efc78737693c88d5e Mon Sep 17 00:00:00 2001
From: "F. Jason Park" <jp@neverwas.me>
Date: Sun, 18 Sep 2022 01:37:13 -0700
Subject: [PATCH 3/3] Add non-IRCv3 SASL module to ERC

* lisp/erc/erc-compat.el (erc-compat--scram--client-final-message):
Add partial authorization support via own variant of
`sasl-scram--client-final-message'.

* lisp/erc/erc-sasl.el: New file.
* 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.
---
 lisp/erc/erc-compat.el                        |  83 +++
 lisp/erc/erc-sasl.el                          | 483 ++++++++++++++++++
 test/lisp/erc/erc-sasl-tests.el               | 295 +++++++++++
 test/lisp/erc/erc-scenarios-sasl.el           | 161 ++++++
 test/lisp/erc/resources/sasl/external.eld     |  33 ++
 test/lisp/erc/resources/sasl/plain-failed.eld |  16 +
 test/lisp/erc/resources/sasl/plain.eld        |  35 ++
 test/lisp/erc/resources/sasl/scram-sha-1.eld  |  47 ++
 .../lisp/erc/resources/sasl/scram-sha-256.eld |  47 ++
 9 files changed, 1200 insertions(+)
 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/lisp/erc/erc-compat.el b/lisp/erc/erc-compat.el
index 8a00e711ac..8ba061d5ac 100644
--- a/lisp/erc/erc-compat.el
+++ b/lisp/erc/erc-compat.el
@@ -156,6 +156,89 @@ erc-subseq
 		 (setq i (1+ i) start (1+ start)))
 	       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 decode-hex-string "hex-util" (string))
+(declare-function rfc2104-hash "rfc2104" (hash block-length hash-length
+                                               key text))
+(declare-function cl-mapcar "cl-lib" (cl-func cl-x &rest cl-rest))
+
+;; This is `sasl-scram--client-final-message' from sasl-scram-rfc,
+;; with the NO-LINE-BREAK argument of `base64-encode-string' set to t.
+;; The only other substantial change is the addition of authz support.
+;; If adopted by Emacs 29, this can dropped when ERC no longer
+;; supports Emacs 28.  Unfortunately, advising `base64-encode-string'
+;; won't work because the byte compiler precomputes the result when
+;; all inputs are constants, as they are in the unpatched version.
+;; Changes from the latter are marked with a "; *n", comment below.
+;; See older versions of lisp/erc/erc-sasl.el if needing a
+;; 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--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)
+              (or (sasl-client-property client 'gs2-header) "n,,") ; *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))
+
 (provide 'erc-compat)
 
 ;;; erc-compat.el ends here
diff --git a/lisp/erc/erc-sasl.el b/lisp/erc/erc-sasl.el
new file mode 100644
index 0000000000..6cd9a928d8
--- /dev/null
+++ b/lisp/erc/erc-sasl.el
@@ -0,0 +1,483 @@
+;;; 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:
+
+;; WARNING: this is a naive/hack (non-IRCv3) implementation of SASL.
+;; Please see bug#49860, which adds full 3.2 capability negotiation.
+
+;; Various ERC implementations of the PLAIN mechanism have surfaced
+;; over the years, the first possibly being:
+;;
+;; https://lists.gnu.org/archive/html/erc-discuss/2012-02/msg00001.html
+;;
+;; This module would not exist without this and other pioneering
+;; efforts.
+;;
+;; FIXME move the following to doc/misc/erc.texi
+;;
+;; Regardless of the mechanism or server, you'll likely have to be
+;; registered before first use.  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 client -> bouncer connection.
+;;
+;; Note that `sasl' is a "local" ERC module (effectively introduced in
+;; ERC 5.5).  This means invoking `erc-sasl-mode' manually or calling
+;; `erc-update-modules' won't do any good.  Instead, simply add `sasl'
+;; to `erc-modules' or `let'-bind it while calling `erc-tls', and SASL
+;; will be enabled for the current connection.  But before that,
+;; please explore all custom options that pertain to your chosen
+;; mechanism.
+;;
+;; Password-based mechanisms:
+;;
+;;   Here, "password" refers to your account password, which is
+;;   usually your NickServ password.  This often differs from any
+;;   connection (server) password given to `erc-tls' via its :password
+;;   arg.  To make this work, customize both `erc-sasl-user' and
+;;   `erc-sasl-password' or bind them when invoking `erc-tls'.
+;;
+;;   When `erc-sasl-password' is a string, it's used unconditionally.
+;;   When it's a non-nil symbol, like Libera.Chat, it's used as the
+;;   host param in an auth-source query.  When it's nil and a session
+;;   ID is on file (see `erc-tls'), the ID is instead used for the
+;;   host param.  The value of `erc-sasl-user' is always specified for
+;;   the user (login) param.  See the info node "(erc) Connecting" for
+;;   specifics.
+;;
+;;   If no password can be determined, a non-nil connection password
+;;   will be tried (but this may change, so please don't rely on it).
+;;
+;; EXTERNAL (with Client TLS Certificate):
+;;
+;;   1. Specify the `:client-certificate' param when opening a new
+;;      connection, which is typically done by calling `emacs-tls'.
+;;      See (info "(erc) Connecting").
+;;
+;;   2. Ensure you've registered your fingerprint with the network and
+;;      (re)connect.  The fingerprint is usually a SHA1 or SHA256
+;;      digest in either "normalized" or "openssl" forms.  The first
+;;      is lowercase without delims ("deadbeef") and the second
+;;      uppercase with colon seps ("DE:AD:BE:EF").
+;;
+;;   There's no reason to send your password after registering.  Note
+;;   that most ircds will allow you to authenticate with a client cert
+;;   but without the hassle of SASL (meaning you may not need this
+;;   module).
+;;
+;; ECDSA-NIST256P-CHALLENGE:
+;;
+;;   Use something else if at all possible.  This currently requires
+;;   the openssl command-line utility.  On servers running Atheme
+;;   services, add your public key with NickServ like so:
+;;
+;;   /msg NickServ set property
+;;     pubkey AgGZmlYTUjJlea/BVz7yrjJ6gysiAPaQxzeUzTH4hd5j
+;;
+;;   (You may not need the "property" subcommand.)
+;;
+;;
+;; TODO
+;;
+;; - Implement pseudo PASSWORD mechanism that chooses the strongest
+;;   available mechanism for you.
+;;
+;; - Maybe provide explicit authz.  Currently, there's only an obscure
+;;   customizable function option for SCRAM and nothing for plain.
+
+;;; Code:
+(require 'erc-backend)
+(require 'rx)
+(require 'sasl)
+(require 'sasl-scram-rfc)
+(require 'sasl-scram-sha256 nil t)
+
+(defgroup erc-sasl nil
+  "SASL for ERC."
+  :group 'erc
+  :package-version '(ERC . "5.4")) ; FIXME increment on next release
+
+(defcustom erc-sasl-mechanism nil
+  "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 nil)
+                 (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, it's used unconditionally.  As a
+special case, when the value is a non-nil symbol, it's used as
+the value of the `:host' field in an auth-source query, provided
+`erc-sasl-auth-source-function' is set to a function.  When
+nil, a non-nil \"session password\" will be tried, likely one
+given as the `:password' argument to `erc-tls'.  As a last
+resort, the user will be prompted for input."
+  :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 (const erc-auth-source-search)
+                 (const nil)
+                 function))
+
+(defcustom erc-sasl-ecdsa-private-key nil
+  "Private signing key file for ECDSA-NIST256P-CHALLENGE."
+  :type '(choice (const nil) string))
+
+(defcustom erc-sasl-scram-authzid-function nil
+  "Function for retrieving authorization for SCRAM GSS-API header.
+Passed current SASL client object as the sole argument (see
+function `sasl-make-client')."
+  :type '(choice (const nil) function))
+
+
+;; Analogous to what erc-backend does to persist opening params.
+(defvar-local erc-sasl--options nil)
+
+;; 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."
+  ;; Copying prevent `sasl-plain-response' from clobbering
+  (if-let
+      ((found
+        (or (and-let* ((pass (alist-get 'password erc-sasl--options))
+                       ((stringp pass))
+                       (pass)))
+            (and erc-sasl-auth-source-function
+                 (let ((user (alist-get 'user erc-sasl--options))
+                       (host (alist-get 'password erc-sasl--options)))
+                   (apply erc-sasl-auth-source-function
+                          `(,@(and user (list :user user))
+                            ,@(and host (list :host (symbol-name host)))))))
+            erc-session-password)))
+      (copy-sequence found)
+    (read-passwd prompt)))
+
+(defun erc-sasl--plain-response (client steps)
+  "Call `sasl-plain-response' with CLIENT and STEPS."
+  (let ((sasl-read-passphrase #'erc-sasl--read-password))
+    (sasl-plain-response client steps)))
+
+(defun erc-sasl--scram-client-first-message (client _step)
+  "Prepare CLIENT's first message."
+  (let* ((c-nonce (sasl-unique-id))
+         (fn (alist-get 'scram-authzid-function erc-sasl--options))
+         (authzid (and fn (concat "a=" (funcall fn client))))
+         (gs2-header (concat "n," authzid ",")))
+    (sasl-client-set-property client 'c-nonce c-nonce)
+    (sasl-client-set-property client 'gs2-header gs2-header)
+    (concat gs2-header (sasl-scram--client-first-message-bare client))))
+
+(declare-function erc-compat--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)
+  "Call `sasl-scram--client-final-message' with args.
+Pass HASH-FUN, BLOCK-LENGTH, HASH-LENGTH, CLIENT, and STEP
+directly upstream."
+  ;; 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--scram--client-final-message args)))
+
+(defun erc-sasl--scram-sha-1-client-final-message (client step)
+  "Prepare CLIENT's final message with 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)
+  "Prepare CLIENT's final message with 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)
+  "Pass OBJECT, START, END, and BINARY to `secure-hash'."
+  (secure-hash 'sha512 object start end binary))
+
+(defun erc-sasl--scram-sha-512-client-final-message (client step)
+  "Prepare CLIENT's final message with 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)
+  "Call `sasl-scram--authenticate-server' with CLIENT and 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 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"
+                           (alist-get 'ecdsa-private-key erc-sasl--options)
+                           "-sign")
+      (buffer-string))))
+
+;; This API may seem roundabout, but the "template method" here is
+;; one that we provide, namely `erc-sasl--authenticate-handler'.
+
+(pcase-dolist
+    (`(,name . ,steps)
+     '(("PLAIN"
+        erc-sasl--plain-response)
+       ("EXTERNAL"
+        ignore)
+       ("SCRAM-SHA-1"
+        erc-sasl--scram-client-first-message
+        erc-sasl--scram-sha-1-client-final-message
+        sasl-scram-sha-1-authenticate-server)
+       ("SCRAM-SHA-256"
+        erc-sasl--scram-client-first-message
+        erc-sasl--scram-sha-256-client-final-message
+        sasl-scram-sha-256-authenticate-server)
+       ("SCRAM-SHA-512"
+        erc-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 (concat "erc-sasl-" (symbol-name mechanism)))))
+    (setf (alist-get name sasl-mechanism-alist nil nil #'equal) `(,feature))
+    (cl-pushnew name sasl-mechanisms :test #'equal)
+    (sasl-make-client (sasl-find-mechanism `(,name))
+                      (or (alist-get 'user erc-sasl--options)
+                          (erc-downcase (erc-current-nick)))
+                      "N/A" "N/A")))
+
+;; Oragono doesn't like when authzid (if present) does not match
+;; the authcid.  TODO see if this still true.
+
+(cl-defmethod erc-sasl--create-client ((_m (eql plain)))
+  "Create and return new SASL PLAIN client object.
+See message breakdown at
+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 authc)
+    client))
+
+(cl-defmethod erc-sasl--create-client ((m (eql scram-sha-256)))
+  "Create a 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 a 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 a ECDSA-NIST256P-CHALLENGE client."
+  (unless (executable-find "openssl")
+    (user-error "Could not find openssl command-line utility"))
+  (unless (and (alist-get 'ecdsa-private-key erc-sasl--options)
+               (file-exists-p (alist-get 'ecdsa-private-key
+                                         erc-sasl--options)))
+    (user-error "Could not find `erc-sasl-ecdsa-private-key'"))
+  (cl-call-next-method))
+
+(defun erc-sasl--init ()
+  (setq erc-sasl--state (make-erc-sasl--state)
+        erc-sasl--options `((user . ,erc-sasl-user)
+                            (password . ,erc-sasl-password)
+                            (mechanism . ,erc-sasl-mechanism)
+                            (ecdsa-private-key . ,erc-sasl-ecdsa-private-key)
+                            (scram-authzid-function
+                             . ,erc-sasl-scram-authzid-function))))
+
+(defun erc-sasl--mechanism-offered-p (offered)
+  "Non-nil when mechanism OFFERED by server."
+  (string-match-p (rx-to-string
+                   `(: (| bot ",")
+                       ,(symbol-name
+                         (alist-get 'mechanism erc-sasl--options))
+                       (| eot ",")))
+                  (downcase offered)))
+
+(defun erc-sasl--add-hook ()
+  (add-hook 'erc-server-AUTHENTICATE-functions
+            #'erc-sasl--authenticate-handler 0 t))
+
+(defun erc-sasl--remove-hook ()
+  (remove-hook 'erc-server-AUTHENTICATE-functions
+               #'erc-sasl--authenticate-handler t))
+
+(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 (dumb) SASL support for ERC.
+Needless to say, 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
+     (erc-sasl--add-hook)
+     (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 mechanism: %s" mech))
+         (erc-error "Unknown mechanism: %s" mech))
+       (setf (erc-sasl--state-client erc-sasl--state) client))))
+  ((erc-sasl--remove-hook)
+   (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)
+
+;; FIXME do something decisive here
+(define-erc-response-handler (902)
+  "Handle a ERR_NICKLOCKED response." nil
+  (let ((nick (car (erc-response.command-args parsed)))
+        (msg (erc-response.contents parsed)))
+    (erc-display-message parsed '(notice error) 'active 's902 ?n nick ?s msg)))
+
+(define-erc-response-handler (903)
+  "Handle a RPL_SASLSUCCESS response." nil
+  (when erc-sasl-mode
+    (unless erc-server-connected
+      (erc-server-send "CAP END")))
+  (erc-handle-unknown-server-response proc parsed))
+
+(define-erc-response-handler (904 905 906 907 908)
+  "Handle various SASL-related error responses." nil
+  (let* ((msg (intern (format "s%s" (erc-response.command parsed))))
+         (args `(parsed (notice error) active ,msg
+                        ,@(when (string= "908" (erc-response.command parsed))
+                            (list '?m
+                                  (alist-get 'mechanism erc-sasl--options)))
+                        ?s ,(erc-response.contents parsed))))
+    (apply #'erc-display-message args))
+  (when (member (erc-response.command parsed) '("904" "905" "906"))
+    (run-hook-with-args 'erc-quit-hook proc)
+    (delete-process proc)
+    (erc-error "Disconnected from %s; please review SASL settings" proc)))
+
+(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))))
+
+(provide 'erc-sasl)
+;;; erc-sasl.el ends here
+;;
+;; Local Variables:
+;; generated-autoload-file: "erc-loaddefs.el"
+;; End:
diff --git a/test/lisp/erc/erc-sasl-tests.el b/test/lisp/erc/erc-sasl-tests.el
new file mode 100644
index 0000000000..5171a5d6b8
--- /dev/null
+++ b/test/lisp/erc/erc-sasl-tests.el
@@ -0,0 +1,295 @@
+;;; erc-sasl-tests.el --- Tests for erc-sasl.  -*- lexical-binding:t -*-
+
+;; Copyright (C) 2020-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 GNU/chat port 6697 user bob password spam"
+                   "machine FSF.chat port 6697 user bob password sesame"
+                   "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-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")
+            (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 '((scram-authzid-function . sasl-client-name)))
+         (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-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 '((scram-authzid-function . sasl-client-name)))
+         (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-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-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-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 ()
+  (unless (executable-find "openssl")
+    (ert-skip "System lacks openssl"))
+  (let* ((erc-server-current-nick "jilles")
+         (keyfile (make-temp-file "ecdsa_key.pem" nil nil
+                                  erc-sasl-tests-ecdsa-key-file))
+         (erc-sasl--options `((ecdsa-private-key . ,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))
+        ;; FIXME this is dumb
+        (should (<= 68 (length (sasl-step-data step)) 72))))
+    (should-not (sasl-next-step client step))
+    (delete-file keyfile)))
+
+;;; 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..b4f926b54c
--- /dev/null
+++ b/test/lisp/erc/erc-scenarios-sasl.el
@@ -0,0 +1,161 @@
+;;; 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")
+       (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"))))))
+
+(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)
+       (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)
+       (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 zfunc)
+  (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-scram-authzid-function zfunc)
+       (erc-sasl-password "sesame")
+       (erc-sasl-mechanism mech)
+       (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)
+  (erc-scenarios--common--sasl 'scram-sha-1 #'sasl-client-name))
+
+(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 nil))
+
+;;; erc-scenarios-sasl.el ends here
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..9c6ce3feeb
--- /dev/null
+++ b/test/lisp/erc/resources/sasl/plain.eld
@@ -0,0 +1,35 @@
+;; -*- 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."))
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.37.2


[-- Attachment #3: Type: text/plain, Size: 67 bytes --]

-------------------- End of forwarded message --------------------

^ permalink raw reply related	[flat|nested] 54+ messages in thread

* bug#29108: 25.3; ERC SASL support
  2022-09-18 18:32 ` bug#29108: [J.P.] Add "non-IRCv3" SASL to ERC J.P.
@ 2022-09-20  6:07   ` J.P.
       [not found]   ` <875yhifujk.fsf_-_@neverwas.me>
  1 sibling, 0 replies; 54+ messages in thread
From: J.P. @ 2022-09-20  6:07 UTC (permalink / raw)
  To: 29108; +Cc: emacs-erc

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

v2. Reworked to more faithfully align with original sasl.el design.
Added dedicated authz user option and related patch for
sasl-scram-rfc.el (to be offered in separate bug report).

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

From b2e7df6b097b4b203860189dd59219909959c016 Mon Sep 17 00:00:00 2001
From: "F. Jason Park" <jp@neverwas.me>
Date: Mon, 19 Sep 2022 22:55:25 -0700
Subject: [PATCH 0/4] *** NOT A PATCH ***

*** BLURB HERE ***

F. Jason Park (4):
  Add GS2 authorization to sasl-scram-rfc
  Support local ERC modules in erc-mode buffers
  Make erc-login generic
  Add non-IRCv3 SASL module to ERC

 lisp/erc/erc-backend.el                       |   8 +-
 lisp/erc/erc-compat.el                        | 104 ++++
 lisp/erc/erc-sasl.el                          | 477 ++++++++++++++++++
 lisp/erc/erc.el                               | 108 ++--
 lisp/net/sasl-scram-rfc.el                    |  21 +-
 test/lisp/erc/erc-sasl-tests.el               | 299 +++++++++++
 test/lisp/erc/erc-scenarios-sasl.el           | 161 ++++++
 test/lisp/erc/erc-tests.el                    |  47 ++
 test/lisp/erc/resources/sasl/external.eld     |  33 ++
 test/lisp/erc/resources/sasl/plain-failed.eld |  16 +
 test/lisp/erc/resources/sasl/plain.eld        |  35 ++
 test/lisp/erc/resources/sasl/scram-sha-1.eld  |  47 ++
 .../lisp/erc/resources/sasl/scram-sha-256.eld |  47 ++
 13 files changed, 1358 insertions(+), 45 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/lisp/erc/erc-compat.el b/lisp/erc/erc-compat.el
index 8ba061d5ac..3123f64b88 100644
--- a/lisp/erc/erc-compat.el
+++ b/lisp/erc/erc-compat.el
@@ -167,25 +167,46 @@ erc-subseq
 (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.
+;; 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, advising `base64-encode-string' won't work
+;; because the byte compiler precomputes the result when all inputs
+;; are constants, as they are in the unpatched version.
+;;
 ;; The only other substantial change is the addition of authz support.
-;; If adopted by Emacs 29, this can dropped when ERC no longer
-;; supports Emacs 28.  Unfortunately, advising `base64-encode-string'
-;; won't work because the byte compiler precomputes the result when
-;; all inputs are constants, as they are in the unpatched version.
-;; Changes from the latter are marked with a "; *n", comment below.
-;; See older versions of lisp/erc/erc-sasl.el if needing a
+;; 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--scram--client-final-message
+(defun erc-compat--sasl-scram--client-final-message
     (hash-fun block-length hash-length client step)
   (unless (string-match
            "^r=\\([^,]+\\),s=\\([^,]+\\),i=\\([0-9]+\\)\\(?:$\\|,\\)"
@@ -202,7 +223,7 @@ erc-compat--scram--client-final-message
          (c-nonce (sasl-client-property client 'c-nonce))
          (cbind-input
           (if (string-prefix-p c-nonce nonce)
-              (or (sasl-client-property client 'gs2-header) "n,,") ; *1
+              (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
diff --git a/lisp/erc/erc-sasl.el b/lisp/erc/erc-sasl.el
index 6cd9a928d8..bd27934125 100644
--- a/lisp/erc/erc-sasl.el
+++ b/lisp/erc/erc-sasl.el
@@ -162,11 +162,11 @@ erc-sasl-ecdsa-private-key
   "Private signing key file for ECDSA-NIST256P-CHALLENGE."
   :type '(choice (const nil) string))
 
-(defcustom erc-sasl-scram-authzid-function nil
-  "Function for retrieving authorization for SCRAM GSS-API header.
-Passed current SASL client object as the sole argument (see
-function `sasl-make-client')."
-  :type '(choice (const nil) function))
+(defcustom erc-sasl-authzid nil
+  "SASL authorization identity.
+Generally unneeded for normal use.  Some test frameworks and
+aberrant servers may want this to match `erc-sasl-user'."
+  :type '(choice (const nil) string))
 
 
 ;; Analogous to what erc-backend does to persist opening params.
@@ -205,17 +205,7 @@ erc-sasl--plain-response
   (let ((sasl-read-passphrase #'erc-sasl--read-password))
     (sasl-plain-response client steps)))
 
-(defun erc-sasl--scram-client-first-message (client _step)
-  "Prepare CLIENT's first message."
-  (let* ((c-nonce (sasl-unique-id))
-         (fn (alist-get 'scram-authzid-function erc-sasl--options))
-         (authzid (and fn (concat "a=" (funcall fn client))))
-         (gs2-header (concat "n," authzid ",")))
-    (sasl-client-set-property client 'c-nonce c-nonce)
-    (sasl-client-set-property client 'gs2-header gs2-header)
-    (concat gs2-header (sasl-scram--client-first-message-bare client))))
-
-(declare-function erc-compat--scram--client-final-message "erc-compat"
+(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)
@@ -226,7 +216,7 @@ erc-sasl--scram-sha-hack-client-final-message
   ;; `sasl-scram--client-final-message' directly
   (require 'erc-compat)
   (let ((sasl-read-passphrase #'erc-sasl--read-password))
-    (apply #'erc-compat--scram--client-final-message args)))
+    (apply #'erc-compat--sasl-scram--client-final-message args)))
 
 (defun erc-sasl--scram-sha-1-client-final-message (client step)
   "Prepare CLIENT's final message with STEP."
@@ -278,15 +268,15 @@ erc-sasl--ecdsa-sign
        ("EXTERNAL"
         ignore)
        ("SCRAM-SHA-1"
-        erc-sasl--scram-client-first-message
+        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-sasl--scram-client-first-message
+        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-sasl--scram-client-first-message
+        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"
@@ -301,13 +291,17 @@ 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 (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)
-    (sasl-make-client (sasl-find-mechanism `(,name))
-                      (or (alist-get 'user erc-sasl--options)
-                          (erc-downcase (erc-current-nick)))
-                      "N/A" "N/A")))
+    (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))
 
 ;; Oragono doesn't like when authzid (if present) does not match
 ;; the authcid.  TODO see if this still true.
@@ -328,7 +322,8 @@ erc-sasl--create-client
          (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 authc)
+    (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)))
@@ -359,8 +354,7 @@ erc-sasl--init
                             (password . ,erc-sasl-password)
                             (mechanism . ,erc-sasl-mechanism)
                             (ecdsa-private-key . ,erc-sasl-ecdsa-private-key)
-                            (scram-authzid-function
-                             . ,erc-sasl-scram-authzid-function))))
+                            (authzid . ,erc-sasl-authzid))))
 
 (defun erc-sasl--mechanism-offered-p (offered)
   "Non-nil when mechanism OFFERED by server."
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)))
diff --git a/test/lisp/erc/erc-sasl-tests.el b/test/lisp/erc/erc-sasl-tests.el
index 5171a5d6b8..beac287a6e 100644
--- a/test/lisp/erc/erc-sasl-tests.el
+++ b/test/lisp/erc/erc-sasl-tests.el
@@ -109,15 +109,16 @@ erc-sasl-create-client--external
 (ert-deftest erc-sasl-create-client--scram-sha-1 ()
   (let* ((erc-server-current-nick "jilles")
          (erc-session-password "sesame")
-         (erc-sasl--options '((scram-authzid-function . sasl-client-name)))
+         (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-sasl--scram-client-first-message
-                                      ,req])
+        (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")
@@ -146,15 +147,16 @@ erc-sasl-create-client--scram-sha-256
     (ert-skip "Emacs lacks sasl-scram-sha256"))
   (let* ((erc-server-current-nick "jilles")
          (erc-session-password "sesame")
-         (erc-sasl--options '((scram-authzid-function . sasl-client-name)))
+         (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-sasl--scram-client-first-message
-                                      ,req])
+        (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")
@@ -191,8 +193,9 @@ erc-sasl-create-client--scram-sha-256--no-authzid
          (step (sasl-next-step client nil)))
     (ert-info ("Client's initial request")
       (let ((req "n,,n=jilles,r=c5RqLCZy0L4fGkKAZ0hujFBs"))
-        (should (equal (format "%S" `[erc-sasl--scram-client-first-message
-                                      ,req])
+        (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")
@@ -229,8 +232,9 @@ erc-sasl-create-client--scram-sha-512--no-authzid
          (step (sasl-next-step client nil)))
     (ert-info ("Client's initial request")
       (let ((req "n,,n=jilles,r=c5RqLCZy0L4fGkKAZ0hujFBs"))
-        (should (equal (format "%S" `[erc-sasl--scram-client-first-message
-                                      ,req])
+        (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")
diff --git a/test/lisp/erc/erc-scenarios-sasl.el b/test/lisp/erc/erc-scenarios-sasl.el
index b4f926b54c..3ff7cc805d 100644
--- a/test/lisp/erc/erc-scenarios-sasl.el
+++ b/test/lisp/erc/erc-scenarios-sasl.el
@@ -118,7 +118,7 @@ erc-scenarios-sasl--plain-fail
           (funcall expect 20 "SASL authentication failed")
           (should-not (erc-server-process-alive)))))))
 
-(defun erc-scenarios--common--sasl (mech zfunc)
+(defun erc-scenarios--common--sasl (mech)
   (erc-scenarios-common-with-cleanup
       ((erc-scenarios-common-dialog "sasl")
        (erc-d-linger-secs 0.5)
@@ -126,7 +126,6 @@ erc-scenarios--common--sasl
        (dumb-server (erc-d-run "localhost" t mech))
        (port (process-contact dumb-server :service))
        (erc-modules (cons 'sasl erc-modules))
-       (erc-sasl-scram-authzid-function zfunc)
        (erc-sasl-password "sesame")
        (erc-sasl-mechanism mech)
        (mock-rvs (list "c5RqLCZy0L4fGkKAZ0hujFBs" ""))
@@ -150,12 +149,13 @@ erc-scenarios--common--sasl
 
 (ert-deftest erc-scenarios-sasl--scram-sha-1 ()
   :tags '(:expensive-test)
-  (erc-scenarios--common--sasl 'scram-sha-1 #'sasl-client-name))
+  (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 nil))
+  (erc-scenarios--common--sasl 'scram-sha-256))
 
 ;;; erc-scenarios-sasl.el ends here
-- 
2.37.2


[-- 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: 2949 bytes --]

From 91e33541457a55e2e509d800cd8b9f97702e706d 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/4] Add GS2 authorization to sasl-scram-rfc

* lisp/net/sasl-scram-rfc.el (sasl-scram-fs2-header-function,
sasl-scram-construct-gs2-header): Add new variable and default
function for determining a SCRAM GSS-API message header.
(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.
---
 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.37.2


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

From b88bcadffba84b64ae91d45b84736313ac49dfef 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 2/4] Support local ERC modules in erc-mode buffers

* lisp/erc/erc.el (erc-migrate-modules): add some missing mappings.
(erc--module-name-migrations, erc--features-to-modules,
erc--modules-to-features): add alists to support simplified
module-name migrations.
(erc-update-modules): Change return value to a list of minor-mode
commands for local modules that need deferred activation, if any.  Use
`custom-variable-p' to detect flavor.  Currently, all modules are
global, meaning 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.
(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.  It's believed that the original authors
wanted this functionality.
---
 lisp/erc/erc.el            | 108 ++++++++++++++++++++++++-------------
 test/lisp/erc/erc-tests.el |  47 ++++++++++++++++
 2 files changed, 119 insertions(+), 36 deletions(-)

diff --git a/lisp/erc/erc.el b/lisp/erc/erc.el
index 20f22c896f..8fa9d0c8a3 100644
--- a/lisp/erc/erc.el
+++ b/lisp/erc/erc.el
@@ -1390,7 +1390,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:
 
@@ -1426,16 +1428,21 @@ 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 (erc--normalize-module-symbol ',name) erc-modules))
+         (when (or ,(not local-p) (eq major-mode 'erc-mode))
+           (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 (erc--normalize-module-symbol ',name)
+                                   erc-modules)))
+         (when (or ,(not local-p) ,mode)
+           (setq ,mode nil)
+           ,@disable-body))
        ,(when (and alias (not (eq name alias)))
           `(defalias
              ',(intern
@@ -2030,14 +2037,40 @@ erc-default-nicks
 (defvar-local erc-nick-change-attempt-count 0
   "Used to keep track of how many times an attempt at changing nick is made.")
 
+(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.")
+
+(defconst erc--modules-to-features
+  (cl-loop for (feature . names) in erc--features-to-modules
+           append (mapcar (lambda (name) (cons name feature)) names))
+  "Migration alist mapping a module's name to 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 (module)
+  "Canonicalize symbol MODULE for `erc-modules'."
+  (or (cdr (assq module erc--module-name-migrations)) module))
+
 (defun erc-migrate-modules (mods)
   "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
@@ -2116,27 +2149,22 @@ erc-modules
   :group 'erc)
 
 (defun erc-update-modules ()
-  "Run this to enable erc-foo-mode for all modules in `erc-modules'."
-  (let (req)
+  "Enable global minor mode for all global modules in `erc-modules'.
+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.6."
+  (let (local-modules)
     (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))
+      (require (or (alist-get mod erc--modules-to-features)
+                   (intern (concat "erc-" (symbol-name mod))))
+               nil 'noerror) ; some modules don't have a corresponding feature
       (let ((sym (intern-soft (concat "erc-" (symbol-name mod) "-mode"))))
-        (if (fboundp sym)
+        (unless (and sym (fboundp sym))
+          (error "`%s' is not a known ERC module" mod))
+        (if (custom-variable-p sym)
             (funcall sym 1)
-          (error "`%s' is not a known ERC module" mod))))))
+          (push sym local-modules))))
+    local-modules))
 
 (defun erc-setup-buffer (buffer)
   "Consults `erc-join-buffer' to find out how to display `BUFFER'."
@@ -2192,18 +2220,22 @@ 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))))
     (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))
+
+    (delay-mode-hooks (erc-mode))
+
+    (setq erc-server-reconnect-count old-recon-count)
+
     (when (setq erc-server-connected (not connect))
       (setq erc-server-announced-name
             (buffer-local-value 'erc-server-announced-name old-buffer)))
@@ -2266,6 +2298,12 @@ erc-open
     (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))
@@ -2277,8 +2315,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 b2ed29e80e..d3d319ab22 100644
--- a/test/lisp/erc/erc-tests.el
+++ b/test/lisp/erc/erc-tests.el
@@ -975,4 +975,51 @@ 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 '(fake-foo fake-bar)))
+    (cl-letf (((symbol-function 'require)
+               (lambda (s &rest _) (push s calls)))
+              ((symbol-function 'erc-fake-foo-mode)
+               (lambda (n) (push (cons 'fake-foo n) calls)))
+              ;; Here, foo is a global module (minor mode)
+              ((get 'erc-fake-foo-mode 'standard-value) #'ignore)
+              ((symbol-function 'erc-fake-bar-mode)
+               (lambda (n) (push (cons 'fake-bar n) calls)))
+              ((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)))
+              ((symbol-function 'erc-completion-mode)
+               (lambda (n) (push (cons 'completion n) calls)))
+              ((get 'erc-completion-mode 'standard-value) #'ignore))
+
+      (ert-info ("Locals")
+        (should (equal (erc-update-modules)
+                       '(erc-fake-bar-mode)))
+        ;; Bar still required
+        (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)) ; no locals
+        (should (equal (nreverse calls)
+                       '(erc-pcomplete
+                         (completion . 1)
+                         erc-join
+                         (autojoin . 1)
+                         erc-networks
+                         (networks . 1))))
+        (setq calls nil)))))
+
 ;;; erc-tests.el ends here
-- 
2.37.2


[-- Warning: decoded text below may be mangled, UTF-8 assumed --]
[-- Attachment #5: 0003-Make-erc-login-generic.patch --]
[-- Type: text/x-patch, Size: 1965 bytes --]

From 1d59baf98f0b9fe4178b18eeaa7ad79f48b14ee7 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 3/4] Make erc-login generic

* lisp/erc/erc-backend (erc--register-connection): Add new generic
function that's just a wrapper for `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 df9efe4b0c..25c4481d1d 100644
--- a/lisp/erc/erc-backend.el
+++ b/lisp/erc/erc-backend.el
@@ -532,6 +532,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.
@@ -580,7 +584,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.
@@ -758,7 +762,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.37.2


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

From b2e7df6b097b4b203860189dd59219909959c016 Mon Sep 17 00:00:00 2001
From: "F. Jason Park" <jp@neverwas.me>
Date: Sun, 18 Sep 2022 01:37:13 -0700
Subject: [PATCH 4/4] 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-sasl.el: New file.
* 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.
---
 lisp/erc/erc-compat.el                        | 104 ++++
 lisp/erc/erc-sasl.el                          | 477 ++++++++++++++++++
 test/lisp/erc/erc-sasl-tests.el               | 299 +++++++++++
 test/lisp/erc/erc-scenarios-sasl.el           | 161 ++++++
 test/lisp/erc/resources/sasl/external.eld     |  33 ++
 test/lisp/erc/resources/sasl/plain-failed.eld |  16 +
 test/lisp/erc/resources/sasl/plain.eld        |  35 ++
 test/lisp/erc/resources/sasl/scram-sha-1.eld  |  47 ++
 .../lisp/erc/resources/sasl/scram-sha-256.eld |  47 ++
 9 files changed, 1219 insertions(+)
 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/lisp/erc/erc-compat.el b/lisp/erc/erc-compat.el
index 8a00e711ac..3123f64b88 100644
--- a/lisp/erc/erc-compat.el
+++ b/lisp/erc/erc-compat.el
@@ -156,6 +156,110 @@ erc-subseq
 		 (setq i (1+ i) start (1+ start)))
 	       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, advising `base64-encode-string' won't work
+;; because the byte compiler precomputes the result when all inputs
+;; are constants, as they are in the unpatched 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))
+
 (provide 'erc-compat)
 
 ;;; erc-compat.el ends here
diff --git a/lisp/erc/erc-sasl.el b/lisp/erc/erc-sasl.el
new file mode 100644
index 0000000000..bd27934125
--- /dev/null
+++ b/lisp/erc/erc-sasl.el
@@ -0,0 +1,477 @@
+;;; 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:
+
+;; WARNING: this is a naive/hack (non-IRCv3) implementation of SASL.
+;; Please see bug#49860, which adds full 3.2 capability negotiation.
+
+;; Various ERC implementations of the PLAIN mechanism have surfaced
+;; over the years, the first possibly being:
+;;
+;; https://lists.gnu.org/archive/html/erc-discuss/2012-02/msg00001.html
+;;
+;; This module would not exist without this and other pioneering
+;; efforts.
+;;
+;; FIXME move the following to doc/misc/erc.texi
+;;
+;; Regardless of the mechanism or server, you'll likely have to be
+;; registered before first use.  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 client -> bouncer connection.
+;;
+;; Note that `sasl' is a "local" ERC module (effectively introduced in
+;; ERC 5.5).  This means invoking `erc-sasl-mode' manually or calling
+;; `erc-update-modules' won't do any good.  Instead, simply add `sasl'
+;; to `erc-modules' or `let'-bind it while calling `erc-tls', and SASL
+;; will be enabled for the current connection.  But before that,
+;; please explore all custom options that pertain to your chosen
+;; mechanism.
+;;
+;; Password-based mechanisms:
+;;
+;;   Here, "password" refers to your account password, which is
+;;   usually your NickServ password.  This often differs from any
+;;   connection (server) password given to `erc-tls' via its :password
+;;   arg.  To make this work, customize both `erc-sasl-user' and
+;;   `erc-sasl-password' or bind them when invoking `erc-tls'.
+;;
+;;   When `erc-sasl-password' is a string, it's used unconditionally.
+;;   When it's a non-nil symbol, like Libera.Chat, it's used as the
+;;   host param in an auth-source query.  When it's nil and a session
+;;   ID is on file (see `erc-tls'), the ID is instead used for the
+;;   host param.  The value of `erc-sasl-user' is always specified for
+;;   the user (login) param.  See the info node "(erc) Connecting" for
+;;   specifics.
+;;
+;;   If no password can be determined, a non-nil connection password
+;;   will be tried (but this may change, so please don't rely on it).
+;;
+;; EXTERNAL (with Client TLS Certificate):
+;;
+;;   1. Specify the `:client-certificate' param when opening a new
+;;      connection, which is typically done by calling `emacs-tls'.
+;;      See (info "(erc) Connecting").
+;;
+;;   2. Ensure you've registered your fingerprint with the network and
+;;      (re)connect.  The fingerprint is usually a SHA1 or SHA256
+;;      digest in either "normalized" or "openssl" forms.  The first
+;;      is lowercase without delims ("deadbeef") and the second
+;;      uppercase with colon seps ("DE:AD:BE:EF").
+;;
+;;   There's no reason to send your password after registering.  Note
+;;   that most ircds will allow you to authenticate with a client cert
+;;   but without the hassle of SASL (meaning you may not need this
+;;   module).
+;;
+;; ECDSA-NIST256P-CHALLENGE:
+;;
+;;   Use something else if at all possible.  This currently requires
+;;   the openssl command-line utility.  On servers running Atheme
+;;   services, add your public key with NickServ like so:
+;;
+;;   /msg NickServ set property
+;;     pubkey AgGZmlYTUjJlea/BVz7yrjJ6gysiAPaQxzeUzTH4hd5j
+;;
+;;   (You may not need the "property" subcommand.)
+;;
+;;
+;; TODO
+;;
+;; - Implement pseudo PASSWORD mechanism that chooses the strongest
+;;   available mechanism for you.
+;;
+;; - Maybe provide explicit authz.  Currently, there's only an obscure
+;;   customizable function option for SCRAM and nothing for plain.
+
+;;; Code:
+(require 'erc-backend)
+(require 'rx)
+(require 'sasl)
+(require 'sasl-scram-rfc)
+(require 'sasl-scram-sha256 nil t)
+
+(defgroup erc-sasl nil
+  "SASL for ERC."
+  :group 'erc
+  :package-version '(ERC . "5.4")) ; FIXME increment on next release
+
+(defcustom erc-sasl-mechanism nil
+  "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 nil)
+                 (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, it's used unconditionally.  As a
+special case, when the value is a non-nil symbol, it's used as
+the value of the `:host' field in an auth-source query, provided
+`erc-sasl-auth-source-function' is set to a function.  When
+nil, a non-nil \"session password\" will be tried, likely one
+given as the `:password' argument to `erc-tls'.  As a last
+resort, the user will be prompted for input."
+  :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 (const erc-auth-source-search)
+                 (const nil)
+                 function))
+
+(defcustom erc-sasl-ecdsa-private-key nil
+  "Private signing key file for ECDSA-NIST256P-CHALLENGE."
+  :type '(choice (const nil) string))
+
+(defcustom erc-sasl-authzid nil
+  "SASL authorization identity.
+Generally unneeded for normal use.  Some test frameworks and
+aberrant servers may want this to match `erc-sasl-user'."
+  :type '(choice (const nil) string))
+
+
+;; Analogous to what erc-backend does to persist opening params.
+(defvar-local erc-sasl--options nil)
+
+;; 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."
+  ;; Copying prevent `sasl-plain-response' from clobbering
+  (if-let
+      ((found
+        (or (and-let* ((pass (alist-get 'password erc-sasl--options))
+                       ((stringp pass))
+                       (pass)))
+            (and erc-sasl-auth-source-function
+                 (let ((user (alist-get 'user erc-sasl--options))
+                       (host (alist-get 'password erc-sasl--options)))
+                   (apply erc-sasl-auth-source-function
+                          `(,@(and user (list :user user))
+                            ,@(and host (list :host (symbol-name host)))))))
+            erc-session-password)))
+      (copy-sequence found)
+    (read-passwd prompt)))
+
+(defun erc-sasl--plain-response (client steps)
+  "Call `sasl-plain-response' with CLIENT and 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)
+  "Call `sasl-scram--client-final-message' with args.
+Pass HASH-FUN, BLOCK-LENGTH, HASH-LENGTH, CLIENT, and STEP
+directly upstream."
+  ;; 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)
+  "Prepare CLIENT's final message with 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)
+  "Prepare CLIENT's final message with 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)
+  "Pass OBJECT, START, END, and BINARY to `secure-hash'."
+  (secure-hash 'sha512 object start end binary))
+
+(defun erc-sasl--scram-sha-512-client-final-message (client step)
+  "Prepare CLIENT's final message with 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)
+  "Call `sasl-scram--authenticate-server' with CLIENT and 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 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"
+                           (alist-get 'ecdsa-private-key erc-sasl--options)
+                           "-sign")
+      (buffer-string))))
+
+;; This API may seem roundabout, but the "template method" here is
+;; one that we provide, namely `erc-sasl--authenticate-handler'.
+
+(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 (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))
+
+;; Oragono doesn't like when authzid (if present) does not match
+;; the authcid.  TODO see if this still true.
+
+(cl-defmethod erc-sasl--create-client ((_m (eql plain)))
+  "Create and return new SASL PLAIN client object.
+See message breakdown at
+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 a 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 a 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 a ECDSA-NIST256P-CHALLENGE client."
+  (unless (executable-find "openssl")
+    (user-error "Could not find openssl command-line utility"))
+  (unless (and (alist-get 'ecdsa-private-key erc-sasl--options)
+               (file-exists-p (alist-get 'ecdsa-private-key
+                                         erc-sasl--options)))
+    (user-error "Could not find `erc-sasl-ecdsa-private-key'"))
+  (cl-call-next-method))
+
+(defun erc-sasl--init ()
+  (setq erc-sasl--state (make-erc-sasl--state)
+        erc-sasl--options `((user . ,erc-sasl-user)
+                            (password . ,erc-sasl-password)
+                            (mechanism . ,erc-sasl-mechanism)
+                            (ecdsa-private-key . ,erc-sasl-ecdsa-private-key)
+                            (authzid . ,erc-sasl-authzid))))
+
+(defun erc-sasl--mechanism-offered-p (offered)
+  "Non-nil when mechanism OFFERED by server."
+  (string-match-p (rx-to-string
+                   `(: (| bot ",")
+                       ,(symbol-name
+                         (alist-get 'mechanism erc-sasl--options))
+                       (| eot ",")))
+                  (downcase offered)))
+
+(defun erc-sasl--add-hook ()
+  (add-hook 'erc-server-AUTHENTICATE-functions
+            #'erc-sasl--authenticate-handler 0 t))
+
+(defun erc-sasl--remove-hook ()
+  (remove-hook 'erc-server-AUTHENTICATE-functions
+               #'erc-sasl--authenticate-handler t))
+
+(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 (dumb) SASL support for ERC.
+Needless to say, 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
+     (erc-sasl--add-hook)
+     (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 mechanism: %s" mech))
+         (erc-error "Unknown mechanism: %s" mech))
+       (setf (erc-sasl--state-client erc-sasl--state) client))))
+  ((erc-sasl--remove-hook)
+   (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)
+
+;; FIXME do something decisive here
+(define-erc-response-handler (902)
+  "Handle a ERR_NICKLOCKED response." nil
+  (let ((nick (car (erc-response.command-args parsed)))
+        (msg (erc-response.contents parsed)))
+    (erc-display-message parsed '(notice error) 'active 's902 ?n nick ?s msg)))
+
+(define-erc-response-handler (903)
+  "Handle a RPL_SASLSUCCESS response." nil
+  (when erc-sasl-mode
+    (unless erc-server-connected
+      (erc-server-send "CAP END")))
+  (erc-handle-unknown-server-response proc parsed))
+
+(define-erc-response-handler (904 905 906 907 908)
+  "Handle various SASL-related error responses." nil
+  (let* ((msg (intern (format "s%s" (erc-response.command parsed))))
+         (args `(parsed (notice error) active ,msg
+                        ,@(when (string= "908" (erc-response.command parsed))
+                            (list '?m
+                                  (alist-get 'mechanism erc-sasl--options)))
+                        ?s ,(erc-response.contents parsed))))
+    (apply #'erc-display-message args))
+  (when (member (erc-response.command parsed) '("904" "905" "906"))
+    (run-hook-with-args 'erc-quit-hook proc)
+    (delete-process proc)
+    (erc-error "Disconnected from %s; please review SASL settings" proc)))
+
+(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))))
+
+(provide 'erc-sasl)
+;;; erc-sasl.el ends here
+;;
+;; Local Variables:
+;; generated-autoload-file: "erc-loaddefs.el"
+;; End:
diff --git a/test/lisp/erc/erc-sasl-tests.el b/test/lisp/erc/erc-sasl-tests.el
new file mode 100644
index 0000000000..beac287a6e
--- /dev/null
+++ b/test/lisp/erc/erc-sasl-tests.el
@@ -0,0 +1,299 @@
+;;; erc-sasl-tests.el --- Tests for erc-sasl.  -*- lexical-binding:t -*-
+
+;; Copyright (C) 2020-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 GNU/chat port 6697 user bob password spam"
+                   "machine FSF.chat port 6697 user bob password sesame"
+                   "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-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")
+            (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 ()
+  (unless (executable-find "openssl")
+    (ert-skip "System lacks openssl"))
+  (let* ((erc-server-current-nick "jilles")
+         (keyfile (make-temp-file "ecdsa_key.pem" nil nil
+                                  erc-sasl-tests-ecdsa-key-file))
+         (erc-sasl--options `((ecdsa-private-key . ,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))
+        ;; FIXME this is dumb
+        (should (<= 68 (length (sasl-step-data step)) 72))))
+    (should-not (sasl-next-step client step))
+    (delete-file keyfile)))
+
+;;; 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..3ff7cc805d
--- /dev/null
+++ b/test/lisp/erc/erc-scenarios-sasl.el
@@ -0,0 +1,161 @@
+;;; 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")
+       (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"))))))
+
+(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)
+       (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)
+       (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)
+       (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/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..9c6ce3feeb
--- /dev/null
+++ b/test/lisp/erc/resources/sasl/plain.eld
@@ -0,0 +1,35 @@
+;; -*- 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."))
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.37.2


^ permalink raw reply related	[flat|nested] 54+ messages in thread

* bug#29108: 25.3; ERC SASL support
       [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>
  0 siblings, 2 replies; 54+ messages in thread
From: J.P. @ 2022-09-21 13:13 UTC (permalink / raw)
  To: 29108; +Cc: emacs-erc

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

v3. Updated Info manual. Revised some sloppy error handling.

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

From 1bf236e6f3ffd2097bc4c9cc54ad6a049aa8c1c4 Mon Sep 17 00:00:00 2001
From: "F. Jason Park" <jp@neverwas.me>
Date: Wed, 21 Sep 2022 00:25:49 -0700
Subject: [PATCH 0/4] *** NOT A PATCH ***

*** BLURB HERE ***

F. Jason Park (4):
  Add GS2 authorization to sasl-scram-rfc
  Support local ERC modules in erc-mode buffers
  Make erc-login generic
  Add non-IRCv3 SASL module to ERC

 doc/misc/erc.texi                             | 143 +++++-
 lisp/erc/erc-backend.el                       |   8 +-
 lisp/erc/erc-compat.el                        | 104 +++++
 lisp/erc/erc-sasl.el                          | 418 ++++++++++++++++++
 lisp/erc/erc.el                               | 108 +++--
 lisp/net/sasl-scram-rfc.el                    |  21 +-
 test/lisp/erc/erc-sasl-tests.el               | 300 +++++++++++++
 test/lisp/erc/erc-scenarios-sasl.el           | 161 +++++++
 test/lisp/erc/erc-tests.el                    |  47 ++
 test/lisp/erc/resources/sasl/external.eld     |  33 ++
 test/lisp/erc/resources/sasl/plain-failed.eld |  16 +
 test/lisp/erc/resources/sasl/plain.eld        |  35 ++
 test/lisp/erc/resources/sasl/scram-sha-1.eld  |  47 ++
 .../lisp/erc/resources/sasl/scram-sha-256.eld |  47 ++
 14 files changed, 1442 insertions(+), 46 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 3db83197f9..3b7af0fb1b 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.
 
@@ -478,6 +479,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
@@ -525,6 +530,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
@@ -842,6 +848,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
@@ -854,7 +861,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
@@ -915,6 +923,139 @@ 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.  This means invoking
+@code{erc-sasl-mode} manually or calling @code{erc-update-modules}
+won't do any good.  Instead, simply add @code{sasl} to
+@code{erc-modules} (or @code{let}-bind it while calling
+@code{erc-tls}), and SASL will be enabled for the current connection.
+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}.
+
+When @code{erc-sasl-password} is a string, it's used unconditionally.
+When it's a non-@code{nil} symbol, like @samp{Libera.Chat}, it's used
+as the @code{:host} param in an auth-source query.  When it's
+@code{nil} and a session ID is on file, the ID is used instead for the
+@code{:host} param (@pxref{Network Identifier}).  The value of
+@code{erc-sasl-user} is always specified for the @code{:user}
+(@code{:login}) param.
+
+If a password can't be determined, a non-@code{nil} server
+(connection) password will be tried.  (This may change, however, so
+please don't rely on it.)
+@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
+presence of the external @samp{openssl} command-line utility, 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
+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
+Optional account password to send when authenticating.
+
+If you specify a string, it'll be considered authoritative and
+accepted at face value.  If you instead give a non-@code{nil} symbol,
+it'll be passed as the value of the @code{:host} field in an
+auth-source query, provided @code{erc-sasl-auth-source-function} is
+set to a function.  If you set this to @code{nil}, a non-@code{nil}
+``session password'' will be tried, likely whatever you gave as the
+@var{password} argument to @code{erc-tls}.  As a last resort, you'll
+be prompted for input.
+
+Note that when @code{erc-sasl-mechanism} is set to
+@code{ecdsa-nist256p-challenge}, this option should hold the file name
+of your key, which is typically in PEM format.
+@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/lisp/erc/erc-sasl.el b/lisp/erc/erc-sasl.el
index bd27934125..d237ab73a8 100644
--- a/lisp/erc/erc-sasl.el
+++ b/lisp/erc/erc-sasl.el
@@ -19,9 +19,9 @@
 
 ;;; Commentary:
 
-;; WARNING: this is a naive/hack (non-IRCv3) implementation of SASL.
-;; Please see bug#49860, which adds full 3.2 capability negotiation.
-
+;; WARNING: this is a (non-IRCv3) implementation of SASL.  Please see
+;; bug#49860, which adds full 3.2 capability negotiation.
+;;
 ;; Various ERC implementations of the PLAIN mechanism have surfaced
 ;; over the years, the first possibly being:
 ;;
@@ -30,77 +30,14 @@
 ;; This module would not exist without this and other pioneering
 ;; efforts.
 ;;
-;; FIXME move the following to doc/misc/erc.texi
-;;
-;; Regardless of the mechanism or server, you'll likely have to be
-;; registered before first use.  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 client -> bouncer connection.
-;;
-;; Note that `sasl' is a "local" ERC module (effectively introduced in
-;; ERC 5.5).  This means invoking `erc-sasl-mode' manually or calling
-;; `erc-update-modules' won't do any good.  Instead, simply add `sasl'
-;; to `erc-modules' or `let'-bind it while calling `erc-tls', and SASL
-;; will be enabled for the current connection.  But before that,
-;; please explore all custom options that pertain to your chosen
-;; mechanism.
-;;
-;; Password-based mechanisms:
-;;
-;;   Here, "password" refers to your account password, which is
-;;   usually your NickServ password.  This often differs from any
-;;   connection (server) password given to `erc-tls' via its :password
-;;   arg.  To make this work, customize both `erc-sasl-user' and
-;;   `erc-sasl-password' or bind them when invoking `erc-tls'.
-;;
-;;   When `erc-sasl-password' is a string, it's used unconditionally.
-;;   When it's a non-nil symbol, like Libera.Chat, it's used as the
-;;   host param in an auth-source query.  When it's nil and a session
-;;   ID is on file (see `erc-tls'), the ID is instead used for the
-;;   host param.  The value of `erc-sasl-user' is always specified for
-;;   the user (login) param.  See the info node "(erc) Connecting" for
-;;   specifics.
-;;
-;;   If no password can be determined, a non-nil connection password
-;;   will be tried (but this may change, so please don't rely on it).
-;;
-;; EXTERNAL (with Client TLS Certificate):
-;;
-;;   1. Specify the `:client-certificate' param when opening a new
-;;      connection, which is typically done by calling `emacs-tls'.
-;;      See (info "(erc) Connecting").
-;;
-;;   2. Ensure you've registered your fingerprint with the network and
-;;      (re)connect.  The fingerprint is usually a SHA1 or SHA256
-;;      digest in either "normalized" or "openssl" forms.  The first
-;;      is lowercase without delims ("deadbeef") and the second
-;;      uppercase with colon seps ("DE:AD:BE:EF").
-;;
-;;   There's no reason to send your password after registering.  Note
-;;   that most ircds will allow you to authenticate with a client cert
-;;   but without the hassle of SASL (meaning you may not need this
-;;   module).
-;;
-;; ECDSA-NIST256P-CHALLENGE:
+;; TODO:
 ;;
-;;   Use something else if at all possible.  This currently requires
-;;   the openssl command-line utility.  On servers running Atheme
-;;   services, add your public key with NickServ like so:
-;;
-;;   /msg NickServ set property
-;;     pubkey AgGZmlYTUjJlea/BVz7yrjJ6gysiAPaQxzeUzTH4hd5j
-;;
-;;   (You may not need the "property" subcommand.)
-;;
-;;
-;; TODO
+;; - Find a way to obfuscate the password in memory (via something
+;; - like `auth-source--obfuscate'); it's currently visible in
+;; - backtraces.
 ;;
 ;; - Implement pseudo PASSWORD mechanism that chooses the strongest
 ;;   available mechanism for you.
-;;
-;; - Maybe provide explicit authz.  Currently, there's only an obscure
-;;   customizable function option for SCRAM and nothing for plain.
 
 ;;; Code:
 (require 'erc-backend)
@@ -141,7 +78,11 @@ erc-sasl-password
 `erc-sasl-auth-source-function' is set to a function.  When
 nil, a non-nil \"session password\" will be tried, likely one
 given as the `:password' argument to `erc-tls'.  As a last
-resort, the user will be prompted for input."
+resort, the user will be prompted for input.
+
+Note that when `erc-sasl-mechanism' is set to
+`ecdsa-nist256p-challenge', this option should hold the file name
+of the key, which is typically in PEM format."
   :type '(choice (const nil) string symbol))
 
 (defcustom erc-sasl-auth-source-function nil
@@ -158,10 +99,6 @@ erc-sasl-auth-source-function
                  (const nil)
                  function))
 
-(defcustom erc-sasl-ecdsa-private-key nil
-  "Private signing key file for ECDSA-NIST256P-CHALLENGE."
-  :type '(choice (const nil) string))
-
 (defcustom erc-sasl-authzid nil
   "SASL authorization identity.
 Generally unneeded for normal use.  Some test frameworks and
@@ -246,7 +183,7 @@ erc-sasl--ecdsa-first
   (sasl-client-name client))
 
 ;; FIXME do this with gnutls somehow
-(defun erc-sasl--ecdsa-sign (_client step)
+(defun erc-sasl--ecdsa-sign (client step)
   "Return signed challenge for CLIENT and STEP."
   (let ((challenge (sasl-step-data step)))
     (with-temp-buffer
@@ -254,7 +191,7 @@ erc-sasl--ecdsa-sign
       (insert challenge)
       (call-process-region (point-min) (point-max)
                            "openssl" 'delete t nil "pkeyutl" "-inkey"
-                           (alist-get 'ecdsa-private-key erc-sasl--options)
+                           (sasl-client-property client 'ecdsa-keyfile)
                            "-sign")
       (buffer-string))))
 
@@ -342,18 +279,18 @@ erc-sasl--create-client
   "Create a ECDSA-NIST256P-CHALLENGE client."
   (unless (executable-find "openssl")
     (user-error "Could not find openssl command-line utility"))
-  (unless (and (alist-get 'ecdsa-private-key erc-sasl--options)
-               (file-exists-p (alist-get 'ecdsa-private-key
-                                         erc-sasl--options)))
-    (user-error "Could not find `erc-sasl-ecdsa-private-key'"))
-  (cl-call-next-method))
+  (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)))
 
 (defun erc-sasl--init ()
   (setq erc-sasl--state (make-erc-sasl--state)
         erc-sasl--options `((user . ,erc-sasl-user)
                             (password . ,erc-sasl-password)
                             (mechanism . ,erc-sasl-mechanism)
-                            (ecdsa-private-key . ,erc-sasl-ecdsa-private-key)
                             (authzid . ,erc-sasl-authzid))))
 
 (defun erc-sasl--mechanism-offered-p (offered)
@@ -365,14 +302,6 @@ erc-sasl--mechanism-offered-p
                        (| eot ",")))
                   (downcase offered)))
 
-(defun erc-sasl--add-hook ()
-  (add-hook 'erc-server-AUTHENTICATE-functions
-            #'erc-sasl--authenticate-handler 0 t))
-
-(defun erc-sasl--remove-hook ()
-  (remove-hook 'erc-server-AUTHENTICATE-functions
-               #'erc-sasl--authenticate-handler t))
-
 (defun erc-sasl--authenticate-handler (_proc parsed)
   "Handle PARSED `erc-response' from server.
 Maybe transition to next state."
@@ -417,7 +346,8 @@ sasl
 supported mechanisms.  See bug#49860 for a full, CAP 3.2-aware
 implementation, currently a WIP as of ERC 5.5."
   ((unless erc--target
-     (erc-sasl--add-hook)
+     (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)))
@@ -425,7 +355,8 @@ sasl
          (erc-display-error-notice nil (format "Unknown mechanism: %s" mech))
          (erc-error "Unknown mechanism: %s" mech))
        (setf (erc-sasl--state-client erc-sasl--state) client))))
-  ((erc-sasl--remove-hook)
+  ((remove-hook 'erc-server-AUTHENTICATE-functions
+                #'erc-sasl--authenticate-handler t)
    (kill-local-variable 'erc-sasl--options))
   'local)
 
@@ -433,12 +364,17 @@ sasl
 (define-erc-response-handler (AUTHENTICATE)
   "Maybe authenticate to server." nil)
 
-;; FIXME do something decisive here
+(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
-  (let ((nick (car (erc-response.command-args parsed)))
-        (msg (erc-response.contents parsed)))
-    (erc-display-message parsed '(notice error) 'active 's902 ?n nick ?s msg)))
+  (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
@@ -447,19 +383,24 @@ sasl
       (erc-server-send "CAP END")))
   (erc-handle-unknown-server-response proc parsed))
 
-(define-erc-response-handler (904 905 906 907 908)
+(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
-  (let* ((msg (intern (format "s%s" (erc-response.command parsed))))
-         (args `(parsed (notice error) active ,msg
-                        ,@(when (string= "908" (erc-response.command parsed))
-                            (list '?m
-                                  (alist-get 'mechanism erc-sasl--options)))
-                        ?s ,(erc-response.contents parsed))))
-    (apply #'erc-display-message args))
-  (when (member (erc-response.command parsed) '("904" "905" "906"))
-    (run-hook-with-args 'erc-quit-hook proc)
-    (delete-process proc)
-    (erc-error "Disconnected from %s; please review SASL settings" proc)))
+  (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."
diff --git a/test/lisp/erc/erc-sasl-tests.el b/test/lisp/erc/erc-sasl-tests.el
index beac287a6e..c54acc4d28 100644
--- a/test/lisp/erc/erc-sasl-tests.el
+++ b/test/lisp/erc/erc-sasl-tests.el
@@ -276,24 +276,25 @@ erc-sasl-tests-ecdsa-key-file
 (ert-deftest erc-sasl-create-client-ecdsa ()
   (unless (executable-find "openssl")
     (ert-skip "System lacks openssl"))
-  (let* ((erc-server-current-nick "jilles")
-         (keyfile (make-temp-file "ecdsa_key.pem" nil nil
-                                  erc-sasl-tests-ecdsa-key-file))
-         (erc-sasl--options `((ecdsa-private-key . ,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))
-        ;; FIXME this is dumb
-        (should (<= 68 (length (sasl-step-data step)) 72))))
-    (should-not (sasl-next-step client step))
-    (delete-file keyfile)))
+  (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))
+          ;; FIXME this is dumb
+          (should (<= 68 (length (sasl-step-data step)) 72))))
+      (should-not (sasl-next-step client step)))))
 
 ;;; erc-sasl-tests.el ends here
-- 
2.37.2


[-- 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: 2949 bytes --]

From e01d4d3e620e53629c35952bf705c9e08eafda63 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/4] Add GS2 authorization to sasl-scram-rfc

* lisp/net/sasl-scram-rfc.el (sasl-scram-fs2-header-function,
sasl-scram-construct-gs2-header): Add new variable and default
function for determining a SCRAM GSS-API message header.
(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.
---
 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.37.2


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

From 757442444bbe520c0e2124a1363dacde559b4c2d 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 2/4] Support local ERC modules in erc-mode buffers

* lisp/erc/erc.el (erc-migrate-modules): add some missing mappings.
(erc--module-name-migrations, erc--features-to-modules,
erc--modules-to-features): add alists to support simplified
module-name migrations.
(erc-update-modules): Change return value to a list of minor-mode
commands for local modules that need deferred activation, if any.  Use
`custom-variable-p' to detect flavor.  Currently, all modules are
global, meaning 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.
(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.  It's believed that the original authors
wanted this functionality.
---
 lisp/erc/erc.el            | 108 ++++++++++++++++++++++++-------------
 test/lisp/erc/erc-tests.el |  47 ++++++++++++++++
 2 files changed, 119 insertions(+), 36 deletions(-)

diff --git a/lisp/erc/erc.el b/lisp/erc/erc.el
index 20f22c896f..8fa9d0c8a3 100644
--- a/lisp/erc/erc.el
+++ b/lisp/erc/erc.el
@@ -1390,7 +1390,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:
 
@@ -1426,16 +1428,21 @@ 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 (erc--normalize-module-symbol ',name) erc-modules))
+         (when (or ,(not local-p) (eq major-mode 'erc-mode))
+           (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 (erc--normalize-module-symbol ',name)
+                                   erc-modules)))
+         (when (or ,(not local-p) ,mode)
+           (setq ,mode nil)
+           ,@disable-body))
        ,(when (and alias (not (eq name alias)))
           `(defalias
              ',(intern
@@ -2030,14 +2037,40 @@ erc-default-nicks
 (defvar-local erc-nick-change-attempt-count 0
   "Used to keep track of how many times an attempt at changing nick is made.")
 
+(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.")
+
+(defconst erc--modules-to-features
+  (cl-loop for (feature . names) in erc--features-to-modules
+           append (mapcar (lambda (name) (cons name feature)) names))
+  "Migration alist mapping a module's name to 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 (module)
+  "Canonicalize symbol MODULE for `erc-modules'."
+  (or (cdr (assq module erc--module-name-migrations)) module))
+
 (defun erc-migrate-modules (mods)
   "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
@@ -2116,27 +2149,22 @@ erc-modules
   :group 'erc)
 
 (defun erc-update-modules ()
-  "Run this to enable erc-foo-mode for all modules in `erc-modules'."
-  (let (req)
+  "Enable global minor mode for all global modules in `erc-modules'.
+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.6."
+  (let (local-modules)
     (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))
+      (require (or (alist-get mod erc--modules-to-features)
+                   (intern (concat "erc-" (symbol-name mod))))
+               nil 'noerror) ; some modules don't have a corresponding feature
       (let ((sym (intern-soft (concat "erc-" (symbol-name mod) "-mode"))))
-        (if (fboundp sym)
+        (unless (and sym (fboundp sym))
+          (error "`%s' is not a known ERC module" mod))
+        (if (custom-variable-p sym)
             (funcall sym 1)
-          (error "`%s' is not a known ERC module" mod))))))
+          (push sym local-modules))))
+    local-modules))
 
 (defun erc-setup-buffer (buffer)
   "Consults `erc-join-buffer' to find out how to display `BUFFER'."
@@ -2192,18 +2220,22 @@ 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))))
     (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))
+
+    (delay-mode-hooks (erc-mode))
+
+    (setq erc-server-reconnect-count old-recon-count)
+
     (when (setq erc-server-connected (not connect))
       (setq erc-server-announced-name
             (buffer-local-value 'erc-server-announced-name old-buffer)))
@@ -2266,6 +2298,12 @@ erc-open
     (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))
@@ -2277,8 +2315,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 b2ed29e80e..d3d319ab22 100644
--- a/test/lisp/erc/erc-tests.el
+++ b/test/lisp/erc/erc-tests.el
@@ -975,4 +975,51 @@ 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 '(fake-foo fake-bar)))
+    (cl-letf (((symbol-function 'require)
+               (lambda (s &rest _) (push s calls)))
+              ((symbol-function 'erc-fake-foo-mode)
+               (lambda (n) (push (cons 'fake-foo n) calls)))
+              ;; Here, foo is a global module (minor mode)
+              ((get 'erc-fake-foo-mode 'standard-value) #'ignore)
+              ((symbol-function 'erc-fake-bar-mode)
+               (lambda (n) (push (cons 'fake-bar n) calls)))
+              ((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)))
+              ((symbol-function 'erc-completion-mode)
+               (lambda (n) (push (cons 'completion n) calls)))
+              ((get 'erc-completion-mode 'standard-value) #'ignore))
+
+      (ert-info ("Locals")
+        (should (equal (erc-update-modules)
+                       '(erc-fake-bar-mode)))
+        ;; Bar still required
+        (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)) ; no locals
+        (should (equal (nreverse calls)
+                       '(erc-pcomplete
+                         (completion . 1)
+                         erc-join
+                         (autojoin . 1)
+                         erc-networks
+                         (networks . 1))))
+        (setq calls nil)))))
+
 ;;; erc-tests.el ends here
-- 
2.37.2


[-- Warning: decoded text below may be mangled, UTF-8 assumed --]
[-- Attachment #5: 0003-Make-erc-login-generic.patch --]
[-- Type: text/x-patch, Size: 1965 bytes --]

From db17807f146c6d4803efac742d31177279fdc551 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 3/4] Make erc-login generic

* lisp/erc/erc-backend (erc--register-connection): Add new generic
function that's just a wrapper for `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 df9efe4b0c..25c4481d1d 100644
--- a/lisp/erc/erc-backend.el
+++ b/lisp/erc/erc-backend.el
@@ -532,6 +532,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.
@@ -580,7 +584,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.
@@ -758,7 +762,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.37.2


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

From 1bf236e6f3ffd2097bc4c9cc54ad6a049aa8c1c4 Mon Sep 17 00:00:00 2001
From: "F. Jason Park" <jp@neverwas.me>
Date: Sun, 18 Sep 2022 01:37:13 -0700
Subject: [PATCH 4/4] 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-sasl.el: New file.
* 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                             | 143 +++++-
 lisp/erc/erc-compat.el                        | 104 +++++
 lisp/erc/erc-sasl.el                          | 418 ++++++++++++++++++
 test/lisp/erc/erc-sasl-tests.el               | 300 +++++++++++++
 test/lisp/erc/erc-scenarios-sasl.el           | 161 +++++++
 test/lisp/erc/resources/sasl/external.eld     |  33 ++
 test/lisp/erc/resources/sasl/plain-failed.eld |  16 +
 test/lisp/erc/resources/sasl/plain.eld        |  35 ++
 test/lisp/erc/resources/sasl/scram-sha-1.eld  |  47 ++
 .../lisp/erc/resources/sasl/scram-sha-256.eld |  47 ++
 10 files changed, 1303 insertions(+), 1 deletion(-)
 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 3db83197f9..3b7af0fb1b 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.
 
@@ -478,6 +479,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
@@ -525,6 +530,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
@@ -842,6 +848,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
@@ -854,7 +861,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
@@ -915,6 +923,139 @@ 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.  This means invoking
+@code{erc-sasl-mode} manually or calling @code{erc-update-modules}
+won't do any good.  Instead, simply add @code{sasl} to
+@code{erc-modules} (or @code{let}-bind it while calling
+@code{erc-tls}), and SASL will be enabled for the current connection.
+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}.
+
+When @code{erc-sasl-password} is a string, it's used unconditionally.
+When it's a non-@code{nil} symbol, like @samp{Libera.Chat}, it's used
+as the @code{:host} param in an auth-source query.  When it's
+@code{nil} and a session ID is on file, the ID is used instead for the
+@code{:host} param (@pxref{Network Identifier}).  The value of
+@code{erc-sasl-user} is always specified for the @code{:user}
+(@code{:login}) param.
+
+If a password can't be determined, a non-@code{nil} server
+(connection) password will be tried.  (This may change, however, so
+please don't rely on it.)
+@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
+presence of the external @samp{openssl} command-line utility, 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
+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
+Optional account password to send when authenticating.
+
+If you specify a string, it'll be considered authoritative and
+accepted at face value.  If you instead give a non-@code{nil} symbol,
+it'll be passed as the value of the @code{:host} field in an
+auth-source query, provided @code{erc-sasl-auth-source-function} is
+set to a function.  If you set this to @code{nil}, a non-@code{nil}
+``session password'' will be tried, likely whatever you gave as the
+@var{password} argument to @code{erc-tls}.  As a last resort, you'll
+be prompted for input.
+
+Note that when @code{erc-sasl-mechanism} is set to
+@code{ecdsa-nist256p-challenge}, this option should hold the file name
+of your key, which is typically in PEM format.
+@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/lisp/erc/erc-compat.el b/lisp/erc/erc-compat.el
index 8a00e711ac..3123f64b88 100644
--- a/lisp/erc/erc-compat.el
+++ b/lisp/erc/erc-compat.el
@@ -156,6 +156,110 @@ erc-subseq
 		 (setq i (1+ i) start (1+ start)))
 	       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, advising `base64-encode-string' won't work
+;; because the byte compiler precomputes the result when all inputs
+;; are constants, as they are in the unpatched 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))
+
 (provide 'erc-compat)
 
 ;;; erc-compat.el ends here
diff --git a/lisp/erc/erc-sasl.el b/lisp/erc/erc-sasl.el
new file mode 100644
index 0000000000..d237ab73a8
--- /dev/null
+++ b/lisp/erc/erc-sasl.el
@@ -0,0 +1,418 @@
+;;; 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:
+
+;; WARNING: this is a (non-IRCv3) implementation of SASL.  Please see
+;; bug#49860, which adds full 3.2 capability negotiation.
+;;
+;; Various ERC implementations of the PLAIN mechanism have surfaced
+;; over the years, the first possibly being:
+;;
+;; https://lists.gnu.org/archive/html/erc-discuss/2012-02/msg00001.html
+;;
+;; This module would not exist without this and other pioneering
+;; efforts.
+;;
+;; TODO:
+;;
+;; - Find a way to obfuscate the password in memory (via something
+;; - like `auth-source--obfuscate'); it's currently visible in
+;; - backtraces.
+;;
+;; - Implement pseudo PASSWORD mechanism that chooses the strongest
+;;   available mechanism for you.
+
+;;; Code:
+(require 'erc-backend)
+(require 'rx)
+(require 'sasl)
+(require 'sasl-scram-rfc)
+(require 'sasl-scram-sha256 nil t)
+
+(defgroup erc-sasl nil
+  "SASL for ERC."
+  :group 'erc
+  :package-version '(ERC . "5.4")) ; FIXME increment on next release
+
+(defcustom erc-sasl-mechanism nil
+  "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 nil)
+                 (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, it's used unconditionally.  As a
+special case, when the value is a non-nil symbol, it's used as
+the value of the `:host' field in an auth-source query, provided
+`erc-sasl-auth-source-function' is set to a function.  When
+nil, a non-nil \"session password\" will be tried, likely one
+given as the `:password' argument to `erc-tls'.  As a last
+resort, the user will be prompted for input.
+
+Note that when `erc-sasl-mechanism' is set to
+`ecdsa-nist256p-challenge', this option should hold the file name
+of the key, which is typically in PEM format."
+  :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 (const erc-auth-source-search)
+                 (const nil)
+                 function))
+
+(defcustom erc-sasl-authzid nil
+  "SASL authorization identity.
+Generally unneeded for normal use.  Some test frameworks and
+aberrant servers may want this to match `erc-sasl-user'."
+  :type '(choice (const nil) string))
+
+
+;; Analogous to what erc-backend does to persist opening params.
+(defvar-local erc-sasl--options nil)
+
+;; 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."
+  ;; Copying prevent `sasl-plain-response' from clobbering
+  (if-let
+      ((found
+        (or (and-let* ((pass (alist-get 'password erc-sasl--options))
+                       ((stringp pass))
+                       (pass)))
+            (and erc-sasl-auth-source-function
+                 (let ((user (alist-get 'user erc-sasl--options))
+                       (host (alist-get 'password erc-sasl--options)))
+                   (apply erc-sasl-auth-source-function
+                          `(,@(and user (list :user user))
+                            ,@(and host (list :host (symbol-name host)))))))
+            erc-session-password)))
+      (copy-sequence found)
+    (read-passwd prompt)))
+
+(defun erc-sasl--plain-response (client steps)
+  "Call `sasl-plain-response' with CLIENT and 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)
+  "Call `sasl-scram--client-final-message' with args.
+Pass HASH-FUN, BLOCK-LENGTH, HASH-LENGTH, CLIENT, and STEP
+directly upstream."
+  ;; 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)
+  "Prepare CLIENT's final message with 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)
+  "Prepare CLIENT's final message with 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)
+  "Pass OBJECT, START, END, and BINARY to `secure-hash'."
+  (secure-hash 'sha512 object start end binary))
+
+(defun erc-sasl--scram-sha-512-client-final-message (client step)
+  "Prepare CLIENT's final message with 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)
+  "Call `sasl-scram--authenticate-server' with CLIENT and 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 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))))
+
+;; This API may seem roundabout, but the "template method" here is
+;; one that we provide, namely `erc-sasl--authenticate-handler'.
+
+(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 (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))
+
+;; Oragono doesn't like when authzid (if present) does not match
+;; the authcid.  TODO see if this still true.
+
+(cl-defmethod erc-sasl--create-client ((_m (eql plain)))
+  "Create and return new SASL PLAIN client object.
+See message breakdown at
+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 a 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 a 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 a 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)))
+
+(defun erc-sasl--init ()
+  (setq erc-sasl--state (make-erc-sasl--state)
+        erc-sasl--options `((user . ,erc-sasl-user)
+                            (password . ,erc-sasl-password)
+                            (mechanism . ,erc-sasl-mechanism)
+                            (authzid . ,erc-sasl-authzid))))
+
+(defun erc-sasl--mechanism-offered-p (offered)
+  "Non-nil when mechanism OFFERED by server."
+  (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 (dumb) SASL support for ERC.
+Needless to say, 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 mechanism: %s" mech))
+         (erc-error "Unknown mechanism: %s" mech))
+       (setf (erc-sasl--state-client erc-sasl--state) client))))
+  ((remove-hook 'erc-server-AUTHENTICATE-functions
+                #'erc-sasl--authenticate-handler t)
+   (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")))
+  (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."
+  (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))))
+
+(provide 'erc-sasl)
+;;; erc-sasl.el ends here
+;;
+;; Local Variables:
+;; generated-autoload-file: "erc-loaddefs.el"
+;; End:
diff --git a/test/lisp/erc/erc-sasl-tests.el b/test/lisp/erc/erc-sasl-tests.el
new file mode 100644
index 0000000000..c54acc4d28
--- /dev/null
+++ b/test/lisp/erc/erc-sasl-tests.el
@@ -0,0 +1,300 @@
+;;; erc-sasl-tests.el --- Tests for erc-sasl.  -*- lexical-binding:t -*-
+
+;; Copyright (C) 2020-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 GNU/chat port 6697 user bob password spam"
+                   "machine FSF.chat port 6697 user bob password sesame"
+                   "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-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")
+            (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 ()
+  (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))
+          ;; FIXME this is dumb
+          (should (<= 68 (length (sasl-step-data step)) 72))))
+      (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..3ff7cc805d
--- /dev/null
+++ b/test/lisp/erc/erc-scenarios-sasl.el
@@ -0,0 +1,161 @@
+;;; 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")
+       (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"))))))
+
+(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)
+       (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)
+       (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)
+       (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/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..9c6ce3feeb
--- /dev/null
+++ b/test/lisp/erc/resources/sasl/plain.eld
@@ -0,0 +1,35 @@
+;; -*- 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."))
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.37.2


^ permalink raw reply related	[flat|nested] 54+ messages in thread

* bug#29108: 25.3; ERC SASL support
  2022-09-21 13:13     ` J.P.
@ 2022-10-14  3:05       ` J.P.
       [not found]       ` <878rljxfxs.fsf@neverwas.me>
  1 sibling, 0 replies; 54+ messages in thread
From: J.P. @ 2022-10-14  3:05 UTC (permalink / raw)
  To: 29108; +Cc: emacs-erc

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

v4. Revised manual, doc strings. Fixed test. Note: these patches may not
be fully functional because the "actual" (WIP version) is based atop
bug#56340, whereas these have been modified to produce a smaller diff.


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

From 5e8fd5c54b46286565d938d9984c26d44f194bf0 Mon Sep 17 00:00:00 2001
From: "F. Jason Park" <jp@neverwas.me>
Date: Thu, 13 Oct 2022 19:52:09 -0700
Subject: [PATCH 0/4] *** NOT A PATCH ***

*** BLURB HERE ***

F. Jason Park (4):
  Add GS2 authorization to sasl-scram-rfc
  Support local ERC modules in erc-mode buffers
  Make erc-login generic
  Add non-IRCv3 SASL module to ERC

 doc/misc/erc.texi                             | 138 +++++-
 lisp/erc/erc-backend.el                       |   8 +-
 lisp/erc/erc-compat.el                        | 104 +++++
 lisp/erc/erc-sasl.el                          | 396 ++++++++++++++++++
 lisp/erc/erc.el                               |  84 ++--
 lisp/net/sasl-scram-rfc.el                    |  21 +-
 test/lisp/erc/erc-sasl-tests.el               | 302 +++++++++++++
 test/lisp/erc/erc-scenarios-sasl.el           | 161 +++++++
 test/lisp/erc/erc-tests.el                    |  47 +++
 test/lisp/erc/resources/sasl/external.eld     |  33 ++
 test/lisp/erc/resources/sasl/plain-failed.eld |  16 +
 test/lisp/erc/resources/sasl/plain.eld        |  35 ++
 test/lisp/erc/resources/sasl/scram-sha-1.eld  |  47 +++
 .../lisp/erc/resources/sasl/scram-sha-256.eld |  47 +++
 14 files changed, 1400 insertions(+), 39 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 3b7af0fb1b..80b4171cdb 100644
--- a/doc/misc/erc.texi
+++ b/doc/misc/erc.texi
@@ -957,18 +957,6 @@ SASL
 (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}.
-
-When @code{erc-sasl-password} is a string, it's used unconditionally.
-When it's a non-@code{nil} symbol, like @samp{Libera.Chat}, it's used
-as the @code{:host} param in an auth-source query.  When it's
-@code{nil} and a session ID is on file, the ID is used instead for the
-@code{:host} param (@pxref{Network Identifier}).  The value of
-@code{erc-sasl-user} is always specified for the @code{:user}
-(@code{:login}) param.
-
-If a password can't be determined, a non-@code{nil} server
-(connection) password will be tried.  (This may change, however, so
-please don't rely on it.)
 @end indentedblock
 
 @var{external} (via Client TLS Certificate):
@@ -1001,12 +989,11 @@ SASL
 
 @indentedblock
 This mechanism is quite complicated and currently requires the
-presence of the external @samp{openssl} command-line utility, 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:
+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 \
@@ -1019,26 +1006,34 @@ SASL
 @end defopt
 
 @defopt erc-sasl-user
-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.
+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
-Optional account password to send when authenticating.
-
-If you specify a string, it'll be considered authoritative and
-accepted at face value.  If you instead give a non-@code{nil} symbol,
-it'll be passed as the value of the @code{:host} field in an
-auth-source query, provided @code{erc-sasl-auth-source-function} is
-set to a function.  If you set this to @code{nil}, a non-@code{nil}
-``session password'' will be tried, likely whatever you gave as the
-@var{password} argument to @code{erc-tls}.  As a last resort, you'll
-be prompted for input.
-
-Note that when @code{erc-sasl-mechanism} is set to
-@code{ecdsa-nist256p-challenge}, this option should hold the file name
-of your key, which is typically in PEM format.
+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
diff --git a/lisp/erc/erc-sasl.el b/lisp/erc/erc-sasl.el
index d237ab73a8..f36a305247 100644
--- a/lisp/erc/erc-sasl.el
+++ b/lisp/erc/erc-sasl.el
@@ -19,28 +19,24 @@
 
 ;;; Commentary:
 
-;; WARNING: this is a (non-IRCv3) implementation of SASL.  Please see
-;; bug#49860, which adds full 3.2 capability negotiation.
-;;
-;; Various ERC implementations of the PLAIN mechanism have surfaced
-;; over the years, the first possibly being:
+;; This "non-IRCv3" implementation resembles many others that have
+;; surfaced over the years, the first possibly being:
 ;;
 ;; https://lists.gnu.org/archive/html/erc-discuss/2012-02/msg00001.html
 ;;
-;; This module would not exist without this and other pioneering
-;; efforts.
+;; 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.
+;;   like `auth-source--obfuscate'); it's currently visible in
+;;   backtraces.
 ;;
-;; - Implement pseudo PASSWORD mechanism that chooses the strongest
-;;   available mechanism for you.
+;; - Implement a proxy mechanism that chooses the strongest available
+;;   mechanism for you.  Requires CAP 3.2 (see bug#49860).
 
 ;;; Code:
-(require 'erc-backend)
+(require 'erc)
 (require 'rx)
 (require 'sasl)
 (require 'sasl-scram-rfc)
@@ -72,13 +68,13 @@ erc-sasl-user
 
 (defcustom erc-sasl-password nil
   "Optional account password to send when authenticating.
-When the value is a string, it's used unconditionally.  As a
-special case, when the value is a non-nil symbol, it's used as
-the value of the `:host' field in an auth-source query, provided
-`erc-sasl-auth-source-function' is set to a function.  When
-nil, a non-nil \"session password\" will be tried, likely one
-given as the `:password' argument to `erc-tls'.  As a last
-resort, the user will be prompted for input.
+When the value is a string, ERC uses it unconditionally for most
+mechanisms (see below).  As a special case, when the value is a
+non-nil symbol, ERC uses it as the value of the `:host' field in
+an auth-source query, provided `erc-sasl-auth-source-function' is
+set to a function.  When nil, ERC will try a non-nil \"session
+password\", likely one given as the `:password' argument to
+`erc-tls'.  As a last resort, ERC will prompt the user for input.
 
 Note that when `erc-sasl-mechanism' is set to
 `ecdsa-nist256p-challenge', this option should hold the file name
@@ -100,9 +96,7 @@ erc-sasl-auth-source-function
                  function))
 
 (defcustom erc-sasl-authzid nil
-  "SASL authorization identity.
-Generally unneeded for normal use.  Some test frameworks and
-aberrant servers may want this to match `erc-sasl-user'."
+  "SASL authorization identity, likely unneeded for everyday use."
   :type '(choice (const nil) string))
 
 
@@ -121,24 +115,22 @@ erc-sasl--state
 (defun erc-sasl--read-password (prompt)
   "Return configured option or server password.
 PROMPT is passed to `read-passwd' if necessary."
-  ;; Copying prevent `sasl-plain-response' from clobbering
-  (if-let
-      ((found
-        (or (and-let* ((pass (alist-get 'password erc-sasl--options))
-                       ((stringp pass))
-                       (pass)))
-            (and erc-sasl-auth-source-function
-                 (let ((user (alist-get 'user erc-sasl--options))
-                       (host (alist-get 'password erc-sasl--options)))
-                   (apply erc-sasl-auth-source-function
-                          `(,@(and user (list :user user))
-                            ,@(and host (list :host (symbol-name host)))))))
-            erc-session-password)))
-      (copy-sequence found)
-    (read-passwd prompt)))
+  (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)
-  "Call `sasl-plain-response' with CLIENT and STEPS."
   (let ((sasl-read-passphrase #'erc-sasl--read-password))
     (sasl-plain-response client steps)))
 
@@ -146,9 +138,6 @@ erc-sasl--plain-response
                   (hash-fun block-length hash-length client step))
 
 (defun erc-sasl--scram-sha-hack-client-final-message (&rest args)
-  "Call `sasl-scram--client-final-message' with args.
-Pass HASH-FUN, BLOCK-LENGTH, HASH-LENGTH, CLIENT, and STEP
-directly upstream."
   ;; In the future (29+), we'll hopefully be able to call
   ;; `sasl-scram--client-final-message' directly
   (require 'erc-compat)
@@ -156,27 +145,22 @@ erc-sasl--scram-sha-hack-client-final-message
     (apply #'erc-compat--sasl-scram--client-final-message args)))
 
 (defun erc-sasl--scram-sha-1-client-final-message (client step)
-  "Prepare CLIENT's final message with 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)
-  "Prepare CLIENT's final message with STEP."
   (erc-sasl--scram-sha-hack-client-final-message 'sasl-scram-sha256 64 32
-                                                    client step))
+                                                 client step))
 
 (defun erc-sasl--scram-sha512 (object &optional start end binary)
-  "Pass OBJECT, START, END, and BINARY to `secure-hash'."
   (secure-hash 'sha512 object start end binary))
 
 (defun erc-sasl--scram-sha-512-client-final-message (client step)
-  "Prepare CLIENT's final message with STEP."
-  (erc-sasl--scram-sha-hack-client-final-message
-   #'erc-sasl--scram-sha512 128 64 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)
-  "Call `sasl-scram--authenticate-server' with CLIENT and STEP."
-  (sasl-scram--authenticate-server
-   #'erc-sasl--scram-sha512 128 64 client step))
+  (sasl-scram--authenticate-server #'erc-sasl--scram-sha512
+                                   128 64 client step))
 
 (defun erc-sasl--ecdsa-first (client _step)
   "Return CLIENT name."
@@ -184,7 +168,7 @@ erc-sasl--ecdsa-first
 
 ;; FIXME do this with gnutls somehow
 (defun erc-sasl--ecdsa-sign (client step)
-  "Return signed challenge for CLIENT and STEP."
+  "Return signed challenge for CLIENT and current STEP."
   (let ((challenge (sasl-step-data step)))
     (with-temp-buffer
       (set-buffer-multibyte nil)
@@ -195,9 +179,6 @@ erc-sasl--ecdsa-sign
                            "-sign")
       (buffer-string))))
 
-;; This API may seem roundabout, but the "template method" here is
-;; one that we provide, namely `erc-sasl--authenticate-handler'.
-
 (pcase-dolist
     (`(,name . ,steps)
      '(("PLAIN"
@@ -240,13 +221,9 @@ erc-sasl--create-client
                               (alist-get 'authzid erc-sasl--options))
     client))
 
-;; Oragono doesn't like when authzid (if present) does not match
-;; the authcid.  TODO see if this still true.
-
 (cl-defmethod erc-sasl--create-client ((_m (eql plain)))
-  "Create and return new SASL PLAIN client object.
-See message breakdown at
-https://tools.ietf.org/html/rfc4616#section-2."
+  "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))
@@ -264,19 +241,19 @@ erc-sasl--create-client
     client))
 
 (cl-defmethod erc-sasl--create-client ((m (eql scram-sha-256)))
-  "Create a SCRAM-SHA-256 client."
+  "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 a SCRAM-SHA-512 client."
+  "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 a ECDSA-NIST256P-CHALLENGE client."
+  "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))))
@@ -286,6 +263,7 @@ erc-sasl--create-client
       (sasl-client-set-property client 'ecdsa-keyfile keyfile)
       client)))
 
+;; This stands alone because it's also used by bug#49860
 (defun erc-sasl--init ()
   (setq erc-sasl--state (make-erc-sasl--state)
         erc-sasl--options `((user . ,erc-sasl-user)
@@ -294,7 +272,7 @@ erc-sasl--init
                             (authzid . ,erc-sasl-authzid))))
 
 (defun erc-sasl--mechanism-offered-p (offered)
-  "Non-nil when mechanism OFFERED by server."
+  "Return non-nil when OFFERED appears among a list of mechanisms."
   (string-match-p (rx-to-string
                    `(: (| bot ",")
                        ,(symbol-name
@@ -341,10 +319,10 @@ erc-sasl--authenticate-handler
    (s908 . "RPL_SASLMECHS (unsupported mechanism %m) %s")))
 
 (define-erc-module sasl nil
-  "Non-IRCv3 (dumb) SASL support for ERC.
-Needless to say, 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."
+  "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)
diff --git a/lisp/erc/erc.el b/lisp/erc/erc.el
index 1778480df1..7c72085fea 100644
--- a/lisp/erc/erc.el
+++ b/lisp/erc/erc.el
@@ -1390,9 +1390,7 @@ 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.  Beware that for global
-modules, these helpers, as well as the minor-mode toggle, all mutate
-the user option `erc-modules'.
+erc-NAME-enable, and erc-NAME-disable.
 
 Example:
 
@@ -1428,21 +1426,16 @@ define-erc-module
          ,(format "Enable ERC %S mode."
                   name)
          (interactive)
-         (unless ,local-p
-           (cl-pushnew (erc--normalize-module-symbol ',name) erc-modules))
-         (when (or ,(not local-p) (eq major-mode 'erc-mode))
-           (setq ,mode t)
-           ,@enable-body))
+         (add-to-list 'erc-modules (quote ,name))
+         (setq ,mode t)
+         ,@enable-body)
        (defun ,disable ()
          ,(format "Disable ERC %S mode."
                   name)
          (interactive)
-         (unless ,local-p
-           (setq erc-modules (delq (erc--normalize-module-symbol ',name)
-                                   erc-modules)))
-         (when (or ,(not local-p) ,mode)
-           (setq ,mode nil)
-           ,@disable-body))
+         (setq erc-modules (delq (quote ,name) erc-modules))
+         (setq ,mode nil)
+         ,@disable-body)
        ,(when (and alias (not (eq name alias)))
           `(defalias
              ',(intern
@@ -2062,10 +2055,6 @@ erc--module-name-migrations
     pairs)
   "Association list of obsolete module names to canonical names.")
 
-(defun erc--normalize-module-symbol (module)
-  "Canonicalize symbol MODULE for `erc-modules'."
-  (or (cdr (assq module erc--module-name-migrations)) module))
-
 (defun erc-migrate-modules (mods)
   "Migrate old names of ERC modules to new ones."
   ;; modify `transforms' to specify what needs to be changed
@@ -2132,6 +2121,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
index c54acc4d28..112303baf5 100644
--- a/test/lisp/erc/erc-sasl-tests.el
+++ b/test/lisp/erc/erc-sasl-tests.el
@@ -47,16 +47,18 @@ erc-sasl--read-password
       (should (string= (erc-sasl--read-password nil) "bar"))))
 
   (let* ((entries (list
-                   "machine GNU/chat port 6697 user bob password spam"
                    "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-sasl-auth-source-function #'erc--auth-source-search)
          erc-server-announced-name ; too early
          auth-source-do-cache)
 
@@ -69,7 +71,7 @@ erc-sasl--read-password
                   (erc-networks--id (make-erc-networks--id)))
               (should (string= (erc-sasl--read-password nil) "sesame"))))
 
-          (ert-info ("Use session ID when password empty")
+          (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")))))
-- 
2.37.3


[-- 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: 2949 bytes --]

From 98b02fb10e1cfa1b4d02bf1bc244633046dcbcbb 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/4] Add GS2 authorization to sasl-scram-rfc

* lisp/net/sasl-scram-rfc.el (sasl-scram-fs2-header-function,
sasl-scram-construct-gs2-header): Add new variable and default
function for determining a SCRAM GSS-API message header.
(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.
---
 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.37.3


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

From b9dfeb4e8f2c19a6218aaec12aafccc99a964676 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 2/4] Support local ERC modules in erc-mode buffers

* lisp/erc/erc.el (erc-migrate-modules): add some missing mappings.
(erc--module-name-migrations, erc--features-to-modules,
erc--modules-to-features): add alists to support simplified
module-name migrations.
(erc-update-modules): Change return value to a list of minor-mode
commands for local modules that need deferred activation, if any.  Use
`custom-variable-p' to detect flavor.  Currently, all modules are
global, meaning 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.

* lisp/erc/erc-common.el (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.  It's believed
that the original authors wanted this functionality.
(erc--normalize-module-symbol): Add helper for `erc-migrate-modules'.
---
 lisp/erc/erc.el            | 83 +++++++++++++++++++++++++-------------
 test/lisp/erc/erc-tests.el | 47 +++++++++++++++++++++
 2 files changed, 101 insertions(+), 29 deletions(-)

diff --git a/lisp/erc/erc.el b/lisp/erc/erc.el
index db39e341b2..2601ebfc70 100644
--- a/lisp/erc/erc.el
+++ b/lisp/erc/erc.el
@@ -2030,14 +2030,36 @@ erc-default-nicks
 (defvar-local erc-nick-change-attempt-count 0
   "Used to keep track of how many times an attempt at changing nick is made.")
 
+(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.")
+
+(defconst erc--modules-to-features
+  (cl-loop for (feature . names) in erc--features-to-modules
+           append (mapcar (lambda (name) (cons name feature)) names))
+  "Migration alist mapping a module's name to 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-migrate-modules (mods)
   "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
@@ -2116,27 +2138,22 @@ erc-modules
   :group 'erc)
 
 (defun erc-update-modules ()
-  "Run this to enable erc-foo-mode for all modules in `erc-modules'."
-  (let (req)
+  "Enable global minor mode for all global modules in `erc-modules'.
+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.6."
+  (let (local-modules)
     (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))
+      (require (or (alist-get mod erc--modules-to-features)
+                   (intern (concat "erc-" (symbol-name mod))))
+               nil 'noerror) ; some modules don't have a corresponding feature
       (let ((sym (intern-soft (concat "erc-" (symbol-name mod) "-mode"))))
-        (if (fboundp sym)
+        (unless (and sym (fboundp sym))
+          (error "`%s' is not a known ERC module" mod))
+        (if (custom-variable-p sym)
             (funcall sym 1)
-          (error "`%s' is not a known ERC module" mod))))))
+          (push sym local-modules))))
+    local-modules))
 
 (defun erc-setup-buffer (buffer)
   "Consults `erc-join-buffer' to find out how to display `BUFFER'."
@@ -2192,18 +2209,22 @@ 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))))
     (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))
+
+    (delay-mode-hooks (erc-mode))
+
+    (setq erc-server-reconnect-count old-recon-count)
+
     (when (setq erc-server-connected (not connect))
       (setq erc-server-announced-name
             (buffer-local-value 'erc-server-announced-name old-buffer)))
@@ -2266,6 +2287,12 @@ erc-open
     (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))
@@ -2277,8 +2304,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 b2ed29e80e..d3d319ab22 100644
--- a/test/lisp/erc/erc-tests.el
+++ b/test/lisp/erc/erc-tests.el
@@ -975,4 +975,51 @@ 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 '(fake-foo fake-bar)))
+    (cl-letf (((symbol-function 'require)
+               (lambda (s &rest _) (push s calls)))
+              ((symbol-function 'erc-fake-foo-mode)
+               (lambda (n) (push (cons 'fake-foo n) calls)))
+              ;; Here, foo is a global module (minor mode)
+              ((get 'erc-fake-foo-mode 'standard-value) #'ignore)
+              ((symbol-function 'erc-fake-bar-mode)
+               (lambda (n) (push (cons 'fake-bar n) calls)))
+              ((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)))
+              ((symbol-function 'erc-completion-mode)
+               (lambda (n) (push (cons 'completion n) calls)))
+              ((get 'erc-completion-mode 'standard-value) #'ignore))
+
+      (ert-info ("Locals")
+        (should (equal (erc-update-modules)
+                       '(erc-fake-bar-mode)))
+        ;; Bar still required
+        (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)) ; no locals
+        (should (equal (nreverse calls)
+                       '(erc-pcomplete
+                         (completion . 1)
+                         erc-join
+                         (autojoin . 1)
+                         erc-networks
+                         (networks . 1))))
+        (setq calls nil)))))
+
 ;;; erc-tests.el ends here
-- 
2.37.3


[-- Warning: decoded text below may be mangled, UTF-8 assumed --]
[-- Attachment #5: 0003-Make-erc-login-generic.patch --]
[-- Type: text/x-patch, Size: 1965 bytes --]

From 54fca49c044dee3ba0c5b5de27bce9f1eb44f41d 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 3/4] Make erc-login generic

* lisp/erc/erc-backend (erc--register-connection): Add new generic
function that's just a wrapper for `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 df9efe4b0c..25c4481d1d 100644
--- a/lisp/erc/erc-backend.el
+++ b/lisp/erc/erc-backend.el
@@ -532,6 +532,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.
@@ -580,7 +584,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.
@@ -758,7 +762,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.37.3


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

From 5e8fd5c54b46286565d938d9984c26d44f194bf0 Mon Sep 17 00:00:00 2001
From: "F. Jason Park" <jp@neverwas.me>
Date: Sun, 18 Sep 2022 01:37:13 -0700
Subject: [PATCH 4/4] 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.
* 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                             | 138 +++++-
 lisp/erc/erc-compat.el                        | 104 +++++
 lisp/erc/erc-sasl.el                          | 396 ++++++++++++++++++
 lisp/erc/erc.el                               |   1 +
 test/lisp/erc/erc-sasl-tests.el               | 302 +++++++++++++
 test/lisp/erc/erc-scenarios-sasl.el           | 161 +++++++
 test/lisp/erc/resources/sasl/external.eld     |  33 ++
 test/lisp/erc/resources/sasl/plain-failed.eld |  16 +
 test/lisp/erc/resources/sasl/plain.eld        |  35 ++
 test/lisp/erc/resources/sasl/scram-sha-1.eld  |  47 +++
 .../lisp/erc/resources/sasl/scram-sha-256.eld |  47 +++
 11 files changed, 1279 insertions(+), 1 deletion(-)
 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 3db83197f9..80b4171cdb 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.
 
@@ -478,6 +479,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
@@ -525,6 +530,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
@@ -842,6 +848,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
@@ -854,7 +861,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
@@ -915,6 +923,134 @@ 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.  This means invoking
+@code{erc-sasl-mode} manually or calling @code{erc-update-modules}
+won't do any good.  Instead, simply add @code{sasl} to
+@code{erc-modules} (or @code{let}-bind it while calling
+@code{erc-tls}), and SASL will be enabled for the current connection.
+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/lisp/erc/erc-compat.el b/lisp/erc/erc-compat.el
index 8a00e711ac..3123f64b88 100644
--- a/lisp/erc/erc-compat.el
+++ b/lisp/erc/erc-compat.el
@@ -156,6 +156,110 @@ erc-subseq
 		 (setq i (1+ i) start (1+ start)))
 	       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, advising `base64-encode-string' won't work
+;; because the byte compiler precomputes the result when all inputs
+;; are constants, as they are in the unpatched 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))
+
 (provide 'erc-compat)
 
 ;;; erc-compat.el ends here
diff --git a/lisp/erc/erc-sasl.el b/lisp/erc/erc-sasl.el
new file mode 100644
index 0000000000..f36a305247
--- /dev/null
+++ b/lisp/erc/erc-sasl.el
@@ -0,0 +1,396 @@
+;;; 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 many others that have
+;; surfaced over the years, the first possibly being:
+;;
+;; 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.
+;;
+;; - Implement a proxy mechanism that chooses the strongest available
+;;   mechanism for you.  Requires CAP 3.2 (see bug#49860).
+
+;;; Code:
+(require 'erc)
+(require 'rx)
+(require 'sasl)
+(require 'sasl-scram-rfc)
+(require 'sasl-scram-sha256 nil t)
+
+(defgroup erc-sasl nil
+  "SASL for ERC."
+  :group 'erc
+  :package-version '(ERC . "5.4")) ; FIXME increment on next release
+
+(defcustom erc-sasl-mechanism nil
+  "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 nil)
+                 (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 uses it unconditionally for most
+mechanisms (see below).  As a special case, when the value is a
+non-nil symbol, ERC uses it as the value of the `:host' field in
+an auth-source query, provided `erc-sasl-auth-source-function' is
+set to a function.  When nil, ERC will try a non-nil \"session
+password\", likely one given as the `:password' argument to
+`erc-tls'.  As a last resort, ERC will prompt the user for input.
+
+Note that when `erc-sasl-mechanism' is set to
+`ecdsa-nist256p-challenge', this option should hold the file name
+of the key, which is typically in PEM format."
+  :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 (const 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)
+
+;; 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 (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))
+
+(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 ()
+  (setq erc-sasl--state (make-erc-sasl--state)
+        erc-sasl--options `((user . ,erc-sasl-user)
+                            (password . ,erc-sasl-password)
+                            (mechanism . ,erc-sasl-mechanism)
+                            (authzid . ,erc-sasl-authzid))))
+
+(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 mechanism: %s" mech))
+         (erc-error "Unknown mechanism: %s" mech))
+       (setf (erc-sasl--state-client erc-sasl--state) client))))
+  ((remove-hook 'erc-server-AUTHENTICATE-functions
+                #'erc-sasl--authenticate-handler t)
+   (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")))
+  (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."
+  (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))))
+
+(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 2601ebfc70..7c72085fea 100644
--- a/lisp/erc/erc.el
+++ b/lisp/erc/erc.el
@@ -2121,6 +2121,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..112303baf5
--- /dev/null
+++ b/test/lisp/erc/erc-sasl-tests.el
@@ -0,0 +1,302 @@
+;;; erc-sasl-tests.el --- Tests for erc-sasl.  -*- lexical-binding:t -*-
+
+;; Copyright (C) 2020-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 ()
+  (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))
+          ;; FIXME this is dumb
+          (should (<= 68 (length (sasl-step-data step)) 72))))
+      (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..3ff7cc805d
--- /dev/null
+++ b/test/lisp/erc/erc-scenarios-sasl.el
@@ -0,0 +1,161 @@
+;;; 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")
+       (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"))))))
+
+(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)
+       (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)
+       (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)
+       (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/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..9c6ce3feeb
--- /dev/null
+++ b/test/lisp/erc/resources/sasl/plain.eld
@@ -0,0 +1,35 @@
+;; -*- 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."))
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.37.3


^ permalink raw reply related	[flat|nested] 54+ messages in thread

* bug#29108: 25.3; ERC SASL support
       [not found]       ` <878rljxfxs.fsf@neverwas.me>
@ 2022-10-26 13:14         ` J.P.
       [not found]         ` <87k04m4th8.fsf@neverwas.me>
  1 sibling, 0 replies; 54+ messages in thread
From: J.P. @ 2022-10-26 13:14 UTC (permalink / raw)
  To: 29108; +Cc: emacs-erc, bandali

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

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

> Note: these patches may not be fully functional because the "actual"
> (WIP version) is based atop bug#56340, whereas these have been
> modified to produce a smaller diff.

I now realize that was probably just confusing (sorry), so I've attached
the full set with dependencies for clarity. I'd really like this thing
to see some daylight, so if anyone can find the time to take a quick
look, please do (Cc. bandali). I think most folks would agree that an
ERC without SASL in Emacs 29 would be less than ideal. Thanks.


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

From 27242c8becae2962972c2a6cfdf4de44d276184b Mon Sep 17 00:00:00 2001
From: "F. Jason Park" <jp@neverwas.me>
Date: Wed, 26 Oct 2022 00:58:17 -0700
Subject: [PATCH 0/5] *** NOT A PATCH ***

*** BLURB HERE ***

Dick R. Chiang (1):
  Move ERC's core dependencies to separate file

F. Jason Park (4):
  Add GS2 authorization to sasl-scram-rfc
  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                             | 138 +++++-
 lisp/erc/erc-backend.el                       | 137 +++++-
 lisp/erc/erc-common.el                        | 283 +++++++++++
 lisp/erc/erc-compat.el                        | 116 +++++
 lisp/erc/erc-goodies.el                       |  18 +-
 lisp/erc/erc-networks.el                      |  28 +-
 lisp/erc/erc-sasl.el                          | 396 ++++++++++++++++
 lisp/erc/erc.el                               | 447 ++++--------------
 lisp/net/sasl-scram-rfc.el                    |  21 +-
 test/lisp/erc/erc-networks-tests.el           |   2 +-
 test/lisp/erc/erc-sasl-tests.el               | 302 ++++++++++++
 test/lisp/erc/erc-scenarios-sasl.el           | 161 +++++++
 test/lisp/erc/erc-tests.el                    |  69 ++-
 test/lisp/erc/resources/sasl/external.eld     |  33 ++
 test/lisp/erc/resources/sasl/plain-failed.eld |  16 +
 test/lisp/erc/resources/sasl/plain.eld        |  35 ++
 test/lisp/erc/resources/sasl/scram-sha-1.eld  |  47 ++
 .../lisp/erc/resources/sasl/scram-sha-256.eld |  47 ++
 18 files changed, 1889 insertions(+), 407 deletions(-)
 create mode 100644 lisp/erc/erc-common.el
 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/lisp/erc/erc-backend.el b/lisp/erc/erc-backend.el
index 25c4481d1d..fee29e7d05 100644
--- a/lisp/erc/erc-backend.el
+++ b/lisp/erc/erc-backend.el
@@ -99,24 +99,117 @@
 ;;; Code:
 
 (eval-when-compile (require 'cl-lib))
-;; There's a fairly strong mutual dependency between erc.el and erc-backend.el.
-;; Luckily, erc.el does not need erc-backend.el for macroexpansion whereas the
-;; reverse is true:
-(require 'erc)
+(require 'erc-common)
+
+(defvar erc--target)
+(defvar erc-auto-query)
+(defvar erc-channel-list)
+(defvar erc-channel-users)
+(defvar erc-default-nicks)
+(defvar erc-default-recipients)
+(defvar erc-format-nick-function)
+(defvar erc-format-query-as-channel-p)
+(defvar erc-hide-prompt)
+(defvar erc-input-marker)
+(defvar erc-insert-marker)
+(defvar erc-invitation)
+(defvar erc-join-buffer)
+(defvar erc-kill-buffer-on-part)
+(defvar erc-kill-server-buffer-on-quit)
+(defvar erc-log-p)
+(defvar erc-minibuffer-ignored)
+(defvar erc-networks--id)
+(defvar erc-nick)
+(defvar erc-nick-change-attempt-count)
+(defvar erc-prompt-for-channel-key)
+(defvar erc-prompt-hidden)
+(defvar erc-reuse-buffers)
+(defvar erc-verbose-server-ping)
+(defvar erc-whowas-on-nosuchnick)
+
+(declare-function erc--open-target "erc" (target))
+(declare-function erc--target-from-string "erc" (string))
+(declare-function erc-active-buffer "erc" nil)
+(declare-function erc-add-default-channel "erc" (channel))
+(declare-function erc-banlist-update "erc" (proc parsed))
+(declare-function erc-buffer-filter "erc" (predicate &optional proc))
+(declare-function erc-buffer-list-with-nick "erc" (nick proc))
+(declare-function erc-channel-begin-receiving-names "erc" nil)
+(declare-function erc-channel-end-receiving-names "erc" nil)
+(declare-function erc-channel-p "erc" (channel))
+(declare-function erc-channel-receive-names "erc" (names-string))
+(declare-function erc-cmd-JOIN "erc" (channel &optional key))
+(declare-function erc-connection-established "erc" (proc parsed))
+(declare-function erc-current-nick "erc" nil)
+(declare-function erc-current-nick-p "erc" (nick))
+(declare-function erc-current-time "erc" (&optional specified-time))
+(declare-function erc-default-target "erc" nil)
+(declare-function erc-delete-default-channel "erc" (channel &optional buffer))
+(declare-function erc-display-error-notice "erc" (parsed string))
+(declare-function erc-display-server-message "erc" (_proc parsed))
+(declare-function erc-emacs-time-to-erc-time "erc" (&optional specified-time))
+(declare-function erc-format-message "erc" (msg &rest args))
+(declare-function erc-format-privmessage "erc" (nick msg privp msgp))
+(declare-function erc-get-buffer "erc" (target &optional proc))
+(declare-function erc-handle-login "erc" nil)
+(declare-function erc-handle-user-status-change "erc" (type nlh &optional l))
+(declare-function erc-ignored-reply-p "erc" (msg tgt proc))
+(declare-function erc-ignored-user-p "erc" (spec))
+(declare-function erc-is-message-ctcp-and-not-action-p "erc" (message))
+(declare-function erc-is-message-ctcp-p "erc" (message))
+(declare-function erc-log-irc-protocol "erc" (string &optional outbound))
+(declare-function erc-login "erc" nil)
+(declare-function erc-make-notice "erc" (message))
+(declare-function erc-network "erc-networks" nil)
+(declare-function erc-networks--id-given "erc-networks" (arg &rest args))
+(declare-function erc-networks--id-reload "erc-networks" (arg &rest args))
+(declare-function erc-nickname-in-use "erc" (nick reason))
+(declare-function erc-parse-user "erc" (string))
+(declare-function erc-process-away "erc" (proc away-p))
+(declare-function erc-process-ctcp-query "erc" (proc parsed nick login host))
+(declare-function erc-query-buffer-p "erc" (&optional buffer))
+(declare-function erc-remove-channel-member "erc" (channel nick))
+(declare-function erc-remove-channel-users "erc" nil)
+(declare-function erc-remove-user "erc" (nick))
+(declare-function erc-sec-to-time "erc" (ns))
+(declare-function erc-server-buffer "erc" nil)
+(declare-function erc-set-active-buffer "erc" (buffer))
+(declare-function erc-set-current-nick "erc" (nick))
+(declare-function erc-set-modes "erc" (tgt mode-string))
+(declare-function erc-time-diff "erc" (t1 t2))
+(declare-function erc-trim-string "erc" (s))
+(declare-function erc-update-mode-line "erc" (&optional buffer))
+(declare-function erc-update-mode-line-buffer "erc" (buffer))
+(declare-function erc-wash-quit-reason "erc" (reason nick login host))
+
+(declare-function erc-display-message "erc"
+                  (parsed type buffer msg &rest args))
+(declare-function erc-get-buffer-create "erc"
+                  (server port target &optional tgt-info id))
+(declare-function erc-process-ctcp-reply "erc"
+                  (proc parsed nick login host msg))
+(declare-function erc-update-channel-topic "erc"
+                  (channel topic &optional modify))
+(declare-function erc-update-modes "erc"
+                  (tgt mode-string &optional _nick _host _login))
+(declare-function erc-update-user-nick "erc"
+                  (nick &optional new-nick host login full-name info))
+(declare-function erc-open "erc"
+                  (&optional server port nick full-name connect passwd tgt-list
+                             channel process client-certificate user id))
+(declare-function erc-update-channel-member "erc"
+                  (channel nick new-nick
+                           &optional add voice halfop op admin owner host
+                           login full-name info update-message-time))
 
 ;;;; Variables and options
 
+(defvar-local erc-session-password nil
+  "The password used for the current session.")
+
 (defvar erc-server-responses (make-hash-table :test #'equal)
   "Hash table mapping server responses to their handler hooks.")
 
-(cl-defstruct (erc-response (:conc-name erc-response.))
-  (unparsed "" :type string)
-  (sender "" :type string)
-  (command "" :type string)
-  (command-args '() :type list)
-  (contents "" :type string)
-  (tags '() :type list))
-
 ;;; User data
 
 (defvar-local erc-server-current-nick nil
@@ -1666,16 +1759,6 @@ erc--parse-isupport-value
          (split-string value ",")
        (list value)))))
 
-(defmacro erc--with-memoization (table &rest forms)
-  "Adapter to be migrated to erc-compat."
-  (declare (indent defun))
-  `(cond
-    ((fboundp 'with-memoization)
-     (with-memoization ,table ,@forms)) ; 29.1
-    ((fboundp 'cl--generic-with-memoization)
-     (cl--generic-with-memoization ,table ,@forms))
-    (t ,@forms)))
-
 (defun erc--get-isupport-entry (key &optional single)
   "Return an item for \"ISUPPORT\" token KEY, a symbol.
 When a lookup fails return nil.  Otherwise return a list whose
@@ -1685,7 +1768,7 @@ erc--get-isupport-entry
 primitive value."
   (if-let* ((table (or erc--isupport-params
                        (erc-with-server-buffer erc--isupport-params)))
-            (value (erc--with-memoization (gethash key table)
+            (value (erc-compat--with-memoization (gethash key table)
                      (when-let ((v (assoc (symbol-name key)
                                           erc-server-parameters)))
                        (if (cdr v)
diff --git a/lisp/erc/erc-common.el b/lisp/erc/erc-common.el
new file mode 100644
index 0000000000..90ea56108d
--- /dev/null
+++ b/lisp/erc/erc-common.el
@@ -0,0 +1,283 @@
+;;; erc-common.el --- Macros and types for ERC  -*- lexical-binding:t -*-
+
+;; Copyright (C) 2022 Free Software Foundation, Inc.
+;;
+;; Maintainer: Amin Bandali <bandali@gnu.org>, F. Jason Park <jp@neverwas.me>
+;; Keywords: comm, IRC, chat, client, internet
+;;
+;; 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:
+
+(eval-when-compile (require 'cl-lib) (require 'subr-x))
+(require 'erc-compat)
+
+(defvar erc--casemapping-rfc1459)
+(defvar erc--casemapping-rfc1459-strict)
+(defvar erc--module-name-migrations)
+(defvar erc-channel-users)
+(defvar erc-dbuf)
+(defvar erc-log-p)
+(defvar erc-server-users)
+(defvar erc-session-server)
+
+(declare-function erc--get-isupport-entry "erc-backend" (key &optional single))
+(declare-function erc-get-buffer "erc" (target &optional proc))
+(declare-function erc-server-buffer "erc" nil)
+
+(cl-defstruct erc-input
+  string insertp sendp)
+
+(cl-defstruct (erc--input-split (:include erc-input))
+  lines cmdp)
+
+(cl-defstruct (erc-server-user (:type vector) :named)
+  ;; User data
+  nickname host login full-name info
+  ;; Buffers
+  ;;
+  ;; This is an alist of the form (BUFFER . CHANNEL-DATA), where
+  ;; CHANNEL-DATA is either nil or an erc-channel-user struct.
+  (buffers nil))
+
+(cl-defstruct (erc-channel-user (:type vector) :named)
+  voice halfop op admin owner
+  ;; Last message time (in the form of the return value of
+  ;; (current-time)
+  ;;
+  ;; This is useful for ordered name completion.
+  (last-message-time nil))
+
+(cl-defstruct erc--target
+  (string "" :type string :documentation "Received name of target.")
+  (symbol nil :type symbol :documentation "Case-mapped name as symbol."))
+
+;; At some point, it may make sense to add a query type with an
+;; account field, which may help support reassociation across
+;; reconnects and nick changes (likely requires v3 extensions).
+;;
+;; These channel variants should probably take on a `joined' field to
+;; track "joinedness", which `erc-server-JOIN', `erc-server-PART',
+;; etc. should toggle.  Functions like `erc--current-buffer-joined-p'
+;; may find it useful.
+
+(cl-defstruct (erc--target-channel (:include erc--target)))
+(cl-defstruct (erc--target-channel-local (:include erc--target-channel)))
+
+(cl-defstruct (erc-response (:conc-name erc-response.))
+  (unparsed "" :type string)
+  (sender "" :type string)
+  (command "" :type string)
+  (command-args '() :type list)
+  (contents "" :type string)
+  (tags '() :type list))
+
+(defun erc--normalize-module-symbol (module)
+  "Canonicalize symbol MODULE for `erc-modules'."
+  (or (cdr (assq module erc--module-name-migrations)) module))
+
+(defmacro define-erc-module (name alias doc enable-body disable-body
+                                  &optional local-p)
+  "Define a new minor mode using ERC conventions.
+Symbol NAME is the name of the module.
+Symbol ALIAS is the alias to use, or nil.
+DOC is the documentation string to use for the minor mode.
+ENABLE-BODY is a list of expressions used to enable the mode.
+DISABLE-BODY is a list of expressions used to disable the mode.
+If LOCAL-P is non-nil, the mode will be created as a buffer-local
+mode, rather than a global one.
+
+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.  Beware that for global
+modules, these helpers, as well as the minor-mode toggle, all mutate
+the user option `erc-modules'.
+
+Example:
+
+  ;;;###autoload(autoload \\='erc-replace-mode \"erc-replace\")
+  (define-erc-module replace nil
+    \"This mode replaces incoming text according to `erc-replace-alist'.\"
+    ((add-hook \\='erc-insert-modify-hook
+               #\\='erc-replace-insert))
+    ((remove-hook \\='erc-insert-modify-hook
+                  #\\='erc-replace-insert)))"
+  (declare (doc-string 3) (indent defun))
+  (let* ((sn (symbol-name name))
+         (mode (intern (format "erc-%s-mode" (downcase sn))))
+         (group (intern (format "erc-%s" (downcase sn))))
+         (enable (intern (format "erc-%s-enable" (downcase sn))))
+         (disable (intern (format "erc-%s-disable" (downcase sn)))))
+    `(progn
+       (define-minor-mode
+         ,mode
+         ,(format "Toggle ERC %S mode.
+With a prefix argument ARG, enable %s if ARG is positive,
+and disable it otherwise.  If called from Lisp, enable the mode
+if ARG is omitted or nil.
+%s" name name doc)
+         ;; FIXME: We don't know if this group exists, so this `:group' may
+         ;; actually just silence a valid warning about the fact that the var
+         ;; is not associated with any group.
+         :global ,(not local-p) :group (quote ,group)
+         (if ,mode
+             (,enable)
+           (,disable)))
+       (defun ,enable ()
+         ,(format "Enable ERC %S mode."
+                  name)
+         (interactive)
+         (unless ,local-p
+           (cl-pushnew (erc--normalize-module-symbol ',name) erc-modules))
+         (when (or ,(not local-p) (eq major-mode 'erc-mode))
+           (setq ,mode t)
+           ,@enable-body))
+       (defun ,disable ()
+         ,(format "Disable ERC %S mode."
+                  name)
+         (interactive)
+         (unless ,local-p
+           (setq erc-modules (delq (erc--normalize-module-symbol ',name)
+                                   erc-modules)))
+         (when (or ,(not local-p) ,mode)
+           (setq ,mode nil)
+           ,@disable-body))
+       ,(when (and alias (not (eq name alias)))
+          `(defalias
+             ',(intern
+                (format "erc-%s-mode"
+                        (downcase (symbol-name alias))))
+             #',mode))
+       ;; For find-function and find-variable.
+       (put ',mode    'definition-name ',name)
+       (put ',enable  'definition-name ',name)
+       (put ',disable 'definition-name ',name))))
+
+(defmacro erc-with-buffer (spec &rest body)
+  "Execute BODY in the buffer associated with SPEC.
+
+SPEC should have the form
+
+ (TARGET [PROCESS])
+
+If TARGET is a buffer, use it.  Otherwise, use the buffer
+matching TARGET in the process specified by PROCESS.
+
+If PROCESS is nil, use the current `erc-server-process'.
+See `erc-get-buffer' for details.
+
+See also `with-current-buffer'.
+
+\(fn (TARGET [PROCESS]) BODY...)"
+  (declare (indent 1) (debug ((form &optional form) body)))
+  (let ((buf (make-symbol "buf"))
+        (proc (make-symbol "proc"))
+        (target (make-symbol "target"))
+        (process (make-symbol "process")))
+    `(let* ((,target ,(car spec))
+            (,process ,(cadr spec))
+            (,buf (if (bufferp ,target)
+                      ,target
+                    (let ((,proc (or ,process
+                                     (and (processp erc-server-process)
+                                          erc-server-process))))
+                      (if (and ,target ,proc)
+                          (erc-get-buffer ,target ,proc))))))
+       (when (buffer-live-p ,buf)
+         (with-current-buffer ,buf
+           ,@body)))))
+
+(defmacro erc-with-server-buffer (&rest body)
+  "Execute BODY in the current ERC server buffer.
+If no server buffer exists, return nil."
+  (declare (indent 0) (debug (body)))
+  (let ((buffer (make-symbol "buffer")))
+    `(let ((,buffer (erc-server-buffer)))
+       (when (buffer-live-p ,buffer)
+         (with-current-buffer ,buffer
+           ,@body)))))
+
+(defmacro erc-with-all-buffers-of-server (process pred &rest forms)
+  "Execute FORMS in all buffers which have same process as this server.
+FORMS will be evaluated in all buffers having the process PROCESS and
+where PRED matches or in all buffers of the server process if PRED is
+nil."
+  (declare (indent 2) (debug (form form body)))
+  (macroexp-let2 nil pred pred
+    `(erc-buffer-filter (lambda ()
+                          (when (or (not ,pred) (funcall ,pred))
+                            ,@forms))
+                        ,process)))
+
+(defun erc-log-aux (string)
+  "Do the debug logging of STRING."
+  (let ((cb (current-buffer))
+        (point 1)
+        (was-eob nil)
+        (session-buffer (erc-server-buffer)))
+    (if session-buffer
+        (progn
+          (set-buffer session-buffer)
+          (if (not (and erc-dbuf (bufferp erc-dbuf) (buffer-live-p erc-dbuf)))
+              (progn
+                (setq erc-dbuf (get-buffer-create
+                                (concat "*ERC-DEBUG: "
+                                        erc-session-server "*")))))
+          (set-buffer erc-dbuf)
+          (setq point (point))
+          (setq was-eob (eobp))
+          (goto-char (point-max))
+          (insert (concat "** " string "\n"))
+          (if was-eob (goto-char (point-max))
+            (goto-char point))
+          (set-buffer cb))
+      (message "ERC: ** %s" string))))
+
+(define-inline erc-log (string)
+  "Logs STRING if logging is on (see `erc-log-p')."
+  (inline-quote
+   (when erc-log-p
+     (erc-log-aux ,string))))
+
+(defun erc-downcase (string)
+  "Return a downcased copy of STRING with properties.
+Use the CASEMAPPING ISUPPORT parameter to determine the style."
+  (let* ((mapping (erc--get-isupport-entry 'CASEMAPPING 'single))
+         (inhibit-read-only t))
+    (if (equal mapping "ascii")
+        (downcase string)
+      (with-temp-buffer
+        (insert string)
+        (translate-region (point-min) (point-max)
+                          (if (equal mapping "rfc1459-strict")
+                              erc--casemapping-rfc1459-strict
+                            erc--casemapping-rfc1459))
+        (buffer-string)))))
+
+(define-inline erc-get-channel-user (nick)
+  "Find NICK in the current buffer's `erc-channel-users' hash table."
+  (inline-quote (gethash (erc-downcase ,nick) erc-channel-users)))
+
+(define-inline erc-get-server-user (nick)
+  "Find NICK in the current server's `erc-server-users' hash table."
+  (inline-letevals (nick)
+    (inline-quote (erc-with-server-buffer
+                    (gethash (erc-downcase ,nick) erc-server-users)))))
+
+(provide 'erc-common)
+
+;;; erc-common.el ends here
diff --git a/lisp/erc/erc-compat.el b/lisp/erc/erc-compat.el
index 3123f64b88..bc3e1dcfc6 100644
--- a/lisp/erc/erc-compat.el
+++ b/lisp/erc/erc-compat.el
@@ -260,6 +260,18 @@ erc-compat--sasl-scram--client-final-message
     (sasl-client-set-property client 'salted-password salted-password)
     client-final-message))
 
+
+;;;; Misc 29.1
+
+(defmacro erc-compat--with-memoization (table &rest forms)
+  (declare (indent defun))
+  (cond
+   ((fboundp 'with-memoization)
+    `(with-memoization ,table ,@forms)) ; 29.1
+   ((fboundp 'cl--generic-with-memoization)
+    `(cl--generic-with-memoization ,table ,@forms))
+   (t `(progn ,@forms))))
+
 (provide 'erc-compat)
 
 ;;; erc-compat.el ends here
diff --git a/lisp/erc/erc-goodies.el b/lisp/erc/erc-goodies.el
index 8fef23945d..1af83b58ba 100644
--- a/lisp/erc/erc-goodies.el
+++ b/lisp/erc/erc-goodies.el
@@ -29,10 +29,24 @@
 
 ;;; Code:
 
-(require 'erc)
-
 ;;; Imenu support
 
+(eval-when-compile (require 'cl-lib))
+(require 'erc-common)
+
+(defvar erc-controls-highlight-regexp)
+(defvar erc-controls-remove-regexp)
+(defvar erc-input-marker)
+(defvar erc-insert-marker)
+(defvar erc-server-process)
+(defvar erc-modules)
+(defvar erc-log-p)
+
+(declare-function erc-buffer-list "erc" (&optional predicate proc))
+(declare-function erc-error "erc" (&rest args))
+(declare-function erc-extract-command-from-line "erc" (line))
+(declare-function erc-beg-of-input-line "erc" nil)
+
 (defun erc-imenu-setup ()
   "Setup Imenu support in an ERC buffer."
   (setq-local imenu-create-index-function #'erc-create-imenu-index))
diff --git a/lisp/erc/erc-networks.el b/lisp/erc/erc-networks.el
index 2c8f8fb72b..667b0c3d76 100644
--- a/lisp/erc/erc-networks.el
+++ b/lisp/erc/erc-networks.el
@@ -39,8 +39,32 @@
 
 ;;; Code:
 
-(require 'erc)
 (eval-when-compile (require 'cl-lib))
+(require 'erc-common)
+
+(defvar erc--target)
+(defvar erc-insert-marker)
+(defvar erc-kill-buffer-hook)
+(defvar erc-kill-server-hook)
+(defvar erc-modules)
+(defvar erc-rename-buffers)
+(defvar erc-reuse-buffers)
+(defvar erc-server-announced-name)
+(defvar erc-server-connected)
+(defvar erc-server-parameters)
+(defvar erc-server-process)
+(defvar erc-session-server)
+
+(declare-function erc--default-target "erc" nil)
+(declare-function erc--get-isupport-entry "erc-backend" (key &optional single))
+(declare-function erc-buffer-filter "erc" (predicate &optional proc))
+(declare-function erc-current-nick "erc" nil)
+(declare-function erc-display-error-notice "erc" (parsed string))
+(declare-function erc-error "erc" (&rest args))
+(declare-function erc-get-buffer "erc" (target &optional proc))
+(declare-function erc-server-buffer "erc" nil)
+(declare-function erc-server-process-alive "erc-backend" (&optional buffer))
+(declare-function erc-set-active-buffer "erc" (buffer))
 
 ;; Variables
 
@@ -813,7 +837,7 @@ erc-networks--id-given
   (erc-networks--id-symbol nid))
 
 (cl-generic-define-context-rewriter erc-obsolete-var (var spec)
-  `((with-suppressed-warnings ((obsolete ,var)) ,var) ,spec))
+  `((with-suppressed-warnings ((obsolete ,var) (free-vars ,var)) ,var) ,spec))
 
 ;; As a catch-all, derive the symbol from the unquoted printed repr.
 (cl-defgeneric erc-networks--id-create (id)
diff --git a/lisp/erc/erc.el b/lisp/erc/erc.el
index 7c72085fea..994504d72e 100644
--- a/lisp/erc/erc.el
+++ b/lisp/erc/erc.el
@@ -60,6 +60,9 @@
 
 (load "erc-loaddefs" 'noerror 'nomessage)
 
+(require 'erc-networks)
+(require 'erc-goodies)
+(require 'erc-backend)
 (require 'cl-lib)
 (require 'format-spec)
 (require 'pp)
@@ -69,8 +72,6 @@
 (require 'iso8601)
 (eval-when-compile (require 'subr-x))
 
-(require 'erc-compat)
-
 (defconst erc-version "5.4.1"
   "This version of ERC.")
 
@@ -132,29 +133,12 @@ erc-scripts
   "Running scripts at startup and with /LOAD."
   :group 'erc)
 
-;; Defined in erc-backend
-(defvar erc--server-last-reconnect-count)
-(defvar erc--server-reconnecting)
-(defvar erc-channel-members-changed-hook)
-(defvar erc-network)
-(defvar erc-networks--id)
-(defvar erc-server-367-functions)
-(defvar erc-server-announced-name)
-(defvar erc-server-connect-function)
-(defvar erc-server-connected)
-(defvar erc-server-current-nick)
-(defvar erc-server-lag)
-(defvar erc-server-last-sent-time)
-(defvar erc-server-process)
-(defvar erc-server-quitting)
-(defvar erc-server-reconnect-count)
-(defvar erc-server-reconnecting)
-(defvar erc-session-client-certificate)
-(defvar erc-session-connector)
-(defvar erc-session-port)
-(defvar erc-session-server)
-(defvar erc-session-user-full-name)
-(defvar erc-session-username)
+;; Forward declarations
+(defvar erc-message-parsed)
+
+(defvar tabbar--local-hlf)
+(defvar motif-version-string)
+(defvar gtk-version-string)
 
 ;; tunable connection and authentication parameters
 
@@ -349,9 +333,6 @@ erc-channel-hide-list
   :group 'erc-ignore
   :type 'erc-message-type)
 
-(defvar-local erc-session-password nil
-  "The password used for the current session.")
-
 (defcustom erc-disconnected-hook nil
   "Run this hook with arguments (NICK IP REASON) when disconnected.
 This happens before automatic reconnection.  Note, that
@@ -436,69 +417,14 @@ erc--casemapping-rfc1459-strict
    '((?\[ . ?\{) (?\] . ?\}) (?\\ . ?\|))
    (mapcar (lambda (c) (cons c (+ c 32))) "ABCDEFGHIJKLMNOPQRSTUVWXYZ")))
 
-(defun erc-downcase (string)
-  "Return a downcased copy of STRING with properties.
-Use the CASEMAPPING ISUPPORT parameter to determine the style."
-  (let* ((mapping (erc--get-isupport-entry 'CASEMAPPING 'single))
-         (inhibit-read-only t))
-    (if (equal mapping "ascii")
-        (downcase string)
-      (with-temp-buffer
-        (insert string)
-        (translate-region (point-min) (point-max)
-                          (if (equal mapping "rfc1459-strict")
-                              erc--casemapping-rfc1459-strict
-                            erc--casemapping-rfc1459))
-        (buffer-string)))))
-
-(defmacro erc-with-server-buffer (&rest body)
-  "Execute BODY in the current ERC server buffer.
-If no server buffer exists, return nil."
-  (declare (indent 0) (debug (body)))
-  (let ((buffer (make-symbol "buffer")))
-    `(let ((,buffer (erc-server-buffer)))
-       (when (buffer-live-p ,buffer)
-         (with-current-buffer ,buffer
-           ,@body)))))
-
-(cl-defstruct (erc-server-user (:type vector) :named)
-  ;; User data
-  nickname host login full-name info
-  ;; Buffers
-  ;;
-  ;; This is an alist of the form (BUFFER . CHANNEL-DATA), where
-  ;; CHANNEL-DATA is either nil or an erc-channel-user struct.
-  (buffers nil)
-  )
-
-(cl-defstruct (erc-channel-user (:type vector) :named)
-  voice halfop op admin owner
-  ;; Last message time (in the form of the return value of
-  ;; (current-time)
-  ;;
-  ;; This is useful for ordered name completion.
-  (last-message-time nil))
-
-(define-inline erc-get-channel-user (nick)
-  "Find NICK in the current buffer's `erc-channel-users' hash table."
-  (inline-quote (gethash (erc-downcase ,nick) erc-channel-users)))
-
-(define-inline erc-get-server-user (nick)
-  "Find NICK in the current server's `erc-server-users' hash table."
-  (inline-letevals (nick)
-    (inline-quote (erc-with-server-buffer
-		    (gethash (erc-downcase ,nick) erc-server-users)))))
-
-(define-inline erc-add-server-user (nick user)
+(defun erc-add-server-user (nick user)
   "This function is for internal use only.
 
 Adds USER with nickname NICK to the `erc-server-users' hash table."
-  (inline-letevals (nick user)
-    (inline-quote
-     (erc-with-server-buffer
-       (puthash (erc-downcase ,nick) ,user erc-server-users)))))
+  (erc-with-server-buffer
+    (puthash (erc-downcase nick) user erc-server-users)))
 
-(define-inline erc-remove-server-user (nick)
+(defun erc-remove-server-user (nick)
   "This function is for internal use only.
 
 Removes the user with nickname NICK from the `erc-server-users'
@@ -506,10 +432,8 @@ erc-remove-server-user
 `erc-channel-users' lists of other buffers.
 
 See also: `erc-remove-user'."
-  (inline-letevals (nick)
-    (inline-quote
-     (erc-with-server-buffer
-       (remhash (erc-downcase ,nick) erc-server-users)))))
+  (erc-with-server-buffer
+    (remhash (erc-downcase nick) erc-server-users)))
 
 (defun erc-change-user-nickname (user new-nick)
   "This function is for internal use only.
@@ -580,55 +504,45 @@ erc-remove-channel-users
              erc-channel-users)
     (clrhash erc-channel-users)))
 
-(define-inline erc-channel-user-owner-p (nick)
+(defun erc-channel-user-owner-p (nick)
   "Return non-nil if NICK is an owner of the current channel."
-  (inline-letevals (nick)
-    (inline-quote
-     (and ,nick
-	  (hash-table-p erc-channel-users)
-	  (let ((cdata (erc-get-channel-user ,nick)))
-	    (and cdata (cdr cdata)
-		 (erc-channel-user-owner (cdr cdata))))))))
-
-(define-inline erc-channel-user-admin-p (nick)
+  (and nick
+       (hash-table-p erc-channel-users)
+       (let ((cdata (erc-get-channel-user nick)))
+         (and cdata (cdr cdata)
+              (erc-channel-user-owner (cdr cdata))))))
+
+(defun erc-channel-user-admin-p (nick)
   "Return non-nil if NICK is an admin in the current channel."
-  (inline-letevals (nick)
-    (inline-quote
-     (and ,nick
+  (and nick
        (hash-table-p erc-channel-users)
-       (let ((cdata (erc-get-channel-user ,nick)))
+       (let ((cdata (erc-get-channel-user nick)))
          (and cdata (cdr cdata)
-              (erc-channel-user-admin (cdr cdata))))))))
+              (erc-channel-user-admin (cdr cdata))))))
 
-(define-inline erc-channel-user-op-p (nick)
+(defun erc-channel-user-op-p (nick)
   "Return non-nil if NICK is an operator in the current channel."
-  (inline-letevals (nick)
-    (inline-quote
-     (and ,nick
+  (and nick
        (hash-table-p erc-channel-users)
-       (let ((cdata (erc-get-channel-user ,nick)))
+       (let ((cdata (erc-get-channel-user nick)))
          (and cdata (cdr cdata)
-              (erc-channel-user-op (cdr cdata))))))))
+              (erc-channel-user-op (cdr cdata))))))
 
-(define-inline erc-channel-user-halfop-p (nick)
+(defun erc-channel-user-halfop-p (nick)
   "Return non-nil if NICK is a half-operator in the current channel."
-  (inline-letevals (nick)
-    (inline-quote
-     (and ,nick
+  (and nick
        (hash-table-p erc-channel-users)
-       (let ((cdata (erc-get-channel-user ,nick)))
+       (let ((cdata (erc-get-channel-user nick)))
          (and cdata (cdr cdata)
-              (erc-channel-user-halfop (cdr cdata))))))))
+              (erc-channel-user-halfop (cdr cdata))))))
 
-(define-inline erc-channel-user-voice-p (nick)
+(defun erc-channel-user-voice-p (nick)
   "Return non-nil if NICK has voice in the current channel."
-  (inline-letevals (nick)
-    (inline-quote
-     (and ,nick
+  (and nick
        (hash-table-p erc-channel-users)
-       (let ((cdata (erc-get-channel-user ,nick)))
+       (let ((cdata (erc-get-channel-user nick)))
          (and cdata (cdr cdata)
-              (erc-channel-user-voice (cdr cdata))))))))
+              (erc-channel-user-voice (cdr cdata))))))
 
 (defun erc-get-channel-user-list ()
   "Return a list of users in the current channel.
@@ -1377,96 +1291,6 @@ erc-debug-log-file
 
 (defvar-local erc-dbuf nil)
 
-(defmacro define-erc-module (name alias doc enable-body disable-body
-                                  &optional local-p)
-  "Define a new minor mode using ERC conventions.
-Symbol NAME is the name of the module.
-Symbol ALIAS is the alias to use, or nil.
-DOC is the documentation string to use for the minor mode.
-ENABLE-BODY is a list of expressions used to enable the mode.
-DISABLE-BODY is a list of expressions used to disable the mode.
-If LOCAL-P is non-nil, the mode will be created as a buffer-local
-mode, rather than a global one.
-
-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.
-
-Example:
-
-  ;;;###autoload(autoload \\='erc-replace-mode \"erc-replace\")
-  (define-erc-module replace nil
-    \"This mode replaces incoming text according to `erc-replace-alist'.\"
-    ((add-hook \\='erc-insert-modify-hook
-               #\\='erc-replace-insert))
-    ((remove-hook \\='erc-insert-modify-hook
-                  #\\='erc-replace-insert)))"
-  (declare (doc-string 3) (indent defun))
-  (let* ((sn (symbol-name name))
-         (mode (intern (format "erc-%s-mode" (downcase sn))))
-         (group (intern (format "erc-%s" (downcase sn))))
-         (enable (intern (format "erc-%s-enable" (downcase sn))))
-         (disable (intern (format "erc-%s-disable" (downcase sn)))))
-    `(progn
-       (define-minor-mode
-        ,mode
-        ,(format "Toggle ERC %S mode.
-With a prefix argument ARG, enable %s if ARG is positive,
-and disable it otherwise.  If called from Lisp, enable the mode
-if ARG is omitted or nil.
-%s" name name doc)
-        ;; FIXME: We don't know if this group exists, so this `:group' may
-        ;; actually just silence a valid warning about the fact that the var
-        ;; is not associated with any group.
-        :global ,(not local-p) :group (quote ,group)
-        (if ,mode
-            (,enable)
-          (,disable)))
-       (defun ,enable ()
-         ,(format "Enable ERC %S mode."
-                  name)
-         (interactive)
-         (add-to-list 'erc-modules (quote ,name))
-         (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)
-       ,(when (and alias (not (eq name alias)))
-          `(defalias
-             ',(intern
-                (format "erc-%s-mode"
-                        (downcase (symbol-name alias))))
-             #',mode))
-       ;; For find-function and find-variable.
-       (put ',mode    'definition-name ',name)
-       (put ',enable  'definition-name ',name)
-       (put ',disable 'definition-name ',name))))
-
-;; The rationale for favoring inheritance here (nicer dispatch) is
-;; kinda flimsy since there aren't yet any actual methods.
-
-(cl-defstruct erc--target
-  (string "" :type string :documentation "Received name of target.")
-  (symbol nil :type symbol :documentation "Case-mapped name as symbol."))
-
-;; These should probably take on a `joined' field to track joinedness,
-;; which should be toggled by `erc-server-JOIN', `erc-server-PART',
-;; etc.  Functions like `erc--current-buffer-joined-p' (bug#48598) may
-;; find it useful.
-
-(cl-defstruct (erc--target-channel (:include erc--target)))
-
-(cl-defstruct (erc--target-channel-local (:include erc--target-channel)))
-
-;; At some point, it may make sense to add a query type with an
-;; account field, which may help support reassociation across
-;; reconnects and nick changes (likely requires v3 extensions).
-
 (defun erc--target-from-string (string)
   "Construct an `erc--target' variant from STRING."
   (funcall (if (erc-channel-p string)
@@ -1516,12 +1340,6 @@ erc-once-with-server-event
     (add-hook hook fun nil t)
     fun))
 
-(define-inline erc-log (string)
-  "Logs STRING if logging is on (see `erc-log-p')."
-  (inline-quote
-   (when erc-log-p
-     (erc-log-aux ,string))))
-
 (defun erc-server-buffer ()
   "Return the server buffer for the current buffer's process.
 The buffer-local variable `erc-server-process' is used to find
@@ -1577,29 +1395,7 @@ erc-ison-p
                    (if erc-online-p "" "not "))
         erc-online-p))))
 
-(defun erc-log-aux (string)
-  "Do the debug logging of STRING."
-  (let ((cb (current-buffer))
-        (point 1)
-        (was-eob nil)
-        (session-buffer (erc-server-buffer)))
-    (if session-buffer
-        (progn
-          (set-buffer session-buffer)
-          (if (not (and erc-dbuf (bufferp erc-dbuf) (buffer-live-p erc-dbuf)))
-              (progn
-                (setq erc-dbuf (get-buffer-create
-                                (concat "*ERC-DEBUG: "
-                                        erc-session-server "*")))))
-          (set-buffer erc-dbuf)
-          (setq point (point))
-          (setq was-eob (eobp))
-          (goto-char (point-max))
-          (insert (concat "** " string "\n"))
-          (if was-eob (goto-char (point-max))
-            (goto-char point))
-          (set-buffer cb))
-      (message "ERC: ** %s" string))))
+
 
 ;; Last active buffer, to print server messages in the right place
 
@@ -1841,40 +1637,6 @@ erc-member-ignore-case
           (throw 'result list)
         (setq list (cdr list))))))
 
-(defmacro erc-with-buffer (spec &rest body)
-  "Execute BODY in the buffer associated with SPEC.
-
-SPEC should have the form
-
- (TARGET [PROCESS])
-
-If TARGET is a buffer, use it.  Otherwise, use the buffer
-matching TARGET in the process specified by PROCESS.
-
-If PROCESS is nil, use the current `erc-server-process'.
-See `erc-get-buffer' for details.
-
-See also `with-current-buffer'.
-
-\(fn (TARGET [PROCESS]) BODY...)"
-  (declare (indent 1) (debug ((form &optional form) body)))
-  (let ((buf (make-symbol "buf"))
-        (proc (make-symbol "proc"))
-        (target (make-symbol "target"))
-        (process (make-symbol "process")))
-    `(let* ((,target ,(car spec))
-            (,process ,(cadr spec))
-            (,buf (if (bufferp ,target)
-                      ,target
-                    (let ((,proc (or ,process
-                                     (and (processp erc-server-process)
-                                          erc-server-process))))
-                      (if (and ,target ,proc)
-                          (erc-get-buffer ,target ,proc))))))
-       (when (buffer-live-p ,buf)
-         (with-current-buffer ,buf
-           ,@body)))))
-
 (defun erc-get-buffer (target &optional proc)
   "Return the buffer matching TARGET in the process PROC.
 If PROC is not supplied, all processes are searched."
@@ -1921,18 +1683,6 @@ erc-buffer-list
     (setq predicate (lambda () t)))
   (erc-buffer-filter predicate proc))
 
-(defmacro erc-with-all-buffers-of-server (process pred &rest forms)
-  "Execute FORMS in all buffers which have same process as this server.
-FORMS will be evaluated in all buffers having the process PROCESS and
-where PRED matches or in all buffers of the server process if PRED is
-nil."
-  (declare (indent 1) (debug (form form body)))
-  (macroexp-let2 nil pred pred
-    `(erc-buffer-filter (lambda ()
-                          (when (or (not ,pred) (funcall ,pred))
-                            ,@forms))
-                        ,process)))
-
 (define-obsolete-function-alias 'erc-iswitchb #'erc-switch-to-buffer "25.1")
 (defun erc--switch-to-buffer (&optional arg)
   (read-buffer "Switch to ERC buffer: "
@@ -2903,8 +2653,6 @@ erc-lurker-cleanup-interval
 consumption of lurker state during long Emacs sessions and/or ERC
 sessions with large numbers of incoming PRIVMSGs.")
 
-(defvar erc-message-parsed)
-
 (defun erc-lurker-update-status (_message)
   "Update `erc-lurker-state' if necessary.
 
@@ -4116,9 +3864,6 @@ erc-cmd-SERVER
   t)
 (put 'erc-cmd-SERVER 'process-not-needed t)
 
-(defvar motif-version-string)
-(defvar gtk-version-string)
-
 (defun erc-cmd-SV ()
   "Say the current ERC and Emacs version into channel."
   (erc-send-message (format "I'm using ERC %s with GNU Emacs %s (%s%s)%s."
@@ -5375,6 +5120,12 @@ erc-parse-prefix
           (setq i (1+ i)))
         alist))))
 
+(defcustom erc-channel-members-changed-hook nil
+  "This hook is called every time the variable `channel-members' changes.
+The buffer where the change happened is current while this hook is called."
+  :group 'erc-hooks
+  :type 'hook)
+
 (defun erc-channel-receive-names (names-string)
   "This function is for internal use only.
 
@@ -5418,13 +5169,6 @@ erc-channel-receive-names
              name name t voice halfop op admin owner)))))
     (run-hooks 'erc-channel-members-changed-hook)))
 
-
-(defcustom erc-channel-members-changed-hook nil
-  "This hook is called every time the variable `channel-members' changes.
-The buffer where the change happened is current while this hook is called."
-  :group 'erc-hooks
-  :type 'hook)
-
 (defun erc-update-user-nick (nick &optional new-nick
                                   host login full-name info)
   "Update the stored user information for the user with nickname NICK.
@@ -6034,12 +5778,6 @@ erc-user-input
 (defvar erc-command-regexp "^/\\([A-Za-z']+\\)\\(\\s-+.*\\|\\s-*\\)$"
   "Regular expression used for matching commands in ERC.")
 
-(cl-defstruct erc-input
-  string insertp sendp)
-
-(cl-defstruct (erc--input-split (:include erc-input))
-  lines cmdp)
-
 (defun erc--discard-trailing-multiline-nulls (state)
   "Ensure last line of STATE's string is non-null.
 But only when `erc-send-whitespace-lines' is non-nil.  STATE is
@@ -6983,9 +6721,6 @@ erc-format-lag-time
           (t ""))))
 
 ;; erc-goodies is required at end of this file.
-(declare-function erc-controls-strip "erc-goodies" (str))
-
-(defvar tabbar--local-hlf)
 
 ;; FIXME when 29.1 is cut and `format-spec' is added to ELPA Compat,
 ;; remove the function invocations from the spec form below.
@@ -7474,12 +7209,4 @@ erc-handle-irc-url
 
 (provide 'erc)
 
-(require 'erc-backend)
-
-;; Deprecated. We might eventually stop requiring the goodies automatically.
-;; IMPORTANT: This require must appear _after_ the above (provide 'erc) to
-;; avoid a recursive require error when byte-compiling the entire package.
-(require 'erc-goodies)
-(require 'erc-networks)
-
 ;;; erc.el ends here
diff --git a/test/lisp/erc/erc-networks-tests.el b/test/lisp/erc/erc-networks-tests.el
index 66a334b709..32bdfa11ff 100644
--- a/test/lisp/erc/erc-networks-tests.el
+++ b/test/lisp/erc/erc-networks-tests.el
@@ -20,7 +20,7 @@
 ;;; Code:
 
 (require 'ert-x) ; cl-lib
-(require 'erc-networks)
+(require 'erc)
 
 (defun erc-networks-tests--create-dead-proc (&optional buf)
   (let ((p (start-process "true" (or buf (current-buffer)) "true")))
diff --git a/test/lisp/erc/erc-tests.el b/test/lisp/erc/erc-tests.el
index d3d319ab22..4646c35e25 100644
--- a/test/lisp/erc/erc-tests.el
+++ b/test/lisp/erc/erc-tests.el
@@ -24,7 +24,6 @@
 (require 'ert-x)
 (require 'erc)
 (require 'erc-ring)
-(require 'erc-networks)
 
 (ert-deftest erc--read-time-period ()
   (cl-letf (((symbol-function 'read-string) (lambda (&rest _) "")))
@@ -48,27 +47,6 @@ erc--read-time-period
   (cl-letf (((symbol-function 'read-string) (lambda (&rest _) "1d")))
     (should (equal (erc--read-time-period "foo: ") 86400))))
 
-(ert-deftest erc--meta--backend-dependencies ()
-  (with-temp-buffer
-    (insert-file-contents-literally
-     (concat (file-name-sans-extension (symbol-file 'erc)) ".el"))
-    (let ((beg (search-forward ";; Defined in erc-backend"))
-          (end (search-forward "\n\n"))
-          vars)
-      (save-excursion
-        (save-restriction
-          (narrow-to-region beg end)
-          (goto-char (point-min))
-          (with-syntax-table lisp-data-mode-syntax-table
-            (condition-case _
-                (while (push (cadr (read (current-buffer))) vars))
-              (end-of-file)))))
-      (should (= (point) end))
-      (dolist (var vars)
-        (setq var (concat "\\_<" (symbol-name var) "\\_>"))
-        (ert-info (var)
-          (should (save-excursion (search-forward-regexp var nil t))))))))
-
 (ert-deftest erc-with-all-buffers-of-server ()
   (let (proc-exnet
         proc-onet
-- 
2.37.3


[-- Warning: decoded text below may be mangled, UTF-8 assumed --]
[-- Attachment #3: 0001-Move-ERC-s-core-dependencies-to-separate-file.patch --]
[-- Type: text/x-patch, Size: 46973 bytes --]

From 2f66c3f4dcc41195e5578d6a9cf38d98fc1a05d2 Mon Sep 17 00:00:00 2001
From: dickmao <dick.r.chiang@gmail.com>
Date: Fri, 1 Jul 2022 11:06:51 -0400
Subject: [PATCH 1/5] Move ERC's core dependencies to separate file

Asking people to order require's is about as effective
as asking kids to keep off the grass.

* lisp/erc/erc-backend.el (erc--target, erc-auto-query,
erc-channel-list, erc-channel-users, erc-default-nicks,
erc-default-recipients, erc-format-nick-function,
erc-format-query-as-channel-p, erc-hide-prompt, erc-input-marker,
erc-insert-marker, erc-invitation, erc-join-buffer,
erc-kill-buffer-on-part, erc-kill-server-buffer-on-quit, erc-log-p,
erc-minibuffer-ignored, erc-networks--id, erc-nick,
erc-nick-change-attempt-count, erc-prompt-for-channel-key,
erc-prompt-hidden, erc-reuse-buffers, erc-verbose-server-ping,
erc-whowas-on-nosuchnick): Forward-declare variables.
(erc--open-target, erc--target-from-string, erc-active-buffer,
erc-add-default-channel, erc-banlist-update, erc-buffer-filter,
erc-buffer-list-with-nick, erc-channel-begin-receiving-names,
erc-channel-end-receiving-names, erc-channel-p,
erc-channel-receive-names, erc-cmd-JOIN, erc-connection-established,
erc-current-nick, erc-current-nick-p, erc-current-time,
erc-default-target, erc-delete-default-channel,
erc-display-error-notice, erc-display-server-message,
erc-emacs-time-to-erc-time, erc-format-message,
erc-format-privmessage, erc-get-buffer, erc-handle-login,
erc-handle-user-status-change, erc-ignored-reply-p,
erc-ignored-user-p, erc-is-message-ctcp-and-not-action-p,
erc-is-message-ctcp-p, erc-log-irc-protocol, erc-login,
erc-make-notice, erc-network, erc-networks--id-given,
erc-networks--id-reload, erc-nickname-in-use, erc-parse-user,
erc-process-away, erc-process-ctcp-query, erc-query-buffer-p,
erc-remove-channel-member, erc-remove-channel-users, erc-remove-user,
erc-sec-to-time, erc-server-buffer, erc-set-active-buffer,
erc-set-current-nick, erc-set-modes, erc-time-diff, erc-trim-string,
erc-update-mode-line, erc-update-mode-line-buffer,
erc-wash-quit-reason, erc-display-message, erc-get-buffer-create,
erc-process-ctcp-reply, erc-update-channel-topic, erc-update-modes,
erc-update-user-nick, erc-open, erc-update-channel-member):
Forward-declare functions.
(erc-response): Move to lisp/erc/erc-common.el.
(erc-compat--with-memoization): Use "erc-compat-" prefixed macro.

* lisp/erc/erc-common.el: New file.  Change indentation for
`erc-with-all-buffers-of-server' from 1 to 2.

* lisp/erc/erc-compat.el (erc-compat--with-memoization): Migrate macro
from `erc-common' and rename.

* lisp/erc/erc-goodies.el: Require `erc-common' instead of `erc'.
(erc-controls-highlight-regexp, erc-controls-remove-regexp,
erc-input-marker, erc-insert-marker, erc-server-process, erc-modules,
erc-log-p): Forward declare variables.
(erc-buffer-list, erc-error, erc-extract-command-from-line):
Forward-declare functions.

* lisp/erc/erc-networks.el (erc--target, erc-insert-marker,
erc-kill-buffer-hook, erc-kill-server-hook, erc-modules,
erc-rename-buffers, erc-reuse-buffers, erc-server-announced-name,
erc-server-connected, erc-server-parameters, erc-server-process,
erc-session-server): Forward declare variables.
(erc--default-target, erc--get-isupport-entry, erc-buffer-filter,
erc-current-nick, erc-display-error-notice, erc-error, erc-get-buffer,
erc-server-buffer, erc-server-process-alive): Forward-declare
functions.
(erc-obsolete-var): Also suppress free-variable warnings.

* lisp/erc/erc.el: Require `erc-networks', `erc-goodies', and
`erc-backend' at top of file.  Don't require `erc-compat'.
(erc--server-last-reconnect-count, erc--server-reconnecting,
erc-channel-members-changed-hook, erc-network, erc-networks--id,
erc-server-367-functions, erc-server-announced-name,
erc-server-connect-function, erc-server-connected,
erc-server-current-nick, erc-server-lag, erc-server-last-sent-time,
erc-server-process, erc-server-quitting, erc-server-reconnect-count,
erc-server-reconnecting, erc-session-client-certificate,
erc-session-connector, erc-session-port, erc-session-server,
erc-session-user-full-name) Remove superfluous forward declarations.
(erc-message-parsed, tabbar--local-hlf, motif-version-string):
Relocate forward declares to central location.
(erc-session-password): Move to `erc-backend'.
(erc-downcase, erc-with-server-buffer, erc-server-user,
erc-channel-user, erc-get-channel-user, erc-get-server-user): Move to
lisp/erc/erc-common.el.
(erc-add-server-user, erc-remove-server-user,
erc-channel-user-owner-p, erc-channel-user-admin-p,
erc-channel-user-op-p, erc-channel-user-halfop-p,
erc-channel-user-voice-p): Convert from inline functions to normal
functions.
(define-erc-module, erc--target, erc--target-channel,
erc--target-channel-local, erc-log, erc-log-aux, erc-with-buffer,
erc-with-all-buffers-of-server): Move to lisp/erc/erc-common.el.
(erc-channel-members-changed-hook): Relocate option to avoid compiler
warning.
(erc-input, erc--input-split): Move to lisp/erc/erc-common.el.
(erc-controls-strip): Remove forward declaration temporarily until
this file stops requiring `erc-goodies'.

* test/lisp/erc/erc-networks-tests.el: Require `erc' instead of
`erc-networks'.

* test/lisp/erc/erc.el (erc--meta--backend-dependencies): Remove
unused test.  Don't require `erc-networks'. Bug#56340.
---
 lisp/erc/erc-backend.el             | 129 ++++++++--
 lisp/erc/erc-common.el              | 271 +++++++++++++++++++++
 lisp/erc/erc-compat.el              |  12 +
 lisp/erc/erc-goodies.el             |  17 +-
 lisp/erc/erc-networks.el            |  28 ++-
 lisp/erc/erc.el                     | 363 ++++------------------------
 test/lisp/erc/erc-networks-tests.el |   2 +-
 test/lisp/erc/erc-tests.el          |  22 --
 8 files changed, 476 insertions(+), 368 deletions(-)
 create mode 100644 lisp/erc/erc-common.el

diff --git a/lisp/erc/erc-backend.el b/lisp/erc/erc-backend.el
index df9efe4b0c..026b34849a 100644
--- a/lisp/erc/erc-backend.el
+++ b/lisp/erc/erc-backend.el
@@ -99,24 +99,117 @@
 ;;; Code:
 
 (eval-when-compile (require 'cl-lib))
-;; There's a fairly strong mutual dependency between erc.el and erc-backend.el.
-;; Luckily, erc.el does not need erc-backend.el for macroexpansion whereas the
-;; reverse is true:
-(require 'erc)
+(require 'erc-common)
+
+(defvar erc--target)
+(defvar erc-auto-query)
+(defvar erc-channel-list)
+(defvar erc-channel-users)
+(defvar erc-default-nicks)
+(defvar erc-default-recipients)
+(defvar erc-format-nick-function)
+(defvar erc-format-query-as-channel-p)
+(defvar erc-hide-prompt)
+(defvar erc-input-marker)
+(defvar erc-insert-marker)
+(defvar erc-invitation)
+(defvar erc-join-buffer)
+(defvar erc-kill-buffer-on-part)
+(defvar erc-kill-server-buffer-on-quit)
+(defvar erc-log-p)
+(defvar erc-minibuffer-ignored)
+(defvar erc-networks--id)
+(defvar erc-nick)
+(defvar erc-nick-change-attempt-count)
+(defvar erc-prompt-for-channel-key)
+(defvar erc-prompt-hidden)
+(defvar erc-reuse-buffers)
+(defvar erc-verbose-server-ping)
+(defvar erc-whowas-on-nosuchnick)
+
+(declare-function erc--open-target "erc" (target))
+(declare-function erc--target-from-string "erc" (string))
+(declare-function erc-active-buffer "erc" nil)
+(declare-function erc-add-default-channel "erc" (channel))
+(declare-function erc-banlist-update "erc" (proc parsed))
+(declare-function erc-buffer-filter "erc" (predicate &optional proc))
+(declare-function erc-buffer-list-with-nick "erc" (nick proc))
+(declare-function erc-channel-begin-receiving-names "erc" nil)
+(declare-function erc-channel-end-receiving-names "erc" nil)
+(declare-function erc-channel-p "erc" (channel))
+(declare-function erc-channel-receive-names "erc" (names-string))
+(declare-function erc-cmd-JOIN "erc" (channel &optional key))
+(declare-function erc-connection-established "erc" (proc parsed))
+(declare-function erc-current-nick "erc" nil)
+(declare-function erc-current-nick-p "erc" (nick))
+(declare-function erc-current-time "erc" (&optional specified-time))
+(declare-function erc-default-target "erc" nil)
+(declare-function erc-delete-default-channel "erc" (channel &optional buffer))
+(declare-function erc-display-error-notice "erc" (parsed string))
+(declare-function erc-display-server-message "erc" (_proc parsed))
+(declare-function erc-emacs-time-to-erc-time "erc" (&optional specified-time))
+(declare-function erc-format-message "erc" (msg &rest args))
+(declare-function erc-format-privmessage "erc" (nick msg privp msgp))
+(declare-function erc-get-buffer "erc" (target &optional proc))
+(declare-function erc-handle-login "erc" nil)
+(declare-function erc-handle-user-status-change "erc" (type nlh &optional l))
+(declare-function erc-ignored-reply-p "erc" (msg tgt proc))
+(declare-function erc-ignored-user-p "erc" (spec))
+(declare-function erc-is-message-ctcp-and-not-action-p "erc" (message))
+(declare-function erc-is-message-ctcp-p "erc" (message))
+(declare-function erc-log-irc-protocol "erc" (string &optional outbound))
+(declare-function erc-login "erc" nil)
+(declare-function erc-make-notice "erc" (message))
+(declare-function erc-network "erc-networks" nil)
+(declare-function erc-networks--id-given "erc-networks" (arg &rest args))
+(declare-function erc-networks--id-reload "erc-networks" (arg &rest args))
+(declare-function erc-nickname-in-use "erc" (nick reason))
+(declare-function erc-parse-user "erc" (string))
+(declare-function erc-process-away "erc" (proc away-p))
+(declare-function erc-process-ctcp-query "erc" (proc parsed nick login host))
+(declare-function erc-query-buffer-p "erc" (&optional buffer))
+(declare-function erc-remove-channel-member "erc" (channel nick))
+(declare-function erc-remove-channel-users "erc" nil)
+(declare-function erc-remove-user "erc" (nick))
+(declare-function erc-sec-to-time "erc" (ns))
+(declare-function erc-server-buffer "erc" nil)
+(declare-function erc-set-active-buffer "erc" (buffer))
+(declare-function erc-set-current-nick "erc" (nick))
+(declare-function erc-set-modes "erc" (tgt mode-string))
+(declare-function erc-time-diff "erc" (t1 t2))
+(declare-function erc-trim-string "erc" (s))
+(declare-function erc-update-mode-line "erc" (&optional buffer))
+(declare-function erc-update-mode-line-buffer "erc" (buffer))
+(declare-function erc-wash-quit-reason "erc" (reason nick login host))
+
+(declare-function erc-display-message "erc"
+                  (parsed type buffer msg &rest args))
+(declare-function erc-get-buffer-create "erc"
+                  (server port target &optional tgt-info id))
+(declare-function erc-process-ctcp-reply "erc"
+                  (proc parsed nick login host msg))
+(declare-function erc-update-channel-topic "erc"
+                  (channel topic &optional modify))
+(declare-function erc-update-modes "erc"
+                  (tgt mode-string &optional _nick _host _login))
+(declare-function erc-update-user-nick "erc"
+                  (nick &optional new-nick host login full-name info))
+(declare-function erc-open "erc"
+                  (&optional server port nick full-name connect passwd tgt-list
+                             channel process client-certificate user id))
+(declare-function erc-update-channel-member "erc"
+                  (channel nick new-nick
+                           &optional add voice halfop op admin owner host
+                           login full-name info update-message-time))
 
 ;;;; Variables and options
 
+(defvar-local erc-session-password nil
+  "The password used for the current session.")
+
 (defvar erc-server-responses (make-hash-table :test #'equal)
   "Hash table mapping server responses to their handler hooks.")
 
-(cl-defstruct (erc-response (:conc-name erc-response.))
-  (unparsed "" :type string)
-  (sender "" :type string)
-  (command "" :type string)
-  (command-args '() :type list)
-  (contents "" :type string)
-  (tags '() :type list))
-
 ;;; User data
 
 (defvar-local erc-server-current-nick nil
@@ -1662,16 +1755,6 @@ erc--parse-isupport-value
          (split-string value ",")
        (list value)))))
 
-(defmacro erc--with-memoization (table &rest forms)
-  "Adapter to be migrated to erc-compat."
-  (declare (indent defun))
-  `(cond
-    ((fboundp 'with-memoization)
-     (with-memoization ,table ,@forms)) ; 29.1
-    ((fboundp 'cl--generic-with-memoization)
-     (cl--generic-with-memoization ,table ,@forms))
-    (t ,@forms)))
-
 (defun erc--get-isupport-entry (key &optional single)
   "Return an item for \"ISUPPORT\" token KEY, a symbol.
 When a lookup fails return nil.  Otherwise return a list whose
@@ -1681,7 +1764,7 @@ erc--get-isupport-entry
 primitive value."
   (if-let* ((table (or erc--isupport-params
                        (erc-with-server-buffer erc--isupport-params)))
-            (value (erc--with-memoization (gethash key table)
+            (value (erc-compat--with-memoization (gethash key table)
                      (when-let ((v (assoc (symbol-name key)
                                           erc-server-parameters)))
                        (if (cdr v)
diff --git a/lisp/erc/erc-common.el b/lisp/erc/erc-common.el
new file mode 100644
index 0000000000..d8aac36eab
--- /dev/null
+++ b/lisp/erc/erc-common.el
@@ -0,0 +1,271 @@
+;;; erc-common.el --- Macros and types for ERC  -*- lexical-binding:t -*-
+
+;; Copyright (C) 2022 Free Software Foundation, Inc.
+;;
+;; Maintainer: Amin Bandali <bandali@gnu.org>, F. Jason Park <jp@neverwas.me>
+;; Keywords: comm, IRC, chat, client, internet
+;;
+;; 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:
+
+(eval-when-compile (require 'cl-lib) (require 'subr-x))
+(require 'erc-compat)
+
+(defvar erc--casemapping-rfc1459)
+(defvar erc--casemapping-rfc1459-strict)
+(defvar erc-channel-users)
+(defvar erc-dbuf)
+(defvar erc-log-p)
+(defvar erc-server-users)
+(defvar erc-session-server)
+
+(declare-function erc--get-isupport-entry "erc-backend" (key &optional single))
+(declare-function erc-get-buffer "erc" (target &optional proc))
+(declare-function erc-server-buffer "erc" nil)
+
+(cl-defstruct erc-input
+  string insertp sendp)
+
+(cl-defstruct (erc--input-split (:include erc-input))
+  lines cmdp)
+
+(cl-defstruct (erc-server-user (:type vector) :named)
+  ;; User data
+  nickname host login full-name info
+  ;; Buffers
+  ;;
+  ;; This is an alist of the form (BUFFER . CHANNEL-DATA), where
+  ;; CHANNEL-DATA is either nil or an erc-channel-user struct.
+  (buffers nil))
+
+(cl-defstruct (erc-channel-user (:type vector) :named)
+  voice halfop op admin owner
+  ;; Last message time (in the form of the return value of
+  ;; (current-time)
+  ;;
+  ;; This is useful for ordered name completion.
+  (last-message-time nil))
+
+(cl-defstruct erc--target
+  (string "" :type string :documentation "Received name of target.")
+  (symbol nil :type symbol :documentation "Case-mapped name as symbol."))
+
+;; At some point, it may make sense to add a query type with an
+;; account field, which may help support reassociation across
+;; reconnects and nick changes (likely requires v3 extensions).
+;;
+;; These channel variants should probably take on a `joined' field to
+;; track "joinedness", which `erc-server-JOIN', `erc-server-PART',
+;; etc. should toggle.  Functions like `erc--current-buffer-joined-p'
+;; may find it useful.
+
+(cl-defstruct (erc--target-channel (:include erc--target)))
+(cl-defstruct (erc--target-channel-local (:include erc--target-channel)))
+
+(cl-defstruct (erc-response (:conc-name erc-response.))
+  (unparsed "" :type string)
+  (sender "" :type string)
+  (command "" :type string)
+  (command-args '() :type list)
+  (contents "" :type string)
+  (tags '() :type list))
+
+(defmacro define-erc-module (name alias doc enable-body disable-body
+                                  &optional local-p)
+  "Define a new minor mode using ERC conventions.
+Symbol NAME is the name of the module.
+Symbol ALIAS is the alias to use, or nil.
+DOC is the documentation string to use for the minor mode.
+ENABLE-BODY is a list of expressions used to enable the mode.
+DISABLE-BODY is a list of expressions used to disable the mode.
+If LOCAL-P is non-nil, the mode will be created as a buffer-local
+mode, rather than a global one.
+
+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.
+
+Example:
+
+  ;;;###autoload(autoload \\='erc-replace-mode \"erc-replace\")
+  (define-erc-module replace nil
+    \"This mode replaces incoming text according to `erc-replace-alist'.\"
+    ((add-hook \\='erc-insert-modify-hook
+               #\\='erc-replace-insert))
+    ((remove-hook \\='erc-insert-modify-hook
+                  #\\='erc-replace-insert)))"
+  (declare (doc-string 3) (indent defun))
+  (let* ((sn (symbol-name name))
+         (mode (intern (format "erc-%s-mode" (downcase sn))))
+         (group (intern (format "erc-%s" (downcase sn))))
+         (enable (intern (format "erc-%s-enable" (downcase sn))))
+         (disable (intern (format "erc-%s-disable" (downcase sn)))))
+    `(progn
+       (define-minor-mode
+         ,mode
+         ,(format "Toggle ERC %S mode.
+With a prefix argument ARG, enable %s if ARG is positive,
+and disable it otherwise.  If called from Lisp, enable the mode
+if ARG is omitted or nil.
+%s" name name doc)
+         ;; FIXME: We don't know if this group exists, so this `:group' may
+         ;; actually just silence a valid warning about the fact that the var
+         ;; is not associated with any group.
+         :global ,(not local-p) :group (quote ,group)
+         (if ,mode
+             (,enable)
+           (,disable)))
+       (defun ,enable ()
+         ,(format "Enable ERC %S mode."
+                  name)
+         (interactive)
+         (add-to-list 'erc-modules (quote ,name))
+         (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)
+       ,(when (and alias (not (eq name alias)))
+          `(defalias
+             ',(intern
+                (format "erc-%s-mode"
+                        (downcase (symbol-name alias))))
+             #',mode))
+       ;; For find-function and find-variable.
+       (put ',mode    'definition-name ',name)
+       (put ',enable  'definition-name ',name)
+       (put ',disable 'definition-name ',name))))
+
+(defmacro erc-with-buffer (spec &rest body)
+  "Execute BODY in the buffer associated with SPEC.
+
+SPEC should have the form
+
+ (TARGET [PROCESS])
+
+If TARGET is a buffer, use it.  Otherwise, use the buffer
+matching TARGET in the process specified by PROCESS.
+
+If PROCESS is nil, use the current `erc-server-process'.
+See `erc-get-buffer' for details.
+
+See also `with-current-buffer'.
+
+\(fn (TARGET [PROCESS]) BODY...)"
+  (declare (indent 1) (debug ((form &optional form) body)))
+  (let ((buf (make-symbol "buf"))
+        (proc (make-symbol "proc"))
+        (target (make-symbol "target"))
+        (process (make-symbol "process")))
+    `(let* ((,target ,(car spec))
+            (,process ,(cadr spec))
+            (,buf (if (bufferp ,target)
+                      ,target
+                    (let ((,proc (or ,process
+                                     (and (processp erc-server-process)
+                                          erc-server-process))))
+                      (if (and ,target ,proc)
+                          (erc-get-buffer ,target ,proc))))))
+       (when (buffer-live-p ,buf)
+         (with-current-buffer ,buf
+           ,@body)))))
+
+(defmacro erc-with-server-buffer (&rest body)
+  "Execute BODY in the current ERC server buffer.
+If no server buffer exists, return nil."
+  (declare (indent 0) (debug (body)))
+  (let ((buffer (make-symbol "buffer")))
+    `(let ((,buffer (erc-server-buffer)))
+       (when (buffer-live-p ,buffer)
+         (with-current-buffer ,buffer
+           ,@body)))))
+
+(defmacro erc-with-all-buffers-of-server (process pred &rest forms)
+  "Execute FORMS in all buffers which have same process as this server.
+FORMS will be evaluated in all buffers having the process PROCESS and
+where PRED matches or in all buffers of the server process if PRED is
+nil."
+  (declare (indent 2) (debug (form form body)))
+  (macroexp-let2 nil pred pred
+    `(erc-buffer-filter (lambda ()
+                          (when (or (not ,pred) (funcall ,pred))
+                            ,@forms))
+                        ,process)))
+
+(defun erc-log-aux (string)
+  "Do the debug logging of STRING."
+  (let ((cb (current-buffer))
+        (point 1)
+        (was-eob nil)
+        (session-buffer (erc-server-buffer)))
+    (if session-buffer
+        (progn
+          (set-buffer session-buffer)
+          (if (not (and erc-dbuf (bufferp erc-dbuf) (buffer-live-p erc-dbuf)))
+              (progn
+                (setq erc-dbuf (get-buffer-create
+                                (concat "*ERC-DEBUG: "
+                                        erc-session-server "*")))))
+          (set-buffer erc-dbuf)
+          (setq point (point))
+          (setq was-eob (eobp))
+          (goto-char (point-max))
+          (insert (concat "** " string "\n"))
+          (if was-eob (goto-char (point-max))
+            (goto-char point))
+          (set-buffer cb))
+      (message "ERC: ** %s" string))))
+
+(define-inline erc-log (string)
+  "Logs STRING if logging is on (see `erc-log-p')."
+  (inline-quote
+   (when erc-log-p
+     (erc-log-aux ,string))))
+
+(defun erc-downcase (string)
+  "Return a downcased copy of STRING with properties.
+Use the CASEMAPPING ISUPPORT parameter to determine the style."
+  (let* ((mapping (erc--get-isupport-entry 'CASEMAPPING 'single))
+         (inhibit-read-only t))
+    (if (equal mapping "ascii")
+        (downcase string)
+      (with-temp-buffer
+        (insert string)
+        (translate-region (point-min) (point-max)
+                          (if (equal mapping "rfc1459-strict")
+                              erc--casemapping-rfc1459-strict
+                            erc--casemapping-rfc1459))
+        (buffer-string)))))
+
+(define-inline erc-get-channel-user (nick)
+  "Find NICK in the current buffer's `erc-channel-users' hash table."
+  (inline-quote (gethash (erc-downcase ,nick) erc-channel-users)))
+
+(define-inline erc-get-server-user (nick)
+  "Find NICK in the current server's `erc-server-users' hash table."
+  (inline-letevals (nick)
+    (inline-quote (erc-with-server-buffer
+                    (gethash (erc-downcase ,nick) erc-server-users)))))
+
+(provide 'erc-common)
+
+;;; erc-common.el ends here
diff --git a/lisp/erc/erc-compat.el b/lisp/erc/erc-compat.el
index 8a00e711ac..03bd8f1352 100644
--- a/lisp/erc/erc-compat.el
+++ b/lisp/erc/erc-compat.el
@@ -156,6 +156,18 @@ erc-subseq
 		 (setq i (1+ i) start (1+ start)))
 	       res))))))
 
+
+;;;; Misc 29.1
+
+(defmacro erc-compat--with-memoization (table &rest forms)
+  (declare (indent defun))
+  (cond
+   ((fboundp 'with-memoization)
+    `(with-memoization ,table ,@forms)) ; 29.1
+   ((fboundp 'cl--generic-with-memoization)
+    `(cl--generic-with-memoization ,table ,@forms))
+   (t `(progn ,@forms))))
+
 (provide 'erc-compat)
 
 ;;; erc-compat.el ends here
diff --git a/lisp/erc/erc-goodies.el b/lisp/erc/erc-goodies.el
index 8fef23945d..59b5f01f23 100644
--- a/lisp/erc/erc-goodies.el
+++ b/lisp/erc/erc-goodies.el
@@ -29,10 +29,23 @@
 
 ;;; Code:
 
-(require 'erc)
-
 ;;; Imenu support
 
+(require 'erc-common)
+
+(defvar erc-controls-highlight-regexp)
+(defvar erc-controls-remove-regexp)
+(defvar erc-input-marker)
+(defvar erc-insert-marker)
+(defvar erc-server-process)
+(defvar erc-modules)
+(defvar erc-log-p)
+
+(declare-function erc-buffer-list "erc" (&optional predicate proc))
+(declare-function erc-error "erc" (&rest args))
+(declare-function erc-extract-command-from-line "erc" (line))
+(declare-function erc-beg-of-input-line "erc" nil)
+
 (defun erc-imenu-setup ()
   "Setup Imenu support in an ERC buffer."
   (setq-local imenu-create-index-function #'erc-create-imenu-index))
diff --git a/lisp/erc/erc-networks.el b/lisp/erc/erc-networks.el
index 2c8f8fb72b..667b0c3d76 100644
--- a/lisp/erc/erc-networks.el
+++ b/lisp/erc/erc-networks.el
@@ -39,8 +39,32 @@
 
 ;;; Code:
 
-(require 'erc)
 (eval-when-compile (require 'cl-lib))
+(require 'erc-common)
+
+(defvar erc--target)
+(defvar erc-insert-marker)
+(defvar erc-kill-buffer-hook)
+(defvar erc-kill-server-hook)
+(defvar erc-modules)
+(defvar erc-rename-buffers)
+(defvar erc-reuse-buffers)
+(defvar erc-server-announced-name)
+(defvar erc-server-connected)
+(defvar erc-server-parameters)
+(defvar erc-server-process)
+(defvar erc-session-server)
+
+(declare-function erc--default-target "erc" nil)
+(declare-function erc--get-isupport-entry "erc-backend" (key &optional single))
+(declare-function erc-buffer-filter "erc" (predicate &optional proc))
+(declare-function erc-current-nick "erc" nil)
+(declare-function erc-display-error-notice "erc" (parsed string))
+(declare-function erc-error "erc" (&rest args))
+(declare-function erc-get-buffer "erc" (target &optional proc))
+(declare-function erc-server-buffer "erc" nil)
+(declare-function erc-server-process-alive "erc-backend" (&optional buffer))
+(declare-function erc-set-active-buffer "erc" (buffer))
 
 ;; Variables
 
@@ -813,7 +837,7 @@ erc-networks--id-given
   (erc-networks--id-symbol nid))
 
 (cl-generic-define-context-rewriter erc-obsolete-var (var spec)
-  `((with-suppressed-warnings ((obsolete ,var)) ,var) ,spec))
+  `((with-suppressed-warnings ((obsolete ,var) (free-vars ,var)) ,var) ,spec))
 
 ;; As a catch-all, derive the symbol from the unquoted printed repr.
 (cl-defgeneric erc-networks--id-create (id)
diff --git a/lisp/erc/erc.el b/lisp/erc/erc.el
index db39e341b2..e0a4bd3001 100644
--- a/lisp/erc/erc.el
+++ b/lisp/erc/erc.el
@@ -60,6 +60,9 @@
 
 (load "erc-loaddefs" 'noerror 'nomessage)
 
+(require 'erc-networks)
+(require 'erc-goodies)
+(require 'erc-backend)
 (require 'cl-lib)
 (require 'format-spec)
 (require 'pp)
@@ -69,8 +72,6 @@
 (require 'iso8601)
 (eval-when-compile (require 'subr-x))
 
-(require 'erc-compat)
-
 (defconst erc-version "5.4.1"
   "This version of ERC.")
 
@@ -132,29 +133,12 @@ erc-scripts
   "Running scripts at startup and with /LOAD."
   :group 'erc)
 
-;; Defined in erc-backend
-(defvar erc--server-last-reconnect-count)
-(defvar erc--server-reconnecting)
-(defvar erc-channel-members-changed-hook)
-(defvar erc-network)
-(defvar erc-networks--id)
-(defvar erc-server-367-functions)
-(defvar erc-server-announced-name)
-(defvar erc-server-connect-function)
-(defvar erc-server-connected)
-(defvar erc-server-current-nick)
-(defvar erc-server-lag)
-(defvar erc-server-last-sent-time)
-(defvar erc-server-process)
-(defvar erc-server-quitting)
-(defvar erc-server-reconnect-count)
-(defvar erc-server-reconnecting)
-(defvar erc-session-client-certificate)
-(defvar erc-session-connector)
-(defvar erc-session-port)
-(defvar erc-session-server)
-(defvar erc-session-user-full-name)
-(defvar erc-session-username)
+;; Forward declarations
+(defvar erc-message-parsed)
+
+(defvar tabbar--local-hlf)
+(defvar motif-version-string)
+(defvar gtk-version-string)
 
 ;; tunable connection and authentication parameters
 
@@ -349,9 +333,6 @@ erc-channel-hide-list
   :group 'erc-ignore
   :type 'erc-message-type)
 
-(defvar-local erc-session-password nil
-  "The password used for the current session.")
-
 (defcustom erc-disconnected-hook nil
   "Run this hook with arguments (NICK IP REASON) when disconnected.
 This happens before automatic reconnection.  Note, that
@@ -436,69 +417,14 @@ erc--casemapping-rfc1459-strict
    '((?\[ . ?\{) (?\] . ?\}) (?\\ . ?\|))
    (mapcar (lambda (c) (cons c (+ c 32))) "ABCDEFGHIJKLMNOPQRSTUVWXYZ")))
 
-(defun erc-downcase (string)
-  "Return a downcased copy of STRING with properties.
-Use the CASEMAPPING ISUPPORT parameter to determine the style."
-  (let* ((mapping (erc--get-isupport-entry 'CASEMAPPING 'single))
-         (inhibit-read-only t))
-    (if (equal mapping "ascii")
-        (downcase string)
-      (with-temp-buffer
-        (insert string)
-        (translate-region (point-min) (point-max)
-                          (if (equal mapping "rfc1459-strict")
-                              erc--casemapping-rfc1459-strict
-                            erc--casemapping-rfc1459))
-        (buffer-string)))))
-
-(defmacro erc-with-server-buffer (&rest body)
-  "Execute BODY in the current ERC server buffer.
-If no server buffer exists, return nil."
-  (declare (indent 0) (debug (body)))
-  (let ((buffer (make-symbol "buffer")))
-    `(let ((,buffer (erc-server-buffer)))
-       (when (buffer-live-p ,buffer)
-         (with-current-buffer ,buffer
-           ,@body)))))
-
-(cl-defstruct (erc-server-user (:type vector) :named)
-  ;; User data
-  nickname host login full-name info
-  ;; Buffers
-  ;;
-  ;; This is an alist of the form (BUFFER . CHANNEL-DATA), where
-  ;; CHANNEL-DATA is either nil or an erc-channel-user struct.
-  (buffers nil)
-  )
-
-(cl-defstruct (erc-channel-user (:type vector) :named)
-  voice halfop op admin owner
-  ;; Last message time (in the form of the return value of
-  ;; (current-time)
-  ;;
-  ;; This is useful for ordered name completion.
-  (last-message-time nil))
-
-(define-inline erc-get-channel-user (nick)
-  "Find NICK in the current buffer's `erc-channel-users' hash table."
-  (inline-quote (gethash (erc-downcase ,nick) erc-channel-users)))
-
-(define-inline erc-get-server-user (nick)
-  "Find NICK in the current server's `erc-server-users' hash table."
-  (inline-letevals (nick)
-    (inline-quote (erc-with-server-buffer
-		    (gethash (erc-downcase ,nick) erc-server-users)))))
-
-(define-inline erc-add-server-user (nick user)
+(defun erc-add-server-user (nick user)
   "This function is for internal use only.
 
 Adds USER with nickname NICK to the `erc-server-users' hash table."
-  (inline-letevals (nick user)
-    (inline-quote
-     (erc-with-server-buffer
-       (puthash (erc-downcase ,nick) ,user erc-server-users)))))
+  (erc-with-server-buffer
+    (puthash (erc-downcase nick) user erc-server-users)))
 
-(define-inline erc-remove-server-user (nick)
+(defun erc-remove-server-user (nick)
   "This function is for internal use only.
 
 Removes the user with nickname NICK from the `erc-server-users'
@@ -506,10 +432,8 @@ erc-remove-server-user
 `erc-channel-users' lists of other buffers.
 
 See also: `erc-remove-user'."
-  (inline-letevals (nick)
-    (inline-quote
-     (erc-with-server-buffer
-       (remhash (erc-downcase ,nick) erc-server-users)))))
+  (erc-with-server-buffer
+    (remhash (erc-downcase nick) erc-server-users)))
 
 (defun erc-change-user-nickname (user new-nick)
   "This function is for internal use only.
@@ -580,55 +504,45 @@ erc-remove-channel-users
              erc-channel-users)
     (clrhash erc-channel-users)))
 
-(define-inline erc-channel-user-owner-p (nick)
+(defun erc-channel-user-owner-p (nick)
   "Return non-nil if NICK is an owner of the current channel."
-  (inline-letevals (nick)
-    (inline-quote
-     (and ,nick
-	  (hash-table-p erc-channel-users)
-	  (let ((cdata (erc-get-channel-user ,nick)))
-	    (and cdata (cdr cdata)
-		 (erc-channel-user-owner (cdr cdata))))))))
-
-(define-inline erc-channel-user-admin-p (nick)
+  (and nick
+       (hash-table-p erc-channel-users)
+       (let ((cdata (erc-get-channel-user nick)))
+         (and cdata (cdr cdata)
+              (erc-channel-user-owner (cdr cdata))))))
+
+(defun erc-channel-user-admin-p (nick)
   "Return non-nil if NICK is an admin in the current channel."
-  (inline-letevals (nick)
-    (inline-quote
-     (and ,nick
+  (and nick
        (hash-table-p erc-channel-users)
-       (let ((cdata (erc-get-channel-user ,nick)))
+       (let ((cdata (erc-get-channel-user nick)))
          (and cdata (cdr cdata)
-              (erc-channel-user-admin (cdr cdata))))))))
+              (erc-channel-user-admin (cdr cdata))))))
 
-(define-inline erc-channel-user-op-p (nick)
+(defun erc-channel-user-op-p (nick)
   "Return non-nil if NICK is an operator in the current channel."
-  (inline-letevals (nick)
-    (inline-quote
-     (and ,nick
+  (and nick
        (hash-table-p erc-channel-users)
-       (let ((cdata (erc-get-channel-user ,nick)))
+       (let ((cdata (erc-get-channel-user nick)))
          (and cdata (cdr cdata)
-              (erc-channel-user-op (cdr cdata))))))))
+              (erc-channel-user-op (cdr cdata))))))
 
-(define-inline erc-channel-user-halfop-p (nick)
+(defun erc-channel-user-halfop-p (nick)
   "Return non-nil if NICK is a half-operator in the current channel."
-  (inline-letevals (nick)
-    (inline-quote
-     (and ,nick
+  (and nick
        (hash-table-p erc-channel-users)
-       (let ((cdata (erc-get-channel-user ,nick)))
+       (let ((cdata (erc-get-channel-user nick)))
          (and cdata (cdr cdata)
-              (erc-channel-user-halfop (cdr cdata))))))))
+              (erc-channel-user-halfop (cdr cdata))))))
 
-(define-inline erc-channel-user-voice-p (nick)
+(defun erc-channel-user-voice-p (nick)
   "Return non-nil if NICK has voice in the current channel."
-  (inline-letevals (nick)
-    (inline-quote
-     (and ,nick
+  (and nick
        (hash-table-p erc-channel-users)
-       (let ((cdata (erc-get-channel-user ,nick)))
+       (let ((cdata (erc-get-channel-user nick)))
          (and cdata (cdr cdata)
-              (erc-channel-user-voice (cdr cdata))))))))
+              (erc-channel-user-voice (cdr cdata))))))
 
 (defun erc-get-channel-user-list ()
   "Return a list of users in the current channel.
@@ -1377,96 +1291,6 @@ erc-debug-log-file
 
 (defvar-local erc-dbuf nil)
 
-(defmacro define-erc-module (name alias doc enable-body disable-body
-                                  &optional local-p)
-  "Define a new minor mode using ERC conventions.
-Symbol NAME is the name of the module.
-Symbol ALIAS is the alias to use, or nil.
-DOC is the documentation string to use for the minor mode.
-ENABLE-BODY is a list of expressions used to enable the mode.
-DISABLE-BODY is a list of expressions used to disable the mode.
-If LOCAL-P is non-nil, the mode will be created as a buffer-local
-mode, rather than a global one.
-
-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.
-
-Example:
-
-  ;;;###autoload(autoload \\='erc-replace-mode \"erc-replace\")
-  (define-erc-module replace nil
-    \"This mode replaces incoming text according to `erc-replace-alist'.\"
-    ((add-hook \\='erc-insert-modify-hook
-               #\\='erc-replace-insert))
-    ((remove-hook \\='erc-insert-modify-hook
-                  #\\='erc-replace-insert)))"
-  (declare (doc-string 3) (indent defun))
-  (let* ((sn (symbol-name name))
-         (mode (intern (format "erc-%s-mode" (downcase sn))))
-         (group (intern (format "erc-%s" (downcase sn))))
-         (enable (intern (format "erc-%s-enable" (downcase sn))))
-         (disable (intern (format "erc-%s-disable" (downcase sn)))))
-    `(progn
-       (define-minor-mode
-        ,mode
-        ,(format "Toggle ERC %S mode.
-With a prefix argument ARG, enable %s if ARG is positive,
-and disable it otherwise.  If called from Lisp, enable the mode
-if ARG is omitted or nil.
-%s" name name doc)
-        ;; FIXME: We don't know if this group exists, so this `:group' may
-        ;; actually just silence a valid warning about the fact that the var
-        ;; is not associated with any group.
-        :global ,(not local-p) :group (quote ,group)
-        (if ,mode
-            (,enable)
-          (,disable)))
-       (defun ,enable ()
-         ,(format "Enable ERC %S mode."
-                  name)
-         (interactive)
-         (add-to-list 'erc-modules (quote ,name))
-         (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)
-       ,(when (and alias (not (eq name alias)))
-          `(defalias
-             ',(intern
-                (format "erc-%s-mode"
-                        (downcase (symbol-name alias))))
-             #',mode))
-       ;; For find-function and find-variable.
-       (put ',mode    'definition-name ',name)
-       (put ',enable  'definition-name ',name)
-       (put ',disable 'definition-name ',name))))
-
-;; The rationale for favoring inheritance here (nicer dispatch) is
-;; kinda flimsy since there aren't yet any actual methods.
-
-(cl-defstruct erc--target
-  (string "" :type string :documentation "Received name of target.")
-  (symbol nil :type symbol :documentation "Case-mapped name as symbol."))
-
-;; These should probably take on a `joined' field to track joinedness,
-;; which should be toggled by `erc-server-JOIN', `erc-server-PART',
-;; etc.  Functions like `erc--current-buffer-joined-p' (bug#48598) may
-;; find it useful.
-
-(cl-defstruct (erc--target-channel (:include erc--target)))
-
-(cl-defstruct (erc--target-channel-local (:include erc--target-channel)))
-
-;; At some point, it may make sense to add a query type with an
-;; account field, which may help support reassociation across
-;; reconnects and nick changes (likely requires v3 extensions).
-
 (defun erc--target-from-string (string)
   "Construct an `erc--target' variant from STRING."
   (funcall (if (erc-channel-p string)
@@ -1516,12 +1340,6 @@ erc-once-with-server-event
     (add-hook hook fun nil t)
     fun))
 
-(define-inline erc-log (string)
-  "Logs STRING if logging is on (see `erc-log-p')."
-  (inline-quote
-   (when erc-log-p
-     (erc-log-aux ,string))))
-
 (defun erc-server-buffer ()
   "Return the server buffer for the current buffer's process.
 The buffer-local variable `erc-server-process' is used to find
@@ -1577,29 +1395,7 @@ erc-ison-p
                    (if erc-online-p "" "not "))
         erc-online-p))))
 
-(defun erc-log-aux (string)
-  "Do the debug logging of STRING."
-  (let ((cb (current-buffer))
-        (point 1)
-        (was-eob nil)
-        (session-buffer (erc-server-buffer)))
-    (if session-buffer
-        (progn
-          (set-buffer session-buffer)
-          (if (not (and erc-dbuf (bufferp erc-dbuf) (buffer-live-p erc-dbuf)))
-              (progn
-                (setq erc-dbuf (get-buffer-create
-                                (concat "*ERC-DEBUG: "
-                                        erc-session-server "*")))))
-          (set-buffer erc-dbuf)
-          (setq point (point))
-          (setq was-eob (eobp))
-          (goto-char (point-max))
-          (insert (concat "** " string "\n"))
-          (if was-eob (goto-char (point-max))
-            (goto-char point))
-          (set-buffer cb))
-      (message "ERC: ** %s" string))))
+
 
 ;; Last active buffer, to print server messages in the right place
 
@@ -1841,40 +1637,6 @@ erc-member-ignore-case
           (throw 'result list)
         (setq list (cdr list))))))
 
-(defmacro erc-with-buffer (spec &rest body)
-  "Execute BODY in the buffer associated with SPEC.
-
-SPEC should have the form
-
- (TARGET [PROCESS])
-
-If TARGET is a buffer, use it.  Otherwise, use the buffer
-matching TARGET in the process specified by PROCESS.
-
-If PROCESS is nil, use the current `erc-server-process'.
-See `erc-get-buffer' for details.
-
-See also `with-current-buffer'.
-
-\(fn (TARGET [PROCESS]) BODY...)"
-  (declare (indent 1) (debug ((form &optional form) body)))
-  (let ((buf (make-symbol "buf"))
-        (proc (make-symbol "proc"))
-        (target (make-symbol "target"))
-        (process (make-symbol "process")))
-    `(let* ((,target ,(car spec))
-            (,process ,(cadr spec))
-            (,buf (if (bufferp ,target)
-                      ,target
-                    (let ((,proc (or ,process
-                                     (and (processp erc-server-process)
-                                          erc-server-process))))
-                      (if (and ,target ,proc)
-                          (erc-get-buffer ,target ,proc))))))
-       (when (buffer-live-p ,buf)
-         (with-current-buffer ,buf
-           ,@body)))))
-
 (defun erc-get-buffer (target &optional proc)
   "Return the buffer matching TARGET in the process PROC.
 If PROC is not supplied, all processes are searched."
@@ -1921,18 +1683,6 @@ erc-buffer-list
     (setq predicate (lambda () t)))
   (erc-buffer-filter predicate proc))
 
-(defmacro erc-with-all-buffers-of-server (process pred &rest forms)
-  "Execute FORMS in all buffers which have same process as this server.
-FORMS will be evaluated in all buffers having the process PROCESS and
-where PRED matches or in all buffers of the server process if PRED is
-nil."
-  (declare (indent 1) (debug (form form body)))
-  (macroexp-let2 nil pred pred
-    `(erc-buffer-filter (lambda ()
-                          (when (or (not ,pred) (funcall ,pred))
-                            ,@forms))
-                        ,process)))
-
 (define-obsolete-function-alias 'erc-iswitchb #'erc-switch-to-buffer "25.1")
 (defun erc--switch-to-buffer (&optional arg)
   (read-buffer "Switch to ERC buffer: "
@@ -2877,8 +2627,6 @@ erc-lurker-cleanup-interval
 consumption of lurker state during long Emacs sessions and/or ERC
 sessions with large numbers of incoming PRIVMSGs.")
 
-(defvar erc-message-parsed)
-
 (defun erc-lurker-update-status (_message)
   "Update `erc-lurker-state' if necessary.
 
@@ -4090,9 +3838,6 @@ erc-cmd-SERVER
   t)
 (put 'erc-cmd-SERVER 'process-not-needed t)
 
-(defvar motif-version-string)
-(defvar gtk-version-string)
-
 (defun erc-cmd-SV ()
   "Say the current ERC and Emacs version into channel."
   (erc-send-message (format "I'm using ERC %s with GNU Emacs %s (%s%s)%s."
@@ -5349,6 +5094,12 @@ erc-parse-prefix
           (setq i (1+ i)))
         alist))))
 
+(defcustom erc-channel-members-changed-hook nil
+  "This hook is called every time the variable `channel-members' changes.
+The buffer where the change happened is current while this hook is called."
+  :group 'erc-hooks
+  :type 'hook)
+
 (defun erc-channel-receive-names (names-string)
   "This function is for internal use only.
 
@@ -5392,13 +5143,6 @@ erc-channel-receive-names
              name name t voice halfop op admin owner)))))
     (run-hooks 'erc-channel-members-changed-hook)))
 
-
-(defcustom erc-channel-members-changed-hook nil
-  "This hook is called every time the variable `channel-members' changes.
-The buffer where the change happened is current while this hook is called."
-  :group 'erc-hooks
-  :type 'hook)
-
 (defun erc-update-user-nick (nick &optional new-nick
                                   host login full-name info)
   "Update the stored user information for the user with nickname NICK.
@@ -6008,12 +5752,6 @@ erc-user-input
 (defvar erc-command-regexp "^/\\([A-Za-z']+\\)\\(\\s-+.*\\|\\s-*\\)$"
   "Regular expression used for matching commands in ERC.")
 
-(cl-defstruct erc-input
-  string insertp sendp)
-
-(cl-defstruct (erc--input-split (:include erc-input))
-  lines cmdp)
-
 (defun erc--discard-trailing-multiline-nulls (state)
   "Ensure last line of STATE's string is non-null.
 But only when `erc-send-whitespace-lines' is non-nil.  STATE is
@@ -6957,9 +6695,6 @@ erc-format-lag-time
           (t ""))))
 
 ;; erc-goodies is required at end of this file.
-(declare-function erc-controls-strip "erc-goodies" (str))
-
-(defvar tabbar--local-hlf)
 
 ;; FIXME when 29.1 is cut and `format-spec' is added to ELPA Compat,
 ;; remove the function invocations from the spec form below.
@@ -7448,12 +7183,4 @@ erc-handle-irc-url
 
 (provide 'erc)
 
-(require 'erc-backend)
-
-;; Deprecated. We might eventually stop requiring the goodies automatically.
-;; IMPORTANT: This require must appear _after_ the above (provide 'erc) to
-;; avoid a recursive require error when byte-compiling the entire package.
-(require 'erc-goodies)
-(require 'erc-networks)
-
 ;;; erc.el ends here
diff --git a/test/lisp/erc/erc-networks-tests.el b/test/lisp/erc/erc-networks-tests.el
index 66a334b709..32bdfa11ff 100644
--- a/test/lisp/erc/erc-networks-tests.el
+++ b/test/lisp/erc/erc-networks-tests.el
@@ -20,7 +20,7 @@
 ;;; Code:
 
 (require 'ert-x) ; cl-lib
-(require 'erc-networks)
+(require 'erc)
 
 (defun erc-networks-tests--create-dead-proc (&optional buf)
   (let ((p (start-process "true" (or buf (current-buffer)) "true")))
diff --git a/test/lisp/erc/erc-tests.el b/test/lisp/erc/erc-tests.el
index b2ed29e80e..c88dd9888d 100644
--- a/test/lisp/erc/erc-tests.el
+++ b/test/lisp/erc/erc-tests.el
@@ -24,7 +24,6 @@
 (require 'ert-x)
 (require 'erc)
 (require 'erc-ring)
-(require 'erc-networks)
 
 (ert-deftest erc--read-time-period ()
   (cl-letf (((symbol-function 'read-string) (lambda (&rest _) "")))
@@ -48,27 +47,6 @@ erc--read-time-period
   (cl-letf (((symbol-function 'read-string) (lambda (&rest _) "1d")))
     (should (equal (erc--read-time-period "foo: ") 86400))))
 
-(ert-deftest erc--meta--backend-dependencies ()
-  (with-temp-buffer
-    (insert-file-contents-literally
-     (concat (file-name-sans-extension (symbol-file 'erc)) ".el"))
-    (let ((beg (search-forward ";; Defined in erc-backend"))
-          (end (search-forward "\n\n"))
-          vars)
-      (save-excursion
-        (save-restriction
-          (narrow-to-region beg end)
-          (goto-char (point-min))
-          (with-syntax-table lisp-data-mode-syntax-table
-            (condition-case _
-                (while (push (cadr (read (current-buffer))) vars))
-              (end-of-file)))))
-      (should (= (point) end))
-      (dolist (var vars)
-        (setq var (concat "\\_<" (symbol-name var) "\\_>"))
-        (ert-info (var)
-          (should (save-excursion (search-forward-regexp var nil t))))))))
-
 (ert-deftest erc-with-all-buffers-of-server ()
   (let (proc-exnet
         proc-onet
-- 
2.37.3


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

From 05e5bdd488a309b70ca140fc620ad48023befa24 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 2/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.  `defcustom'
not used because library doesn't define any others.
(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.37.3


[-- 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: 11897 bytes --]

From 03812d6e956e83538db5223af473eec621b2f2dd 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

* lisp/erc/erc.el (erc-migrate-modules): add some missing mappings.
(erc--module-name-migrations, erc--features-to-modules,
erc--modules-to-features): add alists to support simplified
module-name migrations.
(erc-update-modules): Change return value to a list of minor-mode
commands for local modules that need deferred activation, if any.  Use
`custom-variable-p' to detect flavor.  Currently, all modules are
global, meaning 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.

* lisp/erc/erc-common.el (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.  It's believed
that the original authors wanted this functionality.
(erc--normalize-module-symbol): Add helper for `erc-migrate-modules'.

* lisp/erc/erc-goodies.el: Require cl-lib. Bug#57955.
---
 lisp/erc/erc-common.el     | 26 ++++++++----
 lisp/erc/erc-goodies.el    |  1 +
 lisp/erc/erc.el            | 83 +++++++++++++++++++++++++-------------
 test/lisp/erc/erc-tests.el | 47 +++++++++++++++++++++
 4 files changed, 121 insertions(+), 36 deletions(-)

diff --git a/lisp/erc/erc-common.el b/lisp/erc/erc-common.el
index d8aac36eab..90ea56108d 100644
--- a/lisp/erc/erc-common.el
+++ b/lisp/erc/erc-common.el
@@ -28,6 +28,7 @@
 
 (defvar erc--casemapping-rfc1459)
 (defvar erc--casemapping-rfc1459-strict)
+(defvar erc--module-name-migrations)
 (defvar erc-channel-users)
 (defvar erc-dbuf)
 (defvar erc-log-p)
@@ -85,6 +86,10 @@ erc--target
   (contents "" :type string)
   (tags '() :type list))
 
+(defun erc--normalize-module-symbol (module)
+  "Canonicalize symbol MODULE for `erc-modules'."
+  (or (cdr (assq module erc--module-name-migrations)) module))
+
 (defmacro define-erc-module (name alias doc enable-body disable-body
                                   &optional local-p)
   "Define a new minor mode using ERC conventions.
@@ -98,7 +103,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:
 
@@ -134,16 +141,21 @@ 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 (erc--normalize-module-symbol ',name) erc-modules))
+         (when (or ,(not local-p) (eq major-mode 'erc-mode))
+           (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 (erc--normalize-module-symbol ',name)
+                                   erc-modules)))
+         (when (or ,(not local-p) ,mode)
+           (setq ,mode nil)
+           ,@disable-body))
        ,(when (and alias (not (eq name alias)))
           `(defalias
              ',(intern
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 e0a4bd3001..23649a5620 100644
--- a/lisp/erc/erc.el
+++ b/lisp/erc/erc.el
@@ -1780,14 +1780,36 @@ erc-default-nicks
 (defvar-local erc-nick-change-attempt-count 0
   "Used to keep track of how many times an attempt at changing nick is made.")
 
+(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.")
+
+(defconst erc--modules-to-features
+  (cl-loop for (feature . names) in erc--features-to-modules
+           append (mapcar (lambda (name) (cons name feature)) names))
+  "Migration alist mapping a module's name to 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-migrate-modules (mods)
   "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
@@ -1866,27 +1888,22 @@ erc-modules
   :group 'erc)
 
 (defun erc-update-modules ()
-  "Run this to enable erc-foo-mode for all modules in `erc-modules'."
-  (let (req)
+  "Enable global minor mode for all global modules in `erc-modules'.
+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.6."
+  (let (local-modules)
     (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))
+      (require (or (alist-get mod erc--modules-to-features)
+                   (intern (concat "erc-" (symbol-name mod))))
+               nil 'noerror) ; some modules don't have a corresponding feature
       (let ((sym (intern-soft (concat "erc-" (symbol-name mod) "-mode"))))
-        (if (fboundp sym)
+        (unless (and sym (fboundp sym))
+          (error "`%s' is not a known ERC module" mod))
+        (if (custom-variable-p sym)
             (funcall sym 1)
-          (error "`%s' is not a known ERC module" mod))))))
+          (push sym local-modules))))
+    local-modules))
 
 (defun erc-setup-buffer (buffer)
   "Consults `erc-join-buffer' to find out how to display `BUFFER'."
@@ -1942,18 +1959,22 @@ 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))))
     (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))
+
+    (delay-mode-hooks (erc-mode))
+
+    (setq erc-server-reconnect-count old-recon-count)
+
     (when (setq erc-server-connected (not connect))
       (setq erc-server-announced-name
             (buffer-local-value 'erc-server-announced-name old-buffer)))
@@ -2016,6 +2037,12 @@ erc-open
     (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))
@@ -2027,8 +2054,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..4646c35e25 100644
--- a/test/lisp/erc/erc-tests.el
+++ b/test/lisp/erc/erc-tests.el
@@ -953,4 +953,51 @@ 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 '(fake-foo fake-bar)))
+    (cl-letf (((symbol-function 'require)
+               (lambda (s &rest _) (push s calls)))
+              ((symbol-function 'erc-fake-foo-mode)
+               (lambda (n) (push (cons 'fake-foo n) calls)))
+              ;; Here, foo is a global module (minor mode)
+              ((get 'erc-fake-foo-mode 'standard-value) #'ignore)
+              ((symbol-function 'erc-fake-bar-mode)
+               (lambda (n) (push (cons 'fake-bar n) calls)))
+              ((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)))
+              ((symbol-function 'erc-completion-mode)
+               (lambda (n) (push (cons 'completion n) calls)))
+              ((get 'erc-completion-mode 'standard-value) #'ignore))
+
+      (ert-info ("Locals")
+        (should (equal (erc-update-modules)
+                       '(erc-fake-bar-mode)))
+        ;; Bar still required
+        (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)) ; no locals
+        (should (equal (nreverse calls)
+                       '(erc-pcomplete
+                         (completion . 1)
+                         erc-join
+                         (autojoin . 1)
+                         erc-networks
+                         (networks . 1))))
+        (setq calls nil)))))
+
 ;;; erc-tests.el ends here
-- 
2.37.3


[-- 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 cbc776566ee5ed177ee1a923300143695c6d71fc 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 026b34849a..fee29e7d05 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.37.3


[-- 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: 66477 bytes --]

From 27242c8becae2962972c2a6cfdf4de44d276184b Mon Sep 17 00:00:00 2001
From: "F. Jason Park" <jp@neverwas.me>
Date: Sun, 18 Sep 2022 01:37:13 -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.
* 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                             | 138 +++++-
 lisp/erc/erc-compat.el                        | 104 +++++
 lisp/erc/erc-sasl.el                          | 396 ++++++++++++++++++
 lisp/erc/erc.el                               |   1 +
 test/lisp/erc/erc-sasl-tests.el               | 302 +++++++++++++
 test/lisp/erc/erc-scenarios-sasl.el           | 161 +++++++
 test/lisp/erc/resources/sasl/external.eld     |  33 ++
 test/lisp/erc/resources/sasl/plain-failed.eld |  16 +
 test/lisp/erc/resources/sasl/plain.eld        |  35 ++
 test/lisp/erc/resources/sasl/scram-sha-1.eld  |  47 +++
 .../lisp/erc/resources/sasl/scram-sha-256.eld |  47 +++
 11 files changed, 1279 insertions(+), 1 deletion(-)
 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 3db83197f9..80b4171cdb 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.
 
@@ -478,6 +479,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
@@ -525,6 +530,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
@@ -842,6 +848,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
@@ -854,7 +861,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
@@ -915,6 +923,134 @@ 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.  This means invoking
+@code{erc-sasl-mode} manually or calling @code{erc-update-modules}
+won't do any good.  Instead, simply add @code{sasl} to
+@code{erc-modules} (or @code{let}-bind it while calling
+@code{erc-tls}), and SASL will be enabled for the current connection.
+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/lisp/erc/erc-compat.el b/lisp/erc/erc-compat.el
index 03bd8f1352..bc3e1dcfc6 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, advising `base64-encode-string' won't work
+;; because the byte compiler precomputes the result when all inputs
+;; are constants, as they are in the unpatched 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..f36a305247
--- /dev/null
+++ b/lisp/erc/erc-sasl.el
@@ -0,0 +1,396 @@
+;;; 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 many others that have
+;; surfaced over the years, the first possibly being:
+;;
+;; 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.
+;;
+;; - Implement a proxy mechanism that chooses the strongest available
+;;   mechanism for you.  Requires CAP 3.2 (see bug#49860).
+
+;;; Code:
+(require 'erc)
+(require 'rx)
+(require 'sasl)
+(require 'sasl-scram-rfc)
+(require 'sasl-scram-sha256 nil t)
+
+(defgroup erc-sasl nil
+  "SASL for ERC."
+  :group 'erc
+  :package-version '(ERC . "5.4")) ; FIXME increment on next release
+
+(defcustom erc-sasl-mechanism nil
+  "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 nil)
+                 (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 uses it unconditionally for most
+mechanisms (see below).  As a special case, when the value is a
+non-nil symbol, ERC uses it as the value of the `:host' field in
+an auth-source query, provided `erc-sasl-auth-source-function' is
+set to a function.  When nil, ERC will try a non-nil \"session
+password\", likely one given as the `:password' argument to
+`erc-tls'.  As a last resort, ERC will prompt the user for input.
+
+Note that when `erc-sasl-mechanism' is set to
+`ecdsa-nist256p-challenge', this option should hold the file name
+of the key, which is typically in PEM format."
+  :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 (const 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)
+
+;; 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 (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))
+
+(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 ()
+  (setq erc-sasl--state (make-erc-sasl--state)
+        erc-sasl--options `((user . ,erc-sasl-user)
+                            (password . ,erc-sasl-password)
+                            (mechanism . ,erc-sasl-mechanism)
+                            (authzid . ,erc-sasl-authzid))))
+
+(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 mechanism: %s" mech))
+         (erc-error "Unknown mechanism: %s" mech))
+       (setf (erc-sasl--state-client erc-sasl--state) client))))
+  ((remove-hook 'erc-server-AUTHENTICATE-functions
+                #'erc-sasl--authenticate-handler t)
+   (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")))
+  (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."
+  (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))))
+
+(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 23649a5620..994504d72e 100644
--- a/lisp/erc/erc.el
+++ b/lisp/erc/erc.el
@@ -1871,6 +1871,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..112303baf5
--- /dev/null
+++ b/test/lisp/erc/erc-sasl-tests.el
@@ -0,0 +1,302 @@
+;;; erc-sasl-tests.el --- Tests for erc-sasl.  -*- lexical-binding:t -*-
+
+;; Copyright (C) 2020-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 ()
+  (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))
+          ;; FIXME this is dumb
+          (should (<= 68 (length (sasl-step-data step)) 72))))
+      (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..3ff7cc805d
--- /dev/null
+++ b/test/lisp/erc/erc-scenarios-sasl.el
@@ -0,0 +1,161 @@
+;;; 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")
+       (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"))))))
+
+(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)
+       (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)
+       (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)
+       (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/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..9c6ce3feeb
--- /dev/null
+++ b/test/lisp/erc/resources/sasl/plain.eld
@@ -0,0 +1,35 @@
+;; -*- 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."))
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.37.3


^ permalink raw reply related	[flat|nested] 54+ messages in thread

* bug#29108: 25.3; ERC SASL support
       [not found]         ` <87k04m4th8.fsf@neverwas.me>
@ 2022-11-08 14:10           ` J.P.
       [not found]           ` <87o7thlepf.fsf@neverwas.me>
  1 sibling, 0 replies; 54+ messages in thread
From: J.P. @ 2022-11-08 14:10 UTC (permalink / raw)
  To: 29108; +Cc: emacs-erc, bandali

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

> I'd really like this thing to see some daylight, so if anyone can find
> the time to take a quick look, please do (Cc. bandali). I think most
> folks would agree that an ERC without SASL in Emacs 29 would be less
> than ideal.

In the interest of keeping things from stagnating further, I'd like to
move development on this to HEAD so people can try it and provide
feedback prior to Emacs 29 being cut. If there are any objections to
that, please raise them before Saturday the 12th of November, 2022.
Thanks.





^ permalink raw reply	[flat|nested] 54+ messages in thread

* bug#29108: 25.3; ERC SASL support
       [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-13 15:36             ` J.P.
       [not found]             ` <87o7taoohd.fsf@neverwas.me>
  2 siblings, 2 replies; 54+ messages in thread
From: Akib Azmain Turja via Bug reports for GNU Emacs, the Swiss army knife of text editors @ 2022-11-09  4:08 UTC (permalink / raw)
  To: J.P.; +Cc: bandali, 29108, emacs-erc

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

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

> "J.P." <jp@neverwas.me> writes:
>
> In the interest of keeping things from stagnating further, I'd like to
> move development on this to HEAD so people can try it and provide
> feedback prior to Emacs 29 being cut. If there are any objections to
> that, please raise them before Saturday the 12th of November, 2022.
> Thanks.

What's special about November 12th?

-- 
Akib Azmain Turja, GPG key: 70018CE5819F17A3BBA666AFE74F0EFA922AE7F5
Fediverse: akib@hostux.social
Codeberg: akib
emailselfdefense.fsf.org | "Nothing can be secure without encryption."

[-- Attachment #2: signature.asc --]
[-- Type: application/pgp-signature, Size: 832 bytes --]

^ permalink raw reply	[flat|nested] 54+ messages in thread

* bug#29108: 25.3; ERC SASL support
  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>
  1 sibling, 0 replies; 54+ messages in thread
From: J.P. @ 2022-11-09 13:49 UTC (permalink / raw)
  To: Akib Azmain Turja; +Cc: bandali, 29108, emacs-erc

Hi Akib,

Akib Azmain Turja <akib@disroot.org> writes:

> What's special about November 12th?

It's just some arbitrary date.

But ...

you can make it somewhat special by volunteering to review some patches
ASAP, which I'll then try to improve upon by said date (or thereabouts).

Also, I've been trying to get a hold of you regarding another bug:

  https://debbugs.gnu.org/cgi/bugreport.cgi?bug=58985

Based on your email to the devel mailing list, it seems you have some
experience (or at least opinions and interest) in that area. Please take
a look if you haven't already, and then volunteer to help get some
improvements in tree before Emacs 29 is cut.

Also, I've noticed someone on Libera with the nick akib pop into #erc on
occasion. Please don't hesitate to say hi if that was in fact you.

Thanks,
J.P.





^ permalink raw reply	[flat|nested] 54+ messages in thread

* bug#29108: 25.3; ERC SASL support
       [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>
  1 sibling, 0 replies; 54+ messages in thread
From: Akib Azmain Turja via Bug reports for GNU Emacs, the Swiss army knife of text editors @ 2022-11-09 17:50 UTC (permalink / raw)
  To: J.P.; +Cc: Adam Porter, 29108, bandali, emacs-erc

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

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

> Hi Akib,
>
> Akib Azmain Turja <akib@disroot.org> writes:
>
>> What's special about November 12th?
>
> It's just some arbitrary date.
>
> But ...
>
> you can make it somewhat special by volunteering to review some patches
> ASAP, which I'll then try to improve upon by said date (or thereabouts).
>
> Also, I've been trying to get a hold of you regarding another bug:

Just out of curiosity, why me?  Is it because I use pass?

>
>   https://debbugs.gnu.org/cgi/bugreport.cgi?bug=58985

Hmm, looks like I was CC'ed, but I didn't notice.  :(
I will try to review the patches.

Yeah, auth-source-pass is indeed just a hack, a terrible one.  I needed
to apply a few advices on that auth-source-pass make it work at least
reasonably well for me uses.

Fix auth-source-pass will require the existing API of it (which the
Emacs package "pass" uses) and may even need complete rewrite.

But Adam Porter (CC'ing), the maintainer of ement.el (available on GNU
ELPA), a Matrix client, claims that auth-source is from the dark side[1]
and refused to support it claiming it's not suitable for general use[2].

>
> Based on your email to the devel mailing list, it seems you have some
> experience (or at least opinions and interest) in that area. Please take
> a look if you haven't already, and then volunteer to help get some
> improvements in tree before Emacs 29 is cut.

Thanks for the reminder, otherwise I would have just missed that
discussion.

Anyway, when the Emacs 29 branch is going to cut?  I have some changes
(semantic highlighting of code) waiting to merge to Eglot (now in core)
for about six months.  I have got the paperwork to sign about ten days
ago, but due to some (personal) problems it's taking some time to sign
it.  I really want to make it into Emacs 29, instead of 30.

>
> Also, I've noticed someone on Libera with the nick akib pop into #erc on
> occasion. Please don't hesitate to say hi if that was in fact you.

Yeah, that's indeed me.

>
> Thanks,
> J.P.
>
>
>


Footnotes:
[1]  https://libreddit.de/r/emacs/comments/8lvda6/is_authsource_from_the_dark_side/
     https://old.reddit.com/r/emacs/comments/8lvda6/is_authsource_from_the_dark_side/

[2]  https://github.com/alphapapa/ement.el/issues/109

-- 
Akib Azmain Turja, GPG key: 70018CE5819F17A3BBA666AFE74F0EFA922AE7F5
Fediverse: akib@hostux.social
Codeberg: akib
emailselfdefense.fsf.org | "Nothing can be secure without encryption."

[-- Attachment #2: signature.asc --]
[-- Type: application/pgp-signature, Size: 832 bytes --]

^ permalink raw reply	[flat|nested] 54+ messages in thread

* bug#29108: 25.3; ERC SASL support
       [not found]                 ` <87iljoqaor.fsf@disroot.org>
@ 2022-11-10  5:28                   ` J.P.
       [not found]                   ` <87sfirml89.fsf@neverwas.me>
  1 sibling, 0 replies; 54+ messages in thread
From: J.P. @ 2022-11-10  5:28 UTC (permalink / raw)
  To: Akib Azmain Turja; +Cc: Adam Porter, 29108, bandali, emacs-erc

Akib Azmain Turja <akib@disroot.org> writes:

> "J.P." <jp@neverwas.me> writes:
>
> [...]
>> Also, I've been trying to get a hold of you regarding another bug:
>
> Just out of curiosity, why me?  Is it because I use pass?

Because you took to emacs-devel to air your frustrations and share some
code, which made me hopeful that you might be willing to direct that
energy toward solving these problems in a productive and practical way.

>>   https://debbugs.gnu.org/cgi/bugreport.cgi?bug=58985
>
> Hmm, looks like I was CC'ed, but I didn't notice.  :(
> I will try to review the patches.

I also replied directly to your initial email on emacs-devel:

  https://lists.gnu.org/archive/html/emacs-devel/2022-11/msg00334.html

> Yeah, auth-source-pass is indeed just a hack, a terrible one.  I needed
> to apply a few advices on that auth-source-pass make it work at least
> reasonably well for me uses.
>
> Fix auth-source-pass will require the existing API of it (which the
> Emacs package "pass" uses) and may even need complete rewrite.

I didn't realize that the Melpa package pass required password-store
(and thereby auth-source-pass). TIL.

> But Adam Porter (CC'ing), the maintainer of ement.el (available on GNU
> ELPA), a Matrix client, claims that auth-source is from the dark side[1]
> and refused to support it claiming it's not suitable for general use[2].

If you don't mind, I'd rather not discuss any gripes you may have with
auth-source generally on this bug thread. And please direct any further
discussion regarding both auth-source-pass and (to whatever extent it's
relevant) auth-source, to bug#58985. Thanks.

>> Based on your email to the devel mailing list, it seems you have some
>> experience (or at least opinions and interest) in that area. Please take
>> a look if you haven't already, and then volunteer to help get some
>> improvements in tree before Emacs 29 is cut.
>
> Thanks for the reminder, otherwise I would have just missed that
> discussion.
>
> Anyway, when the Emacs 29 branch is going to cut?  I have some changes
> (semantic highlighting of code) waiting to merge to Eglot (now in core)
> for about six months.  I have got the paperwork to sign about ten days
> ago, but due to some (personal) problems it's taking some time to sign
> it.  I really want to make it into Emacs 29, instead of 30.

All I've heard is that Emacs 29 will be cut "later this month."
Regarding your paperwork situation, I feel your pain but sadly have
nothing useful say on the matter (not that this bug thread is the right
place for that).

>> Also, I've noticed someone on Libera with the nick akib pop into #erc on
>> occasion. Please don't hesitate to say hi if that was in fact you.
>
> Yeah, that's indeed me.

Cool.





^ permalink raw reply	[flat|nested] 54+ messages in thread

* bug#29108: 25.3; ERC SASL support
       [not found]                   ` <87sfirml89.fsf@neverwas.me>
@ 2022-11-10 18:04                     ` Adam Porter
  2022-11-10 21:50                       ` J.P.
                                         ` (2 more replies)
  0 siblings, 3 replies; 54+ messages in thread
From: Adam Porter @ 2022-11-10 18:04 UTC (permalink / raw)
  To: J.P., Akib Azmain Turja; +Cc: 29108, bandali, emacs-erc

On 11/9/22 23:28, J.P. wrote:

 > Akib Azmain Turja <akib@disroot.org> writes:

>> But Adam Porter (CC'ing), the maintainer of ement.el (available on GNU
>> ELPA), a Matrix client, claims that auth-source is from the dark side[1]
>> and refused to support it claiming it's not suitable for general use[2].

Please note: I did not expect to be mentioned in this way here.  It's 
not my intention to speak poorly of others' software, especially in 
public.  In the Reddit post I made, I tried to be objective and show the 
problems clearly with code examples.

And that is merely my opinion, of course, based on the shortcomings I 
noted (e.g. the lack of API to update a secret, the undocumented 
error-handling signals, etc).  I expect that, were I to use it in my 
software, I would end up working around these problems and answering 
users' support questions about them; and since I don't use it myself, 
either, it doesn't seem like a good idea to do so.

Nevertheless, it's clearly used by a number of people and third-party 
packages that integrate with it, so take my opinion of it with a grain 
of salt.  If it seems useful to you, by all means, use it.





^ permalink raw reply	[flat|nested] 54+ messages in thread

* bug#29108: 25.3; ERC SASL support
  2022-11-10 18:04                     ` Adam Porter
@ 2022-11-10 21:50                       ` J.P.
       [not found]                       ` <87sfiq7a3j.fsf@neverwas.me>
  2022-11-11  5:51                       ` Akib Azmain Turja via Bug reports for GNU Emacs, the Swiss army knife of text editors
  2 siblings, 0 replies; 54+ messages in thread
From: J.P. @ 2022-11-10 21:50 UTC (permalink / raw)
  To: Adam Porter; +Cc: 29108, bandali, Akib Azmain Turja, emacs-erc

Adam Porter <adam@alphapapa.net> writes:

> On 11/9/22 23:28, J.P. wrote:
>
>> Akib Azmain Turja <akib@disroot.org> writes:
>
>>> But Adam Porter (CC'ing), the maintainer of ement.el (available on GNU
>>> ELPA), a Matrix client, claims that auth-source is from the dark side[1]
>>> and refused to support it claiming it's not suitable for general use[2].
>
> Please note: I did not expect to be mentioned in this way here.  It's not my
> intention to speak poorly of others' software, especially in public.  In the
> Reddit post I made, I tried to be objective and show the problems clearly with
> code examples.

That's certainly the impression I got, and I regret not having said as
much sooner. Sorry you had to burn cycles on a dignified defense. At the
same time, I'm hopeful folks will find the restraint to chalk this up to
a teachable moment and attribute Akib's bit of ambush editorializing
(something I myself have been guilty of over the years) to the angst of
youth or a moment of weakness, both potential engines of productivity
when channeled in a more positive direction.

> And that is merely my opinion, of course, based on the shortcomings I noted
> (e.g. the lack of API to update a secret, the undocumented error-handling
> signals, etc).  I expect that, were I to use it in my software, I would end up
> working around these problems and answering users' support questions about
> them; and since I don't use it myself, either, it doesn't seem like a good
> idea to do so.
>
> Nevertheless, it's clearly used by a number of people and third-party packages
> that integrate with it, so take my opinion of it with a grain of salt.  If it
> seems useful to you, by all means, use it.

Thanks for your work on Emacs.





^ permalink raw reply	[flat|nested] 54+ messages in thread

* bug#29108: 25.3; ERC SASL support
       [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>
  2 siblings, 0 replies; 54+ messages in thread
From: Adam Porter @ 2022-11-11  1:25 UTC (permalink / raw)
  To: J.P.; +Cc: 29108, bandali, Akib Azmain Turja, emacs-erc

On 11/10/22 15:50, J.P. wrote:
> Adam Porter <adam@alphapapa.net> writes:
> 
>> On 11/9/22 23:28, J.P. wrote:
>>
>>> Akib Azmain Turja <akib@disroot.org> writes:
>>
>>>> But Adam Porter (CC'ing), the maintainer of ement.el (available on GNU
>>>> ELPA), a Matrix client, claims that auth-source is from the dark side[1]
>>>> and refused to support it claiming it's not suitable for general use[2].
>>
>> Please note: I did not expect to be mentioned in this way here.  It's not my
>> intention to speak poorly of others' software, especially in public.  In the
>> Reddit post I made, I tried to be objective and show the problems clearly with
>> code examples.
> 
> That's certainly the impression I got, and I regret not having said as
> much sooner. Sorry you had to burn cycles on a dignified defense. At the
> same time, I'm hopeful folks will find the restraint to chalk this up to
> a teachable moment and attribute Akib's bit of ambush editorializing
> (something I myself have been guilty of over the years) to the angst of
> youth or a moment of weakness, both potential engines of productivity
> when channeled in a more positive direction.

Agreed, thanks.

>> And that is merely my opinion, of course, based on the shortcomings I noted
>> (e.g. the lack of API to update a secret, the undocumented error-handling
>> signals, etc).  I expect that, were I to use it in my software, I would end up
>> working around these problems and answering users' support questions about
>> them; and since I don't use it myself, either, it doesn't seem like a good
>> idea to do so.
>>
>> Nevertheless, it's clearly used by a number of people and third-party packages
>> that integrate with it, so take my opinion of it with a grain of salt.  If it
>> seems useful to you, by all means, use it.
> 
> Thanks for your work on Emacs.

Thanks for the kind words.





^ permalink raw reply	[flat|nested] 54+ messages in thread

* bug#29108: 25.3; ERC SASL support
  2022-11-10 18:04                     ` Adam Porter
  2022-11-10 21:50                       ` J.P.
       [not found]                       ` <87sfiq7a3j.fsf@neverwas.me>
@ 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
  2 siblings, 1 reply; 54+ messages in thread
From: Akib Azmain Turja via Bug reports for GNU Emacs, the Swiss army knife of text editors @ 2022-11-11  5:51 UTC (permalink / raw)
  To: Adam Porter; +Cc: emacs-erc, 29108, bandali, J.P.

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

Adam Porter <adam@alphapapa.net> writes:

> On 11/9/22 23:28, J.P. wrote:
>
>> Akib Azmain Turja <akib@disroot.org> writes:
>
>>> But Adam Porter (CC'ing), the maintainer of ement.el (available on GNU
>>> ELPA), a Matrix client, claims that auth-source is from the dark side[1]
>>> and refused to support it claiming it's not suitable for general use[2].
>
> Please note: I did not expect to be mentioned in this way here.  It's
> not my intention to speak poorly of others' software, especially in
> public.  In the Reddit post I made, I tried to be objective and show
> the problems clearly with code examples.

Sorry, I didn't want to hurt you, please forgive me.

>
> And that is merely my opinion, of course, based on the shortcomings I
> noted (e.g. the lack of API to update a secret, the undocumented
> error-handling signals, etc).  I expect that, were I to use it in my
> software, I would end up working around these problems and answering
> users' support questions about them; and since I don't use it myself,
> either, it doesn't seem like a good idea to do so.

Thanks for the clarification.

>
> Nevertheless, it's clearly used by a number of people and third-party
> packages that integrate with it, so take my opinion of it with a grain
> of salt.  If it seems useful to you, by all means, use it.
>

-- 
Akib Azmain Turja, GPG key: 70018CE5819F17A3BBA666AFE74F0EFA922AE7F5
Fediverse: akib@hostux.social
Codeberg: akib
emailselfdefense.fsf.org | "Nothing can be secure without encryption."

[-- Attachment #2: signature.asc --]
[-- Type: application/pgp-signature, Size: 832 bytes --]

^ permalink raw reply	[flat|nested] 54+ messages in thread

* bug#29108: 25.3; ERC SASL support
       [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>
  2 siblings, 0 replies; 54+ messages in thread
From: Akib Azmain Turja via Bug reports for GNU Emacs, the Swiss army knife of text editors @ 2022-11-11  5:56 UTC (permalink / raw)
  To: J.P.; +Cc: Adam Porter, 29108, bandali, emacs-erc

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

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

> Adam Porter <adam@alphapapa.net> writes:
>
>> On 11/9/22 23:28, J.P. wrote:
>>
>>> Akib Azmain Turja <akib@disroot.org> writes:
>>
>>>> But Adam Porter (CC'ing), the maintainer of ement.el (available on GNU
>>>> ELPA), a Matrix client, claims that auth-source is from the dark side[1]
>>>> and refused to support it claiming it's not suitable for general use[2].
>>
>> Please note: I did not expect to be mentioned in this way here.  It's not my
>> intention to speak poorly of others' software, especially in public.  In the
>> Reddit post I made, I tried to be objective and show the problems clearly with
>> code examples.
>
> That's certainly the impression I got, and I regret not having said as
> much sooner. Sorry you had to burn cycles on a dignified defense. At the
> same time, I'm hopeful folks will find the restraint to chalk this up to
> a teachable moment and attribute Akib's bit of ambush editorializing
> (something I myself have been guilty of over the years) to the angst of
> youth or a moment of weakness, both potential engines of productivity
> when channeled in a more positive direction.

I'm extremely sorry, I didn't actually wanted to give that impression.
Please forgive me.

>
>> And that is merely my opinion, of course, based on the shortcomings I noted
>> (e.g. the lack of API to update a secret, the undocumented error-handling
>> signals, etc).  I expect that, were I to use it in my software, I would end up
>> working around these problems and answering users' support questions about
>> them; and since I don't use it myself, either, it doesn't seem like a good
>> idea to do so.
>>
>> Nevertheless, it's clearly used by a number of people and third-party packages
>> that integrate with it, so take my opinion of it with a grain of salt.  If it
>> seems useful to you, by all means, use it.

Your works are awesome, thank you much for the work.

>
> Thanks for your work on Emacs.

-- 
Akib Azmain Turja, GPG key: 70018CE5819F17A3BBA666AFE74F0EFA922AE7F5
Fediverse: akib@hostux.social
Codeberg: akib
emailselfdefense.fsf.org | "Nothing can be secure without encryption."

[-- Attachment #2: signature.asc --]
[-- Type: application/pgp-signature, Size: 832 bytes --]

^ permalink raw reply	[flat|nested] 54+ messages in thread

* bug#29108: 25.3; ERC SASL support
       [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-13 15:36             ` J.P.
       [not found]             ` <87o7taoohd.fsf@neverwas.me>
  2 siblings, 0 replies; 54+ messages in thread
From: J.P. @ 2022-11-13 15:36 UTC (permalink / raw)
  To: 29108; +Cc: emacs-erc, bandali

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

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

> In the interest of keeping things from stagnating further, I'd like to
> move development on this to HEAD so people can try it and provide
> feedback prior to Emacs 29 being cut. If there are any objections to
> that, please raise them before Saturday the 12th of November, 2022.
> Thanks.

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.


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

From 21f3196c0b55d8e7c27c4918f741cbbecfaf2136 Mon Sep 17 00:00:00 2001
From: "F. Jason Park" <jp@neverwas.me>
Date: Sun, 13 Nov 2022 07:14:27 -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                             | 137 +++++-
 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                          | 424 ++++++++++++++++++
 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 ++
 17 files changed, 1586 insertions(+), 80 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 80b4171cdb..79f8c92719 100644
--- a/doc/misc/erc.texi
+++ b/doc/misc/erc.texi
@@ -938,13 +938,12 @@ SASL
 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.  This means invoking
-@code{erc-sasl-mode} manually or calling @code{erc-update-modules}
-won't do any good.  Instead, simply add @code{sasl} to
-@code{erc-modules} (or @code{let}-bind it while calling
-@code{erc-tls}), and SASL will be enabled for the current connection.
-But before that, please explore all custom options pertaining to your
-chosen mechanism.
+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.
diff --git a/lisp/erc/erc-backend.el b/lisp/erc/erc-backend.el
index fee29e7d05..37a3da8b66 100644
--- a/lisp/erc/erc-backend.el
+++ b/lisp/erc/erc-backend.el
@@ -1529,7 +1529,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)
@@ -1539,6 +1539,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))))))
@@ -2165,6 +2168,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-common.el b/lisp/erc/erc-common.el
index 90ea56108d..a300cfc4fa 100644
--- a/lisp/erc/erc-common.el
+++ b/lisp/erc/erc-common.el
@@ -28,7 +28,6 @@
 
 (defvar erc--casemapping-rfc1459)
 (defvar erc--casemapping-rfc1459-strict)
-(defvar erc--module-name-migrations)
 (defvar erc-channel-users)
 (defvar erc-dbuf)
 (defvar erc-log-p)
@@ -86,9 +85,40 @@ erc--target
   (contents "" :type string)
   (tags '() :type list))
 
-(defun erc--normalize-module-symbol (module)
-  "Canonicalize symbol MODULE for `erc-modules'."
-  (or (cdr (assq module erc--module-name-migrations)) module))
+;; 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)
@@ -118,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))))
@@ -141,21 +172,20 @@ define-erc-module
          ,(format "Enable ERC %S mode."
                   name)
          (interactive)
-         (unless ,local-p
-           (cl-pushnew (erc--normalize-module-symbol ',name) erc-modules))
-         (when (or ,(not local-p) (eq major-mode 'erc-mode))
-           (setq ,mode t)
-           ,@enable-body))
+         ,@(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)))
        (defun ,disable ()
          ,(format "Disable ERC %S mode."
                   name)
          (interactive)
-         (unless ,local-p
-           (setq erc-modules (delq (erc--normalize-module-symbol ',name)
-                                   erc-modules)))
-         (when (or ,(not local-p) ,mode)
-           (setq ,mode nil)
-           ,@disable-body))
+         ,@(unless local-p `((setq erc-modules (delq ',mod erc-modules))))
+         ,@(macroexp-unprogn
+            `(,@(if local-p `(when ,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 bc3e1dcfc6..6d4ef21383 100644
--- a/lisp/erc/erc-compat.el
+++ b/lisp/erc/erc-compat.el
@@ -194,9 +194,9 @@ erc-compat--sasl-scram-client-first-message
 ;;  > The use of base64 in SCRAM is restricted to the canonical form
 ;;  > with no whitespace.
 ;;
-;; Unfortunately, advising `base64-encode-string' won't work
-;; because the byte compiler precomputes the result when all inputs
-;; are constants, as they are in the unpatched version.
+;; 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
@@ -272,6 +272,18 @@ erc-compat--with-memoization
     `(cl--generic-with-memoization ,table ,@forms))
    (t `(progn ,@forms))))
 
+(defun erc-compat--local-minor-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-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-sasl.el b/lisp/erc/erc-sasl.el
index f36a305247..ac2646051c 100644
--- a/lisp/erc/erc-sasl.el
+++ b/lisp/erc/erc-sasl.el
@@ -19,8 +19,8 @@
 
 ;;; Commentary:
 
-;; This "non-IRCv3" implementation resembles many others that have
-;; surfaced over the years, the first possibly being:
+;; 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
 ;;
@@ -30,29 +30,35 @@
 ;;
 ;; - Find a way to obfuscate the password in memory (via something
 ;;   like `auth-source--obfuscate'); it's currently visible in
-;;   backtraces.
+;;   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)
+(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")) ; FIXME increment on next release
+  :package-version '(ERC . "5.4.1")) ; FIXME increment on next release
 
-(defcustom erc-sasl-mechanism nil
+(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 nil)
-                 (const plain)
+  :type '(choice (const plain)
                  (const external)
                  (const scram-sha-1)
                  (const scram-sha-256)
@@ -68,17 +74,18 @@ erc-sasl-user
 
 (defcustom erc-sasl-password nil
   "Optional account password to send when authenticating.
-When the value is a string, ERC uses it unconditionally for most
-mechanisms (see below).  As a special case, when the value is a
-non-nil symbol, ERC uses it as the value of the `:host' field in
-an auth-source query, provided `erc-sasl-auth-source-function' is
-set to a function.  When nil, ERC will try a non-nil \"session
-password\", likely one given as the `:password' argument to
-`erc-tls'.  As a last resort, ERC will prompt the user for input.
+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, which is typically in PEM format."
+of the key."
   :type '(choice (const nil) string symbol))
 
 (defcustom erc-sasl-auth-source-function nil
@@ -91,7 +98,7 @@ erc-sasl-auth-source-function
 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 (const erc-auth-source-search)
+  :type '(choice (function-item erc-auth-source-search)
                  (const nil)
                  function))
 
@@ -103,6 +110,13 @@ erc-sasl-authzid
 ;; 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)
 
@@ -263,13 +277,26 @@ erc-sasl--create-client
       (sasl-client-set-property client 'ecdsa-keyfile keyfile)
       client)))
 
-;; This stands alone because it's also used by bug#49860
+;; This stands alone because it's also used by bug#49860.
 (defun erc-sasl--init ()
-  (setq erc-sasl--state (make-erc-sasl--state)
-        erc-sasl--options `((user . ,erc-sasl-user)
-                            (password . ,erc-sasl-password)
-                            (mechanism . ,erc-sasl-mechanism)
-                            (authzid . ,erc-sasl-authzid))))
+  ;; 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))
 
 (defun erc-sasl--mechanism-offered-p (offered)
   "Return non-nil when OFFERED appears among a list of mechanisms."
@@ -359,6 +386,7 @@ erc-sasl--destroy
   (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)
diff --git a/lisp/erc/erc.el b/lisp/erc/erc.el
index 2869383960..a703f903ec 100644
--- a/lisp/erc/erc.el
+++ b/lisp/erc/erc.el
@@ -1780,31 +1780,6 @@ erc-default-nicks
 (defvar-local erc-nick-change-attempt-count 0
   "Used to keep track of how many times an attempt at changing nick is made.")
 
-(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.")
-
-(defconst erc--modules-to-features
-  (cl-loop for (feature . names) in erc--features-to-modules
-           append (mapcar (lambda (name) (cons name feature)) names))
-  "Migration alist mapping a module's name to 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-migrate-modules (mods)
   "Migrate old names of ERC modules to new ones."
   ;; modify `transforms' to specify what needs to be changed
@@ -1888,23 +1863,25 @@ erc-modules
     (repeat :tag "Others" :inline t symbol))
   :group 'erc)
 
-(defun erc-update-modules ()
+(defun erc-update-modules (&optional defer-locals)
   "Enable global minor mode for all global modules in `erc-modules'.
-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.6."
-  (let (local-modules)
-    (dolist (mod erc-modules)
-      (require (or (alist-get mod erc--modules-to-features)
-                   (intern (concat "erc-" (symbol-name mod))))
+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-minor-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 ((sym (intern-soft (concat "erc-" (symbol-name mod) "-mode"))))
-        (unless (and sym (fboundp sym))
-          (error "`%s' is not a known ERC module" mod))
-        (if (custom-variable-p sym)
-            (funcall sym 1)
-          (push sym local-modules))))
-    local-modules))
+      (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'."
@@ -1966,15 +1943,17 @@ erc-open
          (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))
     (set-buffer buffer)
     (setq old-point (point))
-    (setq delayed-modules (erc-update-modules))
+    (setq delayed-modules (erc-update-modules 'defer-locals))
 
     (delay-mode-hooks (erc-mode))
 
-    (setq erc-server-reconnect-count old-recon-count)
+    (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
@@ -2030,10 +2009,11 @@ 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 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
@@ -3197,7 +3177,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
diff --git a/test/lisp/erc/erc-sasl-tests.el b/test/lisp/erc/erc-sasl-tests.el
index 112303baf5..81db9ad948 100644
--- a/test/lisp/erc/erc-sasl-tests.el
+++ b/test/lisp/erc/erc-sasl-tests.el
@@ -1,6 +1,6 @@
 ;;; erc-sasl-tests.el --- Tests for erc-sasl.  -*- lexical-binding:t -*-
 
-;; Copyright (C) 2020-2022 Free Software Foundation, Inc.
+;; Copyright (C) 2022 Free Software Foundation, Inc.
 ;;
 ;; This file is part of GNU Emacs.
 ;;
@@ -276,6 +276,10 @@ erc-sasl-tests-ecdsa-key-file
 ")
 
 (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
@@ -295,8 +299,21 @@ erc-sasl-create-client-ecdsa
                             "\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))
-          ;; FIXME this is dumb
-          (should (<= 68 (length (sasl-step-data step)) 72))))
+          (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
index 3ff7cc805d..7970e65ec2 100644
--- a/test/lisp/erc/erc-scenarios-sasl.el
+++ b/test/lisp/erc/erc-scenarios-sasl.el
@@ -41,6 +41,7 @@ erc-scenarios-sasl--plain
        (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)))
 
@@ -60,6 +61,49 @@ erc-scenarios-sasl--plain
         ;; 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
@@ -70,6 +114,7 @@ erc-scenarios-sasl--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)))
 
@@ -99,6 +144,7 @@ erc-scenarios-sasl--plain-fail
        (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))
@@ -128,6 +174,7 @@ erc-scenarios--common--sasl
        (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)
diff --git a/test/lisp/erc/erc-tests.el b/test/lisp/erc/erc-tests.el
index 4646c35e25..91815b8fae 100644
--- a/test/lisp/erc/erc-tests.el
+++ b/test/lisp/erc/erc-tests.el
@@ -960,44 +960,60 @@ erc-migrate-modules
   (should (equal (erc-migrate-modules erc-modules) erc-modules)))
 
 (ert-deftest erc-update-modules ()
-  (let* (calls
-         (erc-modules '(fake-foo fake-bar)))
+  (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)))
-              ((symbol-function 'erc-fake-foo-mode)
-               (lambda (n) (push (cons 'fake-foo n) calls)))
-              ;; Here, foo is a global module (minor mode)
-              ((get 'erc-fake-foo-mode 'standard-value) #'ignore)
+
+              ;; 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)
+              ((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 ("Locals")
-        (should (equal (erc-update-modules)
-                       '(erc-fake-bar-mode)))
-        ;; Bar still required
-        (should (equal (nreverse calls) '(erc-fake-foo
-                                          (fake-foo . 1)
-                                          erc-fake-bar)))
+              ((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)) ; no locals
-        (should (equal (nreverse calls)
-                       '(erc-pcomplete
-                         (completion . 1)
-                         erc-join
-                         (autojoin . 1)
-                         erc-networks
-                         (networks . 1))))
-        (setq calls nil)))))
+        (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))
+          (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)
+                            erc-networks (networks . 1)))))))))
 
 ;;; erc-tests.el ends here
diff --git a/test/lisp/erc/resources/sasl/plain.eld b/test/lisp/erc/resources/sasl/plain.eld
index 9c6ce3feeb..1341cd78e5 100644
--- a/test/lisp/erc/resources/sasl/plain.eld
+++ b/test/lisp/erc/resources/sasl/plain.eld
@@ -33,3 +33,7 @@
 ((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))
-- 
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: 14134 bytes --]

From 6210a98556063dd22b0ddc36ec75cebab5cb9cd6 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

* 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.)
---
 lisp/erc/erc-common.el     | 56 +++++++++++++++++++++++----
 lisp/erc/erc-goodies.el    |  1 +
 lisp/erc/erc.el            | 78 ++++++++++++++++++++------------------
 test/lisp/erc/erc-tests.el | 58 ++++++++++++++++++++++++++++
 4 files changed, 150 insertions(+), 43 deletions(-)

diff --git a/lisp/erc/erc-common.el b/lisp/erc/erc-common.el
index d8aac36eab..a300cfc4fa 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)))
+         ,@(macroexp-unprogn
+            `(,@(if local-p '(when (eq major-mode 'erc-mode)) '(progn))
+              (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 ,mode) '(progn))
+              (setq ,mode nil)
+              ,@disable-body)))
        ,(when (and alias (not (eq name alias)))
           `(defalias
              ',(intern
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..6c9d4de2ba 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,28 @@ 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))
+           (delq nil (mapcar
+                      (lambda (m)
+                        (and (string-prefix-p "erc-" (symbol-name m)) m))
+                      local-minor-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 +1939,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 +2013,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 +2037,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 e47b40618b6481e1fa2b751dd79a5a4e2f3da2a7 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: 73241 bytes --]

From 21f3196c0b55d8e7c27c4918f741cbbecfaf2136 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.
(erc-compat--local-minor-modes): Add helper for finding local modules
active in an ERC buffer.
* 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 +++++-
 lisp/erc/erc-compat.el                        | 116 +++++
 lisp/erc/erc-sasl.el                          | 424 ++++++++++++++++++
 lisp/erc/erc.el                               |   6 +-
 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 ++
 12 files changed, 1394 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 3db83197f9..79f8c92719 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.
 
@@ -478,6 +479,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
@@ -525,6 +530,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
@@ -842,6 +848,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
@@ -854,7 +861,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
@@ -915,6 +923,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/lisp/erc/erc-compat.el b/lisp/erc/erc-compat.el
index 03bd8f1352..6d4ef21383 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)
@@ -168,6 +272,18 @@ erc-compat--with-memoization
     `(cl--generic-with-memoization ,table ,@forms))
    (t `(progn ,@forms))))
 
+(defun erc-compat--local-minor-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-sasl.el b/lisp/erc/erc-sasl.el
new file mode 100644
index 0000000000..ac2646051c
--- /dev/null
+++ b/lisp/erc/erc-sasl.el
@@ -0,0 +1,424 @@
+;;; 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 (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))
+
+(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))
+
+(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 mechanism: %s" mech))
+         (erc-error "Unknown mechanism: %s" mech))
+       (setf (erc-sasl--state-client erc-sasl--state) client))))
+  ((remove-hook 'erc-server-AUTHENTICATE-functions
+                #'erc-sasl--authenticate-handler t)
+   (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."
+  (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))))
+
+(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 6c9d4de2ba..a703f903ec 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"
@@ -1870,10 +1871,7 @@ erc-update-modules
 introduced in ERC 5.5."
   (let ((local-modes
          (when (and defer-locals (derived-mode-p 'erc-mode))
-           (delq nil (mapcar
-                      (lambda (m)
-                        (and (string-prefix-p "erc-" (symbol-name m)) m))
-                      local-minor-modes)))))
+           (erc-compat--local-minor-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))))
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


^ permalink raw reply related	[flat|nested] 54+ messages in thread

* bug#29108: 25.3; ERC SASL support
       [not found]             ` <87o7taoohd.fsf@neverwas.me>
@ 2022-11-14  6:45               ` J.P.
  2022-11-14 15:20                 ` J.P.
       [not found]                 ` <87y1sdk1fg.fsf@neverwas.me>
  0 siblings, 2 replies; 54+ messages in thread
From: J.P. @ 2022-11-14  6:45 UTC (permalink / raw)
  To: 29108; +Cc: emacs-erc, bandali

[-- 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


^ permalink raw reply related	[flat|nested] 54+ messages in thread

* bug#29108: 25.3; ERC SASL support
  2022-11-14  6:45               ` J.P.
@ 2022-11-14 15:20                 ` J.P.
       [not found]                 ` <87y1sdk1fg.fsf@neverwas.me>
  1 sibling, 0 replies; 54+ messages in thread
From: J.P. @ 2022-11-14 15:20 UTC (permalink / raw)
  To: 29108; +Cc: emacs-erc, bandali

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

v8. Removed some extraneous business from `erc-update-modules'. Also
tacked on 0001-Accept-functions-in-place-of-passwords-in-ERC, which
depends on these changes but should probably go in another bug report.


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

From 297df95338694a0af1614dc92ae9c1391bc20a90 Mon Sep 17 00:00:00 2001
From: "F. Jason Park" <jp@neverwas.me>
Date: Mon, 14 Nov 2022 00:02:39 -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                                  |  20 +-
 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                               |  86 ++--
 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                    |  58 +++
 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, 1616 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/etc/ERC-NEWS b/etc/ERC-NEWS
index f5b14376ad..f35e94dc1f 100644
--- a/etc/ERC-NEWS
+++ b/etc/ERC-NEWS
@@ -103,12 +103,11 @@ 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.
+optional argument to defer enabling of local modules and instead
+return their mode commands.  'erc-open' leverages this to delay their
+activation, as well as that of all 'erc-mode-hook' members, until most
+local session variables have been initialized (minus those "server"-
+and process-focused ones in erc-backend).
 
 ** Miscellaneous behavioral changes in the library API.
 A number of core macros and other definitions have been moved to a new
diff --git a/lisp/erc/erc.el b/lisp/erc/erc.el
index c5989dbc7e..6a9bd56794 100644
--- a/lisp/erc/erc.el
+++ b/lisp/erc/erc.el
@@ -1869,9 +1869,7 @@ erc-update-modules
 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))))
+  (let (local-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))))
@@ -1880,7 +1878,7 @@ erc-update-modules
         (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)
+            (push mode local-modes)
           (funcall mode 1))))))
 
 (defun erc-setup-buffer (buffer)
@@ -1948,7 +1946,10 @@ erc-open
     (when connect (run-hook-with-args 'erc-before-connect server port nick))
     (set-buffer buffer)
     (setq old-point (point))
-    (setq delayed-modules (erc-update-modules 'defer-locals))
+    (setq delayed-modules
+          (delete-dups (append (when continued-session
+                                 (erc-compat--local-module-modes))
+                               (erc-update-modules 'defer-locals))))
 
     (delay-mode-hooks (erc-mode))
 
diff --git a/test/lisp/erc/erc-tests.el b/test/lisp/erc/erc-tests.el
index 91815b8fae..3221af03ed 100644
--- a/test/lisp/erc/erc-tests.el
+++ b/test/lisp/erc/erc-tests.el
@@ -998,22 +998,17 @@ erc-update-modules
         (should (equal (nreverse calls) '( erc-pcomplete (completion . 1)
                                            erc-join (autojoin . 1)
                                            erc-networks (networks . 1))))
-        (setq calls nil))
+        (setq calls nil)))))
 
-      (ert-info ("Reenabling of local minor modes by `erc-open'")
-        (with-temp-buffer
-          (erc-mode)
-          (setq erc-modules '(completion autojoin networks))
-          (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)
-                            erc-networks (networks . 1)))))))))
+(ert-deftest erc-compat--local-module-modes ()
+  (with-temp-buffer
+    (if (< 27 emacs-major-version)
+        (let ((local-minor-modes '(font-lock-mode erc-fake-bar-mode)))
+          (should (equal (erc-compat--local-module-modes)
+                         '(erc-fake-bar-mode))))
+      (cl-letf (((symbol-function 'buffer-local-variables)
+                 (lambda (&rest _) '((font-lock-mode) (erc-fake-bar-mode)))))
+        (should (equal (erc-compat--local-module-modes)
+                       '(erc-fake-bar-mode)))))))
 
 ;;; erc-tests.el ends here
-- 
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: 17300 bytes --]

From 55611f2bab21c7d6bc9a59ad17a32fb71a94233f 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               | 13 +++++++
 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, 181 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..354c6d7f3b 100644
--- a/etc/ERC-NEWS
+++ b/etc/ERC-NEWS
@@ -97,6 +97,19 @@ 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 enabling of local modules and instead
+return their mode commands.  'erc-open' leverages this to delay their
+activation, as well as that of all 'erc-mode-hook' members, until most
+local session variables have been initialized (minus those "server"-
+and process-focused ones in erc-backend).
+
 ** 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 b65868ca32f4c78431b17f334990ea84bd2209a6 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: 74996 bytes --]

From 297df95338694a0af1614dc92ae9c1391bc20a90 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                               |  12 +-
 test/lisp/erc/erc-sasl-tests.el               | 319 +++++++++++++
 test/lisp/erc/erc-scenarios-sasl.el           | 208 +++++++++
 test/lisp/erc/erc-tests.el                    |  22 +-
 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, 1403 insertions(+), 21 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 354c6d7f3b..f35e94dc1f 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..6a9bd56794 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"
@@ -1868,9 +1869,7 @@ erc-update-modules
 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))))
+  (let (local-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))))
@@ -1879,7 +1878,7 @@ erc-update-modules
         (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)
+            (push mode local-modes)
           (funcall mode 1))))))
 
 (defun erc-setup-buffer (buffer)
@@ -1947,7 +1946,10 @@ erc-open
     (when connect (run-hook-with-args 'erc-before-connect server port nick))
     (set-buffer buffer)
     (setq old-point (point))
-    (setq delayed-modules (erc-update-modules 'defer-locals))
+    (setq delayed-modules
+          (delete-dups (append (when continued-session
+                                 (erc-compat--local-module-modes))
+                               (erc-update-modules 'defer-locals))))
 
     (delay-mode-hooks (erc-mode))
 
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..3221af03ed 100644
--- a/test/lisp/erc/erc-tests.el
+++ b/test/lisp/erc/erc-tests.el
@@ -998,17 +998,17 @@ erc-update-modules
         (should (equal (nreverse calls) '( erc-pcomplete (completion . 1)
                                            erc-join (autojoin . 1)
                                            erc-networks (networks . 1))))
-        (setq calls nil))
+        (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)))))))))
+(ert-deftest erc-compat--local-module-modes ()
+  (with-temp-buffer
+    (if (< 27 emacs-major-version)
+        (let ((local-minor-modes '(font-lock-mode erc-fake-bar-mode)))
+          (should (equal (erc-compat--local-module-modes)
+                         '(erc-fake-bar-mode))))
+      (cl-letf (((symbol-function 'buffer-local-variables)
+                 (lambda (&rest _) '((font-lock-mode) (erc-fake-bar-mode)))))
+        (should (equal (erc-compat--local-module-modes)
+                       '(erc-fake-bar-mode)))))))
 
 ;;; erc-tests.el ends here
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


[-- Warning: decoded text below may be mangled, UTF-8 assumed --]
[-- Attachment #8: 0001-Accept-functions-in-place-of-passwords-in-ERC.patch --]
[-- Type: text/x-patch, Size: 11875 bytes --]

From 39bd87514ef1bad263116080a398de75fe17c179 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] Accept functions in place of passwords in ERC

* lisp/erc/erc-backend.el (erc-session-password): Add comment
explaining type is now string, nil, or function.
* lisp/erc/erc-common.el (erc--unfun): New inline function for
unwrapping a password couched in a getter.
* lisp/erc/erc-sasl.el (erc-sasl--read-password): Use `erc--unfun'.
* lisp/erc/erc-services.el (erc-nickserv-get-password,
erc-nickserv-send-identify): Use `erc--unfun'.
* lisp/erc/erc.el (erc--debug-irc-protocol-mask-secrets): Add variable
to indicate whether to mask passwords in debug logs.
(erc--mask-secrets): New function to swap masked secret with question
marks in debug logs.
(erc-log-irc-protocol): Conditionally mask secrets when
`erc--debug-irc-protocol-mask-secrets' is non-nil.
(erc--auth-source-search): Don't unwrap secret from function before
returning.
(erc-server-join-channel, erc-login): Use `erc--unfun'.
* test/lisp/erc/erc-services-tests.el
(erc-services-tests--wrap-search): Add helper for `erc--unfun'.
(erc-services-tests--auth-source-standard,
erc-services-tests--auth-source-announced,
erc-services-tests--auth-source-overrides, erc-nickserv-get-password):
Use `erc--unfun'.
* test/lisp/erc/erc-tests.el (erc--debug-irc-protocol-mask-secrets):
Add test for masking secrets with `erc--unfun' and friends.
---
 lisp/erc/erc-backend.el             |  3 ++-
 lisp/erc/erc-common.el              | 13 +++++++++++++
 lisp/erc/erc-sasl.el                |  2 +-
 lisp/erc/erc-services.el            |  5 +++--
 lisp/erc/erc.el                     | 29 ++++++++++++++++++++++++-----
 test/lisp/erc/erc-services-tests.el | 16 ++++++++++++----
 test/lisp/erc/erc-tests.el          | 22 ++++++++++++++++++++++
 7 files changed, 77 insertions(+), 13 deletions(-)

diff --git a/lisp/erc/erc-backend.el b/lisp/erc/erc-backend.el
index 37a3da8b66..382eb833ff 100644
--- a/lisp/erc/erc-backend.el
+++ b/lisp/erc/erc-backend.el
@@ -205,7 +205,8 @@ erc-whowas-on-nosuchnick
 ;;;; Variables and options
 
 (defvar-local erc-session-password nil
-  "The password used for the current session.")
+  "The password used for the current session.
+This should be a string or a function returning a string.")
 
 (defvar erc-server-responses (make-hash-table :test #'equal)
   "Hash table mapping server responses to their handler hooks.")
diff --git a/lisp/erc/erc-common.el b/lisp/erc/erc-common.el
index e5fabdc67f..de1b0383a2 100644
--- a/lisp/erc/erc-common.el
+++ b/lisp/erc/erc-common.el
@@ -308,6 +308,19 @@ erc-get-server-user
     (inline-quote (erc-with-server-buffer
                     (gethash (erc-downcase ,nick) erc-server-users)))))
 
+(defvar erc-debug-irc-protocol)
+(defvar erc--debug-irc-protocol-mask-secrets)
+
+(define-inline erc--unfun (maybe-fn)
+  "Return MAYBE-FN or whatever it returns, if it's a function."
+  (inline-quote
+   (let ((s (if (functionp ,maybe-fn) (funcall ,maybe-fn) ,maybe-fn)))
+     (when (and erc-debug-irc-protocol
+                erc--debug-irc-protocol-mask-secrets
+                (stringp s))
+       (put-text-property 0 (length s) 'erc-secret t s))
+     s)))
+
 (provide 'erc-common)
 
 ;;; erc-common.el ends here
diff --git a/lisp/erc/erc-sasl.el b/lisp/erc/erc-sasl.el
index a9d7ed235d..d8ef600351 100644
--- a/lisp/erc/erc-sasl.el
+++ b/lisp/erc/erc-sasl.el
@@ -141,7 +141,7 @@ erc-sasl--read-password
                               ,@(and host (list :host (symbol-name host)))))))
               erc-session-password)))
     (if found
-        (copy-sequence found)
+        (copy-sequence (erc--unfun found))
       (read-passwd prompt))))
 
 (defun erc-sasl--plain-response (client steps)
diff --git a/lisp/erc/erc-services.el b/lisp/erc/erc-services.el
index fe9cb5b5f1..48953288d1 100644
--- a/lisp/erc/erc-services.el
+++ b/lisp/erc/erc-services.el
@@ -455,7 +455,7 @@ erc-nickserv-get-password
                   (read-passwd
                    (format "NickServ password for %s on %s (RET to cancel): "
                            nick nid)))))
-       ((not (string-empty-p ret))))
+       ((not (string-empty-p (erc--unfun ret)))))
     ret))
 
 (defvar erc-auto-discard-away)
@@ -477,7 +477,8 @@ erc-nickserv-send-identify
          (msgtype (or (erc-nickserv-alist-ident-command nil nickserv-info)
                       "PRIVMSG")))
     (erc-message msgtype
-                 (concat nickserv " " identify-word " " nick password))))
+                 (concat nickserv " " identify-word " " nick
+                         (erc--unfun password)))))
 
 (defun erc-nickserv-call-identify-function (nickname)
   "Call `erc-nickserv-identify' with NICKNAME."
diff --git a/lisp/erc/erc.el b/lisp/erc/erc.el
index 6a9bd56794..da20bdaee9 100644
--- a/lisp/erc/erc.el
+++ b/lisp/erc/erc.el
@@ -2301,6 +2301,23 @@ erc-debug-irc-protocol
 WARNING: Do not set this variable directly!  Instead, use the
 function `erc-toggle-debug-irc-protocol' to toggle its value.")
 
+(defvar erc--debug-irc-protocol-mask-secrets t
+  "Whether to hide secrets in a debug log.
+They are still visible on screen but are replaced by question
+marks when yanked.")
+
+(defun erc--mask-secrets (string)
+  (when-let* ((eot (length string))
+              (beg (text-property-any 0 eot 'erc-secret t string))
+              (end (text-property-not-all beg eot 'erc-secret t string))
+              (sec (substring string beg end)))
+    (setq string (concat (substring string 0 beg)
+                         (make-string (- end beg) ??)
+                         (substring string end eot)))
+    (put-text-property beg end 'face 'erc-inverse-face string)
+    (put-text-property beg end 'display sec string))
+  string)
+
 (defun erc-log-irc-protocol (string &optional outbound)
   "Append STRING to the buffer *erc-protocol*.
 
@@ -2326,6 +2343,8 @@ erc-log-irc-protocol
                       (format "%s:%s" erc-session-server erc-session-port))))
           (ts (when erc-debug-irc-protocol-time-format
                 (format-time-string erc-debug-irc-protocol-time-format))))
+      (when erc--debug-irc-protocol-mask-secrets
+        (setq string (erc--mask-secrets string)))
       (with-current-buffer (get-buffer-create "*erc-protocol*")
         (save-excursion
           (goto-char (point-max))
@@ -3249,9 +3268,8 @@ erc--auth-source-search
       (setq plist (plist-put plist :max 5000))) ; `auth-source-netrc-parse'
     (unless (plist-get defaults :require)
       (setq plist (plist-put plist :require '(:secret))))
-    (when-let* ((sorted (sort (apply #'auth-source-search plist) test))
-                (secret (plist-get (car sorted) :secret)))
-      (if (functionp secret) (funcall secret) secret))))
+    (when-let* ((sorted (sort (apply #'auth-source-search plist) test)))
+      (plist-get (car sorted) :secret))))
 
 (defun erc-auth-source-search (&rest plist)
   "Call `auth-source-search', possibly with keyword params in PLIST."
@@ -3272,7 +3290,8 @@ erc-server-join-channel
     (setq secret (apply erc-auth-source-join-function
                         `(,@(and server (list :host server)) :user ,channel))))
   (erc-log (format "cmd: JOIN: %s" channel))
-  (erc-server-send (concat "JOIN " channel (and secret (concat " " secret)))))
+  (erc-server-send (concat "JOIN " channel
+                           (and secret (concat " " (erc--unfun secret))))))
 
 (defun erc--valid-local-channel-p (channel)
   "Non-nil when channel is server-local on a network that allows them."
@@ -6301,7 +6320,7 @@ erc-login
                    erc-session-server
                    erc-session-user-full-name))
   (if erc-session-password
-      (erc-server-send (concat "PASS :" erc-session-password))
+      (erc-server-send (concat "PASS :" (erc--unfun erc-session-password)))
     (message "Logging in without password"))
   (erc-server-send (format "NICK %s" (erc-current-nick)))
   (erc-server-send
diff --git a/test/lisp/erc/erc-services-tests.el b/test/lisp/erc/erc-services-tests.el
index c22d4cf75e..e35b70e026 100644
--- a/test/lisp/erc/erc-services-tests.el
+++ b/test/lisp/erc/erc-services-tests.el
@@ -62,9 +62,13 @@ erc--auth-source-determine-params-merge
                            :x ("x")
                            :require (:secret))))))
 
+(defun erc-services-tests--wrap-search (s)
+  (lambda (&rest r) (erc--unfun (apply s r))))
+
 ;; Some of the following may be related to bug#23438.
 
 (defun erc-services-tests--auth-source-standard (search)
+  (setq search (erc-services-tests--wrap-search search))
 
   (ert-info ("Session wins")
     (let ((erc-session-server "irc.gnu.org")
@@ -93,6 +97,7 @@ erc-services-tests--auth-source-standard
       (should (string= (funcall search :user "#chan") "baz")))))
 
 (defun erc-services-tests--auth-source-announced (search)
+  (setq search (erc-services-tests--wrap-search search))
   (let* ((erc--isupport-params (make-hash-table))
          (erc-server-parameters '(("CHANTYPES" . "&#")))
          (erc--target (erc--target-from-string "&chan")))
@@ -124,6 +129,7 @@ erc-services-tests--auth-source-announced
           (should (string= (funcall search :user "#chan") "foo")))))))
 
 (defun erc-services-tests--auth-source-overrides (search)
+  (setq search (erc-services-tests--wrap-search search))
   (let* ((erc-session-server "irc.gnu.org")
          (erc-server-announced-name "my.gnu.org")
          (erc-network 'GNU.chat)
@@ -540,18 +546,20 @@ erc-nickserv-get-password
            (erc-network 'FSF.chat)
            (erc-server-current-nick "tester")
            (erc-networks--id (erc-networks--id-create nil))
-           (erc-session-port 6697))
+           (erc-session-port 6697)
+           (search (erc-services-tests--wrap-search
+                    #'erc-nickserv-get-password)))
 
       (ert-info ("Lookup custom option")
-        (should (string= (erc-nickserv-get-password "alice") "foo")))
+        (should (string= (funcall search "alice") "foo")))
 
       (ert-info ("Auth source")
         (ert-info ("Network")
-          (should (string= (erc-nickserv-get-password "bob") "sesame")))
+          (should (string= (funcall search "bob") "sesame")))
 
         (ert-info ("Network ID")
           (let ((erc-networks--id (erc-networks--id-create 'GNU/chat)))
-            (should (string= (erc-nickserv-get-password "bob") "spam")))))
+            (should (string= (funcall search "bob") "spam")))))
 
       (ert-info ("Read input")
         (should (string=
diff --git a/test/lisp/erc/erc-tests.el b/test/lisp/erc/erc-tests.el
index 3221af03ed..ff592e0fa1 100644
--- a/test/lisp/erc/erc-tests.el
+++ b/test/lisp/erc/erc-tests.el
@@ -530,6 +530,28 @@ erc-ring-previous-command
   (when noninteractive
     (kill-buffer "*#fake*")))
 
+(ert-deftest erc--debug-irc-protocol-mask-secrets ()
+  (should-not erc-debug-irc-protocol)
+  (should erc--debug-irc-protocol-mask-secrets)
+  (with-temp-buffer
+    (setq erc-server-process (start-process "fake" (current-buffer) "true")
+          erc-server-current-nick "tester"
+          erc-session-server "myproxy.localhost"
+          erc-session-port 6667)
+    (let ((inhibit-message noninteractive))
+      (erc-toggle-debug-irc-protocol)
+      (erc-log-irc-protocol
+       (concat "PASS :" (erc--unfun (lambda () "changeme")) "\r\n")
+       'outgoing)
+      (set-process-query-on-exit-flag erc-server-process nil))
+    (with-current-buffer "*erc-protocol*"
+      (goto-char (point-min))
+      (search-forward "\r\n\r\n")
+      (search-forward "myproxy.localhost:6667 >> PASS :????????" (pos-eol)))
+    (when noninteractive
+      (kill-buffer "*erc-protocol*")
+      (should-not erc-debug-irc-protocol))))
+
 (ert-deftest erc-log-irc-protocol ()
   (should-not erc-debug-irc-protocol)
   (with-temp-buffer
-- 
2.38.1


^ permalink raw reply related	[flat|nested] 54+ messages in thread

* bug#29108: 25.3; ERC SASL support
  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
  0 siblings, 0 replies; 54+ messages in thread
From: Adam Porter @ 2022-11-14 22:28 UTC (permalink / raw)
  To: Akib Azmain Turja; +Cc: emacs-erc, 29108, bandali, J.P.

Hi Akib,

On 11/10/22 23:51, Akib Azmain Turja wrote:
> Adam Porter <adam@alphapapa.net> writes:
> 
>> On 11/9/22 23:28, J.P. wrote:
>>
>>> Akib Azmain Turja <akib@disroot.org> writes:
>>
>>>> But Adam Porter (CC'ing), the maintainer of ement.el (available on GNU
>>>> ELPA), a Matrix client, claims that auth-source is from the dark side[1]
>>>> and refused to support it claiming it's not suitable for general use[2].
>>
>> Please note: I did not expect to be mentioned in this way here.  It's
>> not my intention to speak poorly of others' software, especially in
>> public.  In the Reddit post I made, I tried to be objective and show
>> the problems clearly with code examples.
> 
> Sorry, I didn't want to hurt you, please forgive me.

Not at all.  I know your intention was just to communicate information. 
  No apology is necessary.  :)

Adam





^ permalink raw reply	[flat|nested] 54+ messages in thread

* bug#29108: 25.3; ERC SASL support
       [not found]                         ` <878rkighkn.fsf@disroot.org>
@ 2022-11-14 22:29                           ` Adam Porter
  0 siblings, 0 replies; 54+ messages in thread
From: Adam Porter @ 2022-11-14 22:29 UTC (permalink / raw)
  To: Akib Azmain Turja, J.P.; +Cc: 29108, bandali, emacs-erc

Hi Akib,

On 11/10/22 23:56, Akib Azmain Turja wrote:
> "J.P." <jp@neverwas.me> writes:
> 
>> Adam Porter <adam@alphapapa.net> writes:
>>
>>> On 11/9/22 23:28, J.P. wrote:
>>>
>>>> Akib Azmain Turja <akib@disroot.org> writes:
>>>
>>>>> But Adam Porter (CC'ing), the maintainer of ement.el (available on GNU
>>>>> ELPA), a Matrix client, claims that auth-source is from the dark side[1]
>>>>> and refused to support it claiming it's not suitable for general use[2].
>>>
>>> Please note: I did not expect to be mentioned in this way here.  It's not my
>>> intention to speak poorly of others' software, especially in public.  In the
>>> Reddit post I made, I tried to be objective and show the problems clearly with
>>> code examples.
>>
>> That's certainly the impression I got, and I regret not having said as
>> much sooner. Sorry you had to burn cycles on a dignified defense. At the
>> same time, I'm hopeful folks will find the restraint to chalk this up to
>> a teachable moment and attribute Akib's bit of ambush editorializing
>> (something I myself have been guilty of over the years) to the angst of
>> youth or a moment of weakness, both potential engines of productivity
>> when channeled in a more positive direction.
> 
> I'm extremely sorry, I didn't actually wanted to give that impression.
> Please forgive me.

Again, no apology is necessary.  :)

>>> And that is merely my opinion, of course, based on the shortcomings I noted
>>> (e.g. the lack of API to update a secret, the undocumented error-handling
>>> signals, etc).  I expect that, were I to use it in my software, I would end up
>>> working around these problems and answering users' support questions about
>>> them; and since I don't use it myself, either, it doesn't seem like a good
>>> idea to do so.
>>>
>>> Nevertheless, it's clearly used by a number of people and third-party packages
>>> that integrate with it, so take my opinion of it with a grain of salt.  If it
>>> seems useful to you, by all means, use it.
> 
> Your works are awesome, thank you much for the work.

Thanks for the kind words.





^ permalink raw reply	[flat|nested] 54+ messages in thread

* bug#29108: 25.3; ERC SASL support
       [not found]                 ` <87y1sdk1fg.fsf@neverwas.me>
@ 2022-11-16 14:51                   ` J.P.
       [not found]                   ` <875yfflzps.fsf@neverwas.me>
  1 sibling, 0 replies; 54+ messages in thread
From: J.P. @ 2022-11-16 14:51 UTC (permalink / raw)
  To: 29108; +Cc: emacs-erc, bandali

Some last-minute thoughts on the previous iteration.

In 0005-Add-non-IRCv3-SASL-module-to-ERC.patch:

> 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
> [...]
> +
> +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.

An earlier version mentioned that users can customize per-network SASL
settings by let-binding `erc-sasl-*' options and `erc-modules' around
`erc-tls' invocations. I'm doubtful this is a sustainable approach and
would guess that a more declarative solution is the likeliest way
forward, perhaps something based on connection-local variables.

That said, I think users still need a practical solution to tide them
over in the short term, one that doesn't involve hooks or timers. And
since this module already takes a snapshot of its own options on
initialization (for reconnection purposes), we might as well revert to
mentioning let-binding, but this time also stress (maybe in an Advanced
chapter) that local modules are still being fleshed out and that
third-party implementations aren't yet encouraged.

> 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 @@
> [...]
> +;;
> +;; 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.

Just backtraces, not bug reports.

> [...]
> +
> +;; 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))))))
> +

We should probably register a local `erc-kill-server-hook' that deletes
whatever's stored in `erc-sasl--session-options' for the current network
context.

> [...]
> +
> +(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))
> +

We could just drop `erc-sasl--options' completely and use a getter to
retrieve values from the global `erc-sasl--session-options' store.
That'd be one less point of failure as far as password exposure is
concerned. And, in the future, whatever context-aware abstractions
emerge for making granular options a thing should obsolete all this
ugly stashing business anyhow.





^ permalink raw reply	[flat|nested] 54+ messages in thread

* bug#29108: 25.3; ERC SASL support
       [not found]                   ` <875yfflzps.fsf@neverwas.me>
@ 2022-11-17  6:30                     ` J.P.
       [not found]                     ` <877czuks8k.fsf@neverwas.me>
                                       ` (2 subsequent siblings)
  3 siblings, 0 replies; 54+ messages in thread
From: J.P. @ 2022-11-17  6:30 UTC (permalink / raw)
  To: 29108; +Cc: emacs-erc, bandali

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

v9 (v8.1). Rebase compat and main tests atop latest "URL integration"
changes.

Please excuse the hassle. (FWIW, there's no material difference outside
of commit summaries).


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

From 4f13e22adb20200bcc875b36ee4687dbc7dd2095 Mon Sep 17 00:00:00 2001
From: "F. Jason Park" <jp@neverwas.me>
Date: Wed, 16 Nov 2022 21:51:48 -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                                  |  20 +-
 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                               |  86 ++--
 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                    |  58 +++
 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, 1616 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

Range-diff:
1:  d1a5b8071b = 1:  2bdd6d498e Add GS2 authorization to sasl-scram-rfc
2:  9b31e5d96e = 2:  7cc800af5c Don't set erc-networks--id until network is known
3:  268e7593ba ! 3:  d9689f4919 Support local ERC modules in erc-mode buffers
    @@ Metadata
      ## Commit message ##
         Support local ERC modules in erc-mode buffers
     
    -    * doc/misc/erc.texi: Mention local modules in Modules Chapter.
    +    * 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.
    +    for finding local modules already active as minor modes 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-update-modules): Add optional param that changes 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
    +    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
    @@ Commit message
         * 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
    +    (define-erc-modules): Don't toggle local modules (minor modes) unless
    +    `erc-mode' is the major mode.  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.)
    +    erc-update-modules): Add rudimentary unit tests asserting correct
    +    module-name mappings.  (Bug#57955.)
     
      ## doc/misc/erc.texi ##
     @@ doc/misc/erc.texi: Modules
    @@ lisp/erc/erc-common.el: define-erc-module
                   ',(intern
     
      ## lisp/erc/erc-compat.el ##
    -@@ lisp/erc/erc-compat.el: erc-compat--with-memoization
    -     `(cl--generic-with-memoization ,table ,@forms))
    -    (t `(progn ,@forms))))
    +@@ lisp/erc/erc-compat.el: erc-compat--29-browse-url-irc
    +                  (cons '("\\`irc6?s?://" . erc-compat--29-browse-url-irc)
    +                        existing))))))
      
     +(defun erc-compat--local-module-modes ()
     +  (delq nil
    @@ lisp/erc/erc.el: erc-open
      
     
      ## test/lisp/erc/erc-tests.el ##
    -@@ test/lisp/erc/erc-tests.el: erc-message
    -     (kill-buffer "ExampleNet")
    +@@ test/lisp/erc/erc-tests.el: erc-handle-irc-url
    +     (kill-buffer "baznet")
          (kill-buffer "#chan")))
      
     +(ert-deftest erc-migrate-modules ()
4:  7dc4b37ba5 ! 4:  230377b28e Call erc-login indirectly via new generic wrapper
    @@ Metadata
      ## Commit message ##
         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.
    +    * lisp/erc/erc-backend (erc--register-connection): Add new internal
    +    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: erc-open-network-stream
     +  "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.
    + (defvar erc--server-connect-dumb-ipv6-regexp
    +   ;; Not for validation (gives false positives).
    +   (rx bot "[" (group (+ (any xdigit digit ":.")) (? "%" (+ alnum))) "]" eot))
     @@ lisp/erc/erc-backend.el: erc-server-connect
              ;; waiting for a non-blocking connect - keep the user informed
              (erc-display-message nil nil buffer "Opening connection..\n")
5:  675a593881 ! 5:  4f13e22adb Add non-IRCv3 SASL module to ERC
    @@ doc/misc/erc.texi: Top
      * Connecting::                  Ways of connecting to an IRC server.
     +* SASL::                        Authenticating via SASL.
      * Sample Configuration::        An example configuration file.
    + * Integrations::                Integrations available for ERC.
      * Options::                     Options that are available for ERC.
    - 
     @@ doc/misc/erc.texi: Modules
      @item ring
      Enable an input history
    @@ doc/misc/erc.texi: Advanced Usage
      * Connecting::                  Ways of connecting to an IRC server.
     +* SASL::                        Authenticating via SASL
      * Sample Configuration::        An example configuration file.
    + * Integrations::                Integrations available for ERC.
      * Options::                     Options that are available for ERC.
    - @end menu
     @@ doc/misc/erc.texi: Connecting
      @noindent
      For details, @pxref{Top,,auth-source, auth, Emacs auth-source Library}.
    @@ etc/ERC-NEWS: hell.  For some, auth-source may provide a workaround in the form
      Commands 'erc' and 'erc-tls' now accept a ':user' keyword argument,
     
      ## lisp/erc/erc-compat.el ##
    -@@ lisp/erc/erc-compat.el: erc-subseq
    - 	       res))))))
    +@@ lisp/erc/erc-compat.el: erc-compat--auth-source-backend-parser-functions
    +     auth-source-backend-parser-functions))
      
      
     +;;;; SASL
-- 
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 2bdd6d498e74ec508846d464a2b69d09965e7695 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 7cc800af5c610374ee381c30647e6af38bbe0a32 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 15fd6ac50f..ebfb4bb830 100644
--- a/lisp/erc/erc-backend.el
+++ b/lisp/erc/erc-backend.el
@@ -1619,7 +1619,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)
@@ -1629,6 +1629,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))))))
@@ -2255,6 +2258,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 b3e5fcf1a3..100d5b4b58 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))
@@ -1382,7 +1374,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)
@@ -1401,8 +1394,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 2312246e3e..95212182b5 100644
--- a/lisp/erc/erc.el
+++ b/lisp/erc/erc.el
@@ -2017,10 +2017,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
@@ -3179,7 +3181,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: 17365 bytes --]

From d9689f4919a281cc2b5613945856642e7e0472dd 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 already active as minor modes in an ERC
buffer.

* lisp/erc/erc.el (erc-migrate-modules): Add some missing mappings.
(erc-update-modules): Add optional param that changes 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 toggle local modules (minor modes) unless
`erc-mode' is the major mode.  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 asserting correct
module-name mappings.  (Bug#57955.)
---
 doc/misc/erc.texi          | 11 +++++-
 etc/ERC-NEWS               | 13 +++++++
 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, 181 insertions(+), 45 deletions(-)

diff --git a/doc/misc/erc.texi b/doc/misc/erc.texi
index 0d807e323e..dd15036b2e 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.
 
diff --git a/etc/ERC-NEWS b/etc/ERC-NEWS
index f638d4717a..f219c6d9e4 100644
--- a/etc/ERC-NEWS
+++ b/etc/ERC-NEWS
@@ -104,6 +104,19 @@ 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 enabling of local modules and instead
+return their mode commands.  'erc-open' leverages this to delay their
+activation, as well as that of all 'erc-mode-hook' members, until most
+local session variables have been initialized (minus those "server"-
+and process-focused ones in erc-backend).
+
 ** 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 23a1933798..b791866ee2 100644
--- a/lisp/erc/erc-common.el
+++ b/lisp/erc/erc-common.el
@@ -88,6 +88,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.
@@ -101,7 +136,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:
 
@@ -114,6 +151,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))))
@@ -137,16 +175,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 d23703394b..f7e6fb7aee 100644
--- a/lisp/erc/erc-compat.el
+++ b/lisp/erc/erc-compat.el
@@ -313,6 +313,18 @@ erc-compat--29-browse-url-irc
                  (cons '("\\`irc6?s?://" . erc-compat--29-browse-url-irc)
                        existing))))))
 
+(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 95212182b5..a4dbe0fc53 100644
--- a/lisp/erc/erc.el
+++ b/lisp/erc/erc.el
@@ -1791,10 +1791,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
@@ -1872,28 +1869,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'."
@@ -1951,18 +1945,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)))
@@ -2019,14 +2019,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))
@@ -2038,8 +2043,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 a5100ec155..a7117d4959 100644
--- a/test/lisp/erc/erc-tests.el
+++ b/test/lisp/erc/erc-tests.el
@@ -1178,4 +1178,62 @@ erc-handle-irc-url
     (kill-buffer "baznet")
     (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: 1951 bytes --]

From 230377b28e2979a340f9ef3a4b59005a1b074aad 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 internal
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 ebfb4bb830..4061522259 100644
--- a/lisp/erc/erc-backend.el
+++ b/lisp/erc/erc-backend.el
@@ -638,6 +638,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))
+
 (defvar erc--server-connect-dumb-ipv6-regexp
   ;; Not for validation (gives false positives).
   (rx bot "[" (group (+ (any xdigit digit ":.")) (? "%" (+ alnum))) "]" eot))
@@ -693,7 +697,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.
@@ -894,7 +898,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: 75178 bytes --]

From 4f13e22adb20200bcc875b36ee4687dbc7dd2095 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                               |  12 +-
 test/lisp/erc/erc-sasl-tests.el               | 319 +++++++++++++
 test/lisp/erc/erc-scenarios-sasl.el           | 208 +++++++++
 test/lisp/erc/erc-tests.el                    |  22 +-
 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, 1403 insertions(+), 21 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 dd15036b2e..d248051871 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.
 * Integrations::                Integrations available for ERC.
 * Options::                     Options that are available for ERC.
@@ -486,6 +487,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
@@ -533,6 +538,7 @@ Advanced Usage
 
 @menu
 * Connecting::                  Ways of connecting to an IRC server.
+* SASL::                        Authenticating via SASL
 * Sample Configuration::        An example configuration file.
 * Integrations::                Integrations available for ERC.
 * Options::                     Options that are available for ERC.
@@ -851,6 +857,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
@@ -863,7 +870,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
@@ -925,6 +933,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 f219c6d9e4..37b9928cf8 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 f7e6fb7aee..47299ee3cc 100644
--- a/lisp/erc/erc-compat.el
+++ b/lisp/erc/erc-compat.el
@@ -273,6 +273,110 @@ erc-compat--auth-source-backend-parser-functions
     auth-source-backend-parser-functions))
 
 
+;;;; 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 a4dbe0fc53..649f578853 100644
--- a/lisp/erc/erc.el
+++ b/lisp/erc/erc.el
@@ -1853,6 +1853,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"
@@ -1875,9 +1876,7 @@ erc-update-modules
 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))))
+  (let (local-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))))
@@ -1886,7 +1885,7 @@ erc-update-modules
         (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)
+            (push mode local-modes)
           (funcall mode 1))))))
 
 (defun erc-setup-buffer (buffer)
@@ -1956,7 +1955,10 @@ erc-open
     (when connect (run-hook-with-args 'erc-before-connect server port nick))
     (set-buffer buffer)
     (setq old-point (point))
-    (setq delayed-modules (erc-update-modules 'defer-locals))
+    (setq delayed-modules
+          (delete-dups (append (when continued-session
+                                 (erc-compat--local-module-modes))
+                               (erc-update-modules 'defer-locals))))
 
     (delay-mode-hooks (erc-mode))
 
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 a7117d4959..fecd17b10e 100644
--- a/test/lisp/erc/erc-tests.el
+++ b/test/lisp/erc/erc-tests.el
@@ -1223,17 +1223,17 @@ erc-update-modules
         (should (equal (nreverse calls) '( erc-pcomplete (completion . 1)
                                            erc-join (autojoin . 1)
                                            erc-networks (networks . 1))))
-        (setq calls nil))
+        (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)))))))))
+(ert-deftest erc-compat--local-module-modes ()
+  (with-temp-buffer
+    (if (< 27 emacs-major-version)
+        (let ((local-minor-modes '(font-lock-mode erc-fake-bar-mode)))
+          (should (equal (erc-compat--local-module-modes)
+                         '(erc-fake-bar-mode))))
+      (cl-letf (((symbol-function 'buffer-local-variables)
+                 (lambda (&rest _) '((font-lock-mode) (erc-fake-bar-mode)))))
+        (should (equal (erc-compat--local-module-modes)
+                       '(erc-fake-bar-mode)))))))
 
 ;;; erc-tests.el ends here
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


^ permalink raw reply related	[flat|nested] 54+ messages in thread

* bug#29108: 25.3; ERC SASL support
       [not found]                     ` <877czuks8k.fsf@neverwas.me>
@ 2022-11-17 15:28                       ` J.P.
  0 siblings, 0 replies; 54+ messages in thread
From: J.P. @ 2022-11-17 15:28 UTC (permalink / raw)
  To: 29108; +Cc: emacs-erc, bandali

A couple potential UX concerns likely need addressing:

1. Say a user wants to change an SASL option after quitting a successful
   session. (Maybe they've added a cert fingerprint and want to try out
   EXTERNAL.) So they set that new option (globally or locally, doesn't
   matter) and attempt to /reconnect. Unfortunately, the module won't
   notice the change and will end up reusing the same parameters again.
   Granted, this isn't the end of the world, especially if the user
   knows that trying from scratch with a fresh `erc-tls' invocation is
   the way to go. But we should probably still make it a point to
   mention this somewhere.

2. Say a user who's normally cloaked is struggling to configure SASL.
   They manage to connect and obtain their desired nick, but only
   provisionally (the timeout is usually something like 30 seconds).
   Unfortunately, if they don't see the warning or can't get their act
   together before being renicked and autojoined, they'll end up sharing
   their IP.

   But what about if they've configured SASL correctly?

   It's not unheard of for a server to drop a client during registration
   for reasons unrelated to authentication, like resource pressure. But
   this module currently only stashes SASL params after making a
   successful (logical) connection, meaning perfectly good settings
   might be thrown away. In and of itself, that's arguably fine, but not
   so much when combined with auto-reconnect timers (which are then
   forced to rely solely on SASL defaults and whatever entry-point
   params and global options happen to be in play). Which ultimately
   leads to provisional nicks and leaked IPs.

   Luckily, potential remedies appear to exist. One might involve local
   handler hooks to just drop the connection on all nick-rejection
   numerics. Another might be adding some conditional reconnect logic to
   the module-init procedure that only proceeds when SASL options from a
   previously successful session are safely on the books.


BTW, also spotted a small UI issue. Something like this should fix it:

diff --git a/lisp/erc/erc-sasl.el b/lisp/erc/erc-sasl.el
index d8ef600351..227ca008ad 100644
--- a/lisp/erc/erc-sasl.el
+++ b/lisp/erc/erc-sasl.el
@@ -347,7 +347,7 @@ erc-sasl--authenticate-handler
    (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")))
+   (s908 . "RPL_SASLMECHS (unsupported mechanism: %m) %s")))
 
 (define-erc-module sasl nil
   "Non-IRCv3 SASL support for ERC.
@@ -411,8 +411,9 @@ erc-sasl--destroy
 (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))
+                       ?m (alist-get 'mechanism erc-sasl--options)
+                       ?s (string-join (cdr (erc-response.command-args parsed))
+                                       " "))
   (erc-sasl--destroy proc))
 
 (cl-defmethod erc--register-connection (&context (erc-sasl-mode (eql t)))





^ permalink raw reply related	[flat|nested] 54+ messages in thread

* bug#29108: 25.3; ERC SASL support
       [not found]                   ` <875yfflzps.fsf@neverwas.me>
  2022-11-17  6:30                     ` J.P.
       [not found]                     ` <877czuks8k.fsf@neverwas.me>
@ 2022-11-18  2:26                     ` J.P.
       [not found]                     ` <878rk9576b.fsf@neverwas.me>
  3 siblings, 0 replies; 54+ messages in thread
From: J.P. @ 2022-11-18  2:26 UTC (permalink / raw)
  To: 29108; +Cc: emacs-erc, bandali

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

> An earlier version mentioned that users can customize per-network SASL
> settings by let-binding `erc-sasl-*' options and `erc-modules' around
> `erc-tls' invocations. I'm doubtful this is a sustainable approach and
> would guess that a more declarative solution is the likeliest way
> forward, perhaps something based on connection-local variables.
> 
> That said, I think users still need a practical solution to tide them
> over in the short term, one that doesn't involve hooks or timers.

Actually, now I'm thinking an even earlier version (from bug#49860) had
the most sensible approach for per-network SASL settings all along, and
that's simply recognizing `:user' and `:password' symbols as values for
`erc-sasl-user' and `erc-sasl-password' (possibly even as defaults). The
module then just uses whatever's stored in `erc-session-username' and
`erc-session-password' (and obviously inhibits the sending of a server
password).

> And since this module already takes a snapshot of its own options on
> initialization (for reconnection purposes), we might as well revert to
> mentioning let-binding, but this time also stress (maybe in an
> Advanced chapter) that local modules are still being fleshed out and
> that third-party implementations aren't yet encouraged.

Given the other realization above, I guess it's no surprise that I've
again flip-flopped on mentioning let-binding and have reverted to the
opinion that we shouldn't officially commit to local modules at all and
should minimize advertising their presence and behavior until we've
arrived at a workable solution for granular (network/nick/channel-aware)
user options.





^ permalink raw reply	[flat|nested] 54+ messages in thread

* bug#29108: 25.3; ERC SASL support
       [not found]                     ` <878rk9576b.fsf@neverwas.me>
@ 2022-11-18 14:06                       ` J.P.
       [not found]                       ` <87leo8z79j.fsf@neverwas.me>
  1 sibling, 0 replies; 54+ messages in thread
From: J.P. @ 2022-11-18 14:06 UTC (permalink / raw)
  To: 29108; +Cc: emacs-erc, bandali

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

v10. Added `:user' and `:password' values for main options. Simplified
approach to persisting options generally. Reduced mention of local
modules.


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

From a06e72aca3f14d903f5716f844067d3a224919cd Mon Sep 17 00:00:00 2001
From: "F. Jason Park" <jp@neverwas.me>
Date: Fri, 18 Nov 2022 00:11:15 -0800
Subject: [PATCH 0/6] *** NOT A PATCH ***

*** BLURB HERE ***

F. Jason Park (6):
  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
  Accept functions in place of passwords in ERC

 doc/misc/erc.texi                             | 161 ++++++-
 etc/ERC-NEWS                                  |  15 +-
 lisp/erc/erc-backend.el                       |  18 +-
 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                          | 429 ++++++++++++++++++
 lisp/erc/erc-services.el                      |   5 +-
 lisp/erc/erc.el                               | 125 +++--
 lisp/net/sasl-scram-rfc.el                    |  21 +-
 test/lisp/erc/erc-sasl-tests.el               | 344 ++++++++++++++
 test/lisp/erc/erc-scenarios-sasl.el           | 202 +++++++++
 test/lisp/erc/erc-services-tests.el           |  16 +-
 test/lisp/erc/erc-tests.el                    |  80 ++++
 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 ++
 20 files changed, 1712 insertions(+), 98 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 d248051871..790db1135e 100644
--- a/doc/misc/erc.texi
+++ b/doc/misc/erc.texi
@@ -611,6 +611,7 @@ Connecting
 parameters, and some, like @code{client-certificate}, will just be
 @code{nil}.
 
+@anchor{ERC client-certificate}
 To use a certificate with @code{erc-tls}, specify the optional
 @var{client-certificate} keyword argument, whose value should be as
 described in the documentation of @code{open-network-stream}: if
@@ -745,7 +746,10 @@ Connecting
 You can manually set another nickname with the /NICK command.
 @end defopt
 
+@anchor{ERC username}
 @subheading User
+@cindex user
+
 @defun erc-compute-user &optional user
 Determine a suitable value to send as the first argument of the
 opening @samp{USER} IRC command by consulting the following sources:
@@ -937,21 +941,11 @@ 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
+know that you probably won't be needing SASL for the client-to-bouncer
+connection.  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.
 
@@ -962,35 +956,35 @@ SASL
 
 @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}.
+your @samp{NickServ} password.  To make this work, customize
+@code{erc-sasl-user} and @code{erc-sasl-password} or specify the
+@code{:user} and @code{:password} keyword arguments when invoking
+@code{erc-tls}.  Note that @code{:user} cannot be given interactively.
 @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}).
+This works in conjunction with the @code{:client-certificate} keyword
+offered by @code{erc-tls}.  Just ensure you've registered your
+fingerprint with the network beforehand.  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}).  These days, there's
+usually a @samp{CERT ADD} command offered by NickServ that can
+register you automatically if you issue it while connected with a
+client cert.  (@pxref{ERC client-certificate}).
 
 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.
+the specific application or service, there's a remote chance your
+server has something else in mind.
 @end enumerate
 @end indentedblock
 
@@ -1015,33 +1009,33 @@ SASL
 @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.
+This should be your network account username, typically the same one
+registered with nickname services.  Specify this when your NickServ
+login differs from the @code{:user} you're connecting with.
+(@pxref{ERC username})
 @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.
+As noted elsewhere, the @code{:password} parameter for @code{erc-tls}
+was orignally intended for traditional ``server passwords,'' but these
+aren't really used any more.  As such, this option defaults to
+borrowing that parameter for its own uses, thus allowing you to call
+@code{erc-tls} with @code{:password} set to your NickServ password.
+
+You can also set this to a nonemtpy string, and ERC will send that
+when needed, no questions asked.  If you instead give a non-@code{nil}
+symbol (other than @code{:password}), 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
+For all such queries, ERC specifies the resolved 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, and it's
+@code{nil} by default.  As a last resort, ERC will prompt you for
+input.
+
+Lastly, if your mechanism is @code{ecdsa-nist256p-challenge}, this
 option should instead hold the file name of your key.
 @end defopt
 
@@ -1060,6 +1054,25 @@ SASL
 leave this set to @code{nil}.
 @end defopt
 
+@subheading Troubleshooting
+
+@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're struggling, remember that your SASL password is almost
+always your NickServ password.  When in doubt, try restoring all SASL
+options to their defaults and calling @code{erc-tls} with @code{:user}
+set to your NickServ account name and @code{:password} to your
+NickServ password.  If you're still having trouble, please contact us
+(@pxref{Getting Help and Reporting Bugs}).
+
+And if, for whatever reason, you do find yourself trying out
+non-default SASL settings, keep in mind that every change requires a
+fresh session, so you'll want to call @code{erc-tls} from scratch
+again rather than rely on @samp{/reconnect} or the auto-reconnect
+facility.  In fact, it's best to temporarily set
+@code{erc-server-auto-reconnect} to @code{nil} while experimenting.
 
 @node Sample Configuration
 @section Sample Configuration
diff --git a/etc/ERC-NEWS b/etc/ERC-NEWS
index 37b9928cf8..829ef25a47 100644
--- a/etc/ERC-NEWS
+++ b/etc/ERC-NEWS
@@ -103,18 +103,13 @@ 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 enabling of local modules and instead
-return their mode commands.  'erc-open' leverages this to delay their
-activation, as well as that of all 'erc-mode-hook' members, until most
-local session variables have been initialized (minus those "server"-
-and process-focused ones in erc-backend).
+** ERC-mode hooks are more useful.
+The function 'erc-update-modules' now supports an optional argument to
+defer enabling of local modules and instead return their mode
+commands.  'erc-open' relies on this to delay their activation, as
+well as that of all 'erc-mode-hook' members, until most local session
+variables have been initialized (minus those "server"- and
+process-focused ones in erc-backend).
 
 ** Miscellaneous behavioral changes in the library API.
 A number of core macros and other definitions have been moved to a new
diff --git a/lisp/erc/erc-sasl.el b/lisp/erc/erc-sasl.el
index d8ef600351..aabb6c8a51 100644
--- a/lisp/erc/erc-sasl.el
+++ b/lisp/erc/erc-sasl.el
@@ -30,7 +30,7 @@
 ;;
 ;; - Find a way to obfuscate the password in memory (via something
 ;;   like `auth-source--obfuscate'); it's currently visible in
-;;   backtraces and bug reports.
+;;   backtraces.
 ;;
 ;; - Implement a proxy mechanism that chooses the strongest available
 ;;   mechanism for you.  Requires CAP 3.2 (see bug#49860).
@@ -65,39 +65,44 @@ erc-sasl-mechanism
                  (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
+(defcustom erc-sasl-user :user
+  "Account username to send when authenticating.
+This is also referred to as the authentication identity or
+\"authcid\".  A value of `:user' or `:nick' indicates that the
+corresponding connection parameter on file should be used.  These
+are most often derived from arguments provided to the `erc' and
+`erc-tls' entry points.  In the case of `:nick', a downcased
+version is used."
+  :type '(choice string (const :user) (const :nick)))
+
+(defcustom erc-sasl-password :password
   "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))
+most mechanisms.  Likewise with `:password', except ERC will
+instead use the \"session password\" on file, which often
+originates from the entry-point commands `erc' or `erc-tls'.
+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 prompt for input.
+
+Note that, with `:password', ERC will forgo sending a traditional
+server password via the IRC \"PASS\" command.  Also, when
+`erc-sasl-mechanism' is set to `ecdsa-nist256p-challenge', this
+option should hold the file name of the key."
+  :type '(choice (const nil) (const :password) 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."
+includes `erc-sasl-user' for the `:user' field and
+`erc-sasl-password' for the `:host' field, when the latter option
+is a non-nil, non-keyword symbol.  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))
@@ -110,13 +115,6 @@ erc-sasl-authzid
 ;; 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)
 
@@ -126,23 +124,27 @@ erc-sasl--state
   (step nil :type vector)
   (pending nil :type string))
 
+(defun erc-sasl--get-user ()
+  (pcase (alist-get 'user erc-sasl--options)
+    (:user erc-session-username)
+    (:nick (erc-downcase (erc-current-nick)))
+    (v v)))
+
 (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 (erc--unfun found))
-      (read-passwd prompt))))
+  (if-let
+      ((found (pcase (alist-get 'password erc-sasl--options)
+                (:password erc-session-password)
+                ((and (pred stringp) v) (unless (string-empty-p v) v))
+                ((and (guard erc-sasl-auth-source-function)
+                      v (let host
+                          (or v (erc-networks--id-given erc-networks--id))))
+                 (apply erc-sasl-auth-source-function
+                        :user (erc-sasl--get-user)
+                        (and host (list :host (symbol-name host))))))))
+      (copy-sequence (erc--unfun found))
+    (read-passwd prompt)))
 
 (defun erc-sasl--plain-response (client steps)
   (let ((sasl-read-passphrase #'erc-sasl--read-password))
@@ -228,22 +230,20 @@ erc-sasl--create-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)))
+      (setq client (sasl-make-client (sasl-find-mechanism (list name))
+                                     (erc-sasl--get-user)
                                      "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)))
+(cl-defmethod erc-sasl--create-client ((_ (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))))
+         (authc (erc-sasl--get-user))
          (port (if (numberp erc-session-port)
                    (number-to-string erc-session-port)
                  "0"))
@@ -255,58 +255,51 @@ erc-sasl--create-client
                               (alist-get 'authzid erc-sasl--options))
     client))
 
-(cl-defmethod erc-sasl--create-client ((m (eql scram-sha-256)))
+(cl-defmethod erc-sasl--create-client ((_ (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))
+  (when (featurep 'sasl-scram-sha256)
+    (cl-call-next-method)))
 
-(cl-defmethod erc-sasl--create-client ((m (eql scram-sha-512)))
+(cl-defmethod erc-sasl--create-client ((_ (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))
+  (when (featurep 'sasl-scram-sha256)
+    (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)))
+    ;; Better to signal usage errors now than inside a process filter.
+    (cond ((or (not (stringp keyfile)) (not (file-readable-p keyfile)))
+           (erc-display-error-notice
+            nil "`erc-sasl-password' not accessible as a file")
+           nil)
+          ((not (executable-find "openssl"))
+           (erc-display-error-notice nil "Could not find openssl program")
+           nil)
+          (t
+           (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))
+  (setq erc-sasl--state (make-erc-sasl--state))
+  ;; If the previous attempt failed during registration, this may be
+  ;; non-nil and contain erroneous values, but how can we detect that?
+  ;; What if the server dropped the connection for some other reason?
+  (setq erc-sasl--options
+        (or (and (consp erc--server-reconnecting)
+                 (alist-get 'erc-sasl--options erc--server-reconnecting))
+            `((user . ,erc-sasl-user)
+              (password . ,erc-sasl-password)
+              (mechanism . ,erc-sasl-mechanism)
+              (authzid . ,erc-sasl-authzid)))))
 
 (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))
+                       ,(symbol-name (alist-get 'mechanism erc-sasl--options))
                        (| eot ",")))
                   (downcase offered)))
 
@@ -347,7 +340,7 @@ erc-sasl--authenticate-handler
    (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")))
+   (s908 . "RPL_SASLMECHS (unsupported mechanism: %m) %s")))
 
 (define-erc-module sasl nil
   "Non-IRCv3 SASL support for ERC.
@@ -362,12 +355,11 @@ sasl
             (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))
+          nil (format "Unknown or unsupported SASL mechanism: %s" mech))
+         (erc-error "Unknown or unsupported 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)
@@ -393,7 +385,6 @@ erc-sasl--destroy
   (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)
@@ -411,8 +402,9 @@ erc-sasl--destroy
 (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))
+                       ?m (alist-get 'mechanism erc-sasl--options)
+                       ?s (string-join (cdr (erc-response.command-args parsed))
+                                       " "))
   (erc-sasl--destroy proc))
 
 (cl-defmethod erc--register-connection (&context (erc-sasl-mode (eql t)))
@@ -421,7 +413,11 @@ erc--register-connection
             (m (sasl-mechanism-name (sasl-client-mechanism c))))
       (progn
         (erc-server-send "CAP REQ :sasl")
-        (erc-login)
+        (if (and erc-session-password
+                 (eq :password (alist-get 'password erc-sasl--options)))
+            (let (erc-session-password)
+              (erc-login))
+          (erc-login))
         (erc-server-send (format "AUTHENTICATE %s" m)))
     (erc-sasl--destroy erc-server-process)))
 
diff --git a/lisp/erc/erc.el b/lisp/erc/erc.el
index ebec8846b1..60bfb909e0 100644
--- a/lisp/erc/erc.el
+++ b/lisp/erc/erc.el
@@ -1951,7 +1951,7 @@ erc-open
                                  (with-suppressed-warnings
                                      ((obsolete erc-reuse-buffers))
                                    erc-reuse-buffers)
-                                 erc-networks--id)))
+                                 (buffer-local-variables))))
     (when connect (run-hook-with-args 'erc-before-connect server port nick))
     (set-buffer buffer)
     (setq old-point (point))
@@ -2021,7 +2021,8 @@ erc-open
     (setq erc-session-client-certificate client-certificate)
     (setq erc-networks--id
           (if connect
-              (or erc--server-reconnecting
+              (or (and erc--server-reconnecting
+                       (alist-get 'erc-networks--id erc--server-reconnecting))
                   (and id (erc-networks--id-create id)))
             (buffer-local-value 'erc-networks--id old-buffer)))
     ;; debug output buffer
diff --git a/test/lisp/erc/erc-sasl-tests.el b/test/lisp/erc/erc-sasl-tests.el
index 81db9ad948..20a6760083 100644
--- a/test/lisp/erc/erc-sasl-tests.el
+++ b/test/lisp/erc/erc-sasl-tests.el
@@ -32,61 +32,83 @@ erc-sasl--mechanism-offered-p
     (should-not (erc-sasl--mechanism-offered-p "fooexternal"))
     (should-not (erc-sasl--mechanism-offered-p "externalbar"))))
 
-(ert-deftest erc-sasl--read-password ()
+(ert-deftest erc-sasl--read-password--basic ()
   (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")))
+  (ert-info ("Explicit session password")
+    (let ((erc-session-password "foo")
+          (erc-sasl--options '((password . :password))))
+      (should (string= (erc-sasl--read-password nil) "foo"))))
+
+  (ert-info ("Fallback to prompt skip auth-source")
+    (should-not erc-sasl-auth-source-function)
     (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"))))
+      (should (string= (ert-simulate-keys "bar\r"
+                         (erc-sasl--read-password "?"))
+                       "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)
+  (ert-info ("Prompt when auth-source fails and `erc-sasl-password' null")
+    (let ((erc-sasl--options '((password)))
+          (erc-sasl-auth-source-function #'ignore))
+      (should (string= (ert-simulate-keys "baz\r"
+                         (erc-sasl--read-password "pwd:"))
+                       "baz")))))
 
-    (unwind-protect
-        (ert-info ("Auth source")
+(ert-deftest erc-sasl--read-password--auth-source ()
+  (ert-with-temp-file netrc-file
+    :text (string-join
+           (list
+            ;; If you swap these first 2 lines, *1 below fails
+            "machine FSF.chat port 6697 user bob password sesame"
+            "machine GNU/chat port 6697 user bob password spam"
+            "machine MyHost port irc password 123")
+           "\n")
+    (let* ((auth-sources (list netrc-file))
+           (erc-session-server "irc.gnu.org")
+           (erc-session-port 6697)
+           (erc-networks--id (erc-networks--id-create nil))
+           calls
+           (erc-sasl-auth-source-function
+            (lambda (&rest r)
+              (push r calls)
+              (apply #'erc--auth-source-search r)))
+           erc-server-announced-name ; too early
+           auth-source-do-cache)
 
-          (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 ("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"))
+          (should (equal (pop calls) '(:user "bob" :host "FSF.chat")))))
 
-          (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")))))
+      (ert-info ("ID for :host and `erc-session-username' for :user") ; *1
+        (let ((erc-session-username "bob")
+              (erc-sasl--options '((user . :user) (password)))
+              (erc-networks--id (erc-networks--id-create 'GNU/chat)))
+          (should (string= (erc-sasl--read-password nil) "spam"))
+          (should (equal (pop calls) '(:user "bob" :host "GNU/chat")))))
 
-      (delete-file netrc-file))
+      (ert-info ("ID for :host and current nick for :user") ; *1
+        (let ((erc-server-current-nick "bob")
+              (erc-sasl--options '((user . :nick) (password)))
+              (erc-networks--id (erc-networks--id-create 'GNU/chat)))
+          (should (string= (erc-sasl--read-password nil) "spam"))
+          (should (equal (pop calls) '(:user "bob" :host "GNU/chat")))))
 
-    (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-info ("Symbol as password, entry lacks user field")
+        (let ((erc-server-current-nick "fake")
+              (erc-sasl--options '((user . :nick) (password . MyHost)))
+              (erc-networks--id (erc-networks--id-create 'GNU/chat)))
+          (should (string= (erc-sasl--read-password nil) "123"))
+          (should (equal (pop calls) '(:user "fake" :host "MyHost"))))))))
 
 (ert-deftest erc-sasl-create-client--plain ()
   (let* ((erc-session-password "password123")
-         (erc-server-current-nick "tester")
+         (erc-session-username "tester")
+         (erc-sasl--options '((user . :user) (password . :password)))
          (erc-session-port 1667)
          (erc-session-server "localhost")
          (client (erc-sasl--create-client 'plain))
@@ -100,7 +122,8 @@ erc-sasl-create-client--plain
 
 (ert-deftest erc-sasl-create-client--external ()
   (let* ((erc-server-current-nick "tester")
-         (client (erc-sasl--create-client 'external))
+         (erc-sasl--options '((user . :nick) (password . :password)))
+         (client (erc-sasl--create-client 'external)) ; unused ^
          (result (sasl-next-step client nil)))
     (should (equal (format "%S" [ignore nil]) (format "%S" result)))
     (should-not (sasl-step-data result))
@@ -109,9 +132,8 @@ erc-sasl-create-client--external
   (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")))
+  (let* ((erc-sasl--options '((user . "jilles") (password . "sesame")
+                              (authzid . "jilles")))
          (mock-rvs (list "c5RqLCZy0L4fGkKAZ0hujFBs" ""))
          (sasl-unique-id-function (lambda () (pop mock-rvs)))
          (client (erc-sasl--create-client 'scram-sha-1))
@@ -149,7 +171,8 @@ erc-sasl-create-client--scram-sha-256
     (ert-skip "Emacs lacks sasl-scram-sha256"))
   (let* ((erc-server-current-nick "jilles")
          (erc-session-password "sesame")
-         (erc-sasl--options '((authzid . "jilles")))
+         (erc-sasl--options '((user . :nick) (password . :password)
+                              (authzid . "jilles")))
          (mock-rvs (list "c5RqLCZy0L4fGkKAZ0hujFBs" ""))
          (sasl-unique-id-function (lambda () (pop mock-rvs)))
          (client (erc-sasl--create-client 'scram-sha-256))
@@ -189,6 +212,7 @@ erc-sasl-create-client--scram-sha-256--no-authzid
     (ert-skip "Emacs lacks sasl-scram-sha256"))
   (let* ((erc-server-current-nick "jilles")
          (erc-session-password "sesame")
+         (erc-sasl--options '((user . :nick) (password . :password) (authzid)))
          (mock-rvs (list "c5RqLCZy0L4fGkKAZ0hujFBs" ""))
          (sasl-unique-id-function (lambda () (pop mock-rvs)))
          (client (erc-sasl--create-client 'scram-sha-256))
@@ -228,6 +252,7 @@ erc-sasl-create-client--scram-sha-512--no-authzid
     (ert-skip "Emacs lacks sasl-scram-sha512"))
   (let* ((erc-server-current-nick "jilles")
          (erc-session-password "sesame")
+         (erc-sasl--options '((user . :nick) (password . :password) (authzid)))
          (mock-rvs (list "c5RqLCZy0L4fGkKAZ0hujFBs" ""))
          (sasl-unique-id-function (lambda () (pop mock-rvs)))
          (client (erc-sasl--create-client 'scram-sha-512))
diff --git a/test/lisp/erc/erc-scenarios-sasl.el b/test/lisp/erc/erc-scenarios-sasl.el
index 7970e65ec2..713c9929c3 100644
--- a/test/lisp/erc/erc-scenarios-sasl.el
+++ b/test/lisp/erc/erc-scenarios-sasl.el
@@ -39,9 +39,7 @@ erc-scenarios-sasl--plain
        (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)))
 
@@ -71,7 +69,6 @@ erc-scenarios-sasl--local-modules-reconnect
        (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)))
 
@@ -94,7 +91,6 @@ erc-scenarios-sasl--local-modules-reconnect
         (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)))
@@ -114,7 +110,6 @@ erc-scenarios-sasl--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)))
 
@@ -144,7 +139,6 @@ erc-scenarios-sasl--plain-fail
        (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))
@@ -172,9 +166,8 @@ erc-scenarios--common--sasl
        (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-user :nick)
        (erc-sasl-mechanism mech)
-       (erc-sasl--session-options nil)
        (mock-rvs (list "c5RqLCZy0L4fGkKAZ0hujFBs" ""))
        (sasl-unique-id-function (lambda () (pop mock-rvs)))
        (inhibit-message noninteractive)
@@ -184,6 +177,7 @@ erc-scenarios--common--sasl
       (with-current-buffer (erc :server "127.0.0.1"
                                 :port port
                                 :nick "jilles"
+                                :password "sesame"
                                 :full-name "jilles")
         (should (string= (buffer-name) (format "127.0.0.1:%d" port)))))
 
-- 
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 2bdd6d498e74ec508846d464a2b69d09965e7695 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/6] 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 7cc800af5c610374ee381c30647e6af38bbe0a32 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/6] 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 15fd6ac50f..ebfb4bb830 100644
--- a/lisp/erc/erc-backend.el
+++ b/lisp/erc/erc-backend.el
@@ -1619,7 +1619,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)
@@ -1629,6 +1629,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))))))
@@ -2255,6 +2258,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 b3e5fcf1a3..100d5b4b58 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))
@@ -1382,7 +1374,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)
@@ -1401,8 +1394,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 2312246e3e..95212182b5 100644
--- a/lisp/erc/erc.el
+++ b/lisp/erc/erc.el
@@ -2017,10 +2017,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
@@ -3179,7 +3181,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: 17223 bytes --]

From fea3ac6fcc199578ccf7c63f2a6b5685473a7c1e 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/6] 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 already active as minor modes in an ERC
buffer.

* lisp/erc/erc.el (erc-migrate-modules): Add some missing mappings.
(erc-update-modules): Add optional param that changes 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 toggle local modules (minor modes) unless
`erc-mode' is the major mode.  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 asserting correct
module-name mappings.  (Bug#57955.)
---
 doc/misc/erc.texi          | 11 +++++-
 etc/ERC-NEWS               |  8 ++++
 lisp/erc/erc-common.el     | 56 +++++++++++++++++++++++----
 lisp/erc/erc-compat.el     | 12 ++++++
 lisp/erc/erc-goodies.el    |  1 +
 lisp/erc/erc.el            | 77 ++++++++++++++++++++------------------
 test/lisp/erc/erc-tests.el | 58 ++++++++++++++++++++++++++++
 7 files changed, 178 insertions(+), 45 deletions(-)

diff --git a/doc/misc/erc.texi b/doc/misc/erc.texi
index 0d807e323e..dd15036b2e 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.
 
diff --git a/etc/ERC-NEWS b/etc/ERC-NEWS
index f638d4717a..4c4b154dca 100644
--- a/etc/ERC-NEWS
+++ b/etc/ERC-NEWS
@@ -104,6 +104,14 @@ messages during periods of heavy traffic no longer disappear.
 Although rare, server passwords containing white space are now handled
 correctly.
 
+** ERC-mode hooks are more useful.
+The function 'erc-update-modules' now supports an optional argument to
+defer enabling of local modules and instead return their mode
+commands.  'erc-open' relies on this to delay their activation, as
+well as that of all 'erc-mode-hook' members, until most local session
+variables have been initialized (minus those "server"- and
+process-focused ones in erc-backend).
+
 ** 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 23a1933798..b791866ee2 100644
--- a/lisp/erc/erc-common.el
+++ b/lisp/erc/erc-common.el
@@ -88,6 +88,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.
@@ -101,7 +136,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:
 
@@ -114,6 +151,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))))
@@ -137,16 +175,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 d23703394b..f7e6fb7aee 100644
--- a/lisp/erc/erc-compat.el
+++ b/lisp/erc/erc-compat.el
@@ -313,6 +313,18 @@ erc-compat--29-browse-url-irc
                  (cons '("\\`irc6?s?://" . erc-compat--29-browse-url-irc)
                        existing))))))
 
+(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 95212182b5..ef70aa1a21 100644
--- a/lisp/erc/erc.el
+++ b/lisp/erc/erc.el
@@ -1791,10 +1791,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
@@ -1872,28 +1869,23 @@ 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)
+    (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)))
+            (push mode local-modes)
+          (funcall mode 1))))))
 
 (defun erc-setup-buffer (buffer)
   "Consults `erc-join-buffer' to find out how to display `BUFFER'."
@@ -1951,18 +1943,27 @@ 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)
+                                 (buffer-local-variables))))
     (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
+          (delete-dups (append (when continued-session
+                                 (erc-compat--local-module-modes))
+                               (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)))
@@ -2019,14 +2020,20 @@ 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 (and erc--server-reconnecting
+                       (alist-get 'erc-networks--id 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))
@@ -2038,8 +2045,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 a5100ec155..fecd17b10e 100644
--- a/test/lisp/erc/erc-tests.el
+++ b/test/lisp/erc/erc-tests.el
@@ -1178,4 +1178,62 @@ erc-handle-irc-url
     (kill-buffer "baznet")
     (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-deftest erc-compat--local-module-modes ()
+  (with-temp-buffer
+    (if (< 27 emacs-major-version)
+        (let ((local-minor-modes '(font-lock-mode erc-fake-bar-mode)))
+          (should (equal (erc-compat--local-module-modes)
+                         '(erc-fake-bar-mode))))
+      (cl-letf (((symbol-function 'buffer-local-variables)
+                 (lambda (&rest _) '((font-lock-mode) (erc-fake-bar-mode)))))
+        (should (equal (erc-compat--local-module-modes)
+                       '(erc-fake-bar-mode)))))))
+
 ;;; 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: 1951 bytes --]

From 39d2c0c61f6b5e858e297595d79882a75a993184 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/6] Call erc-login indirectly via new generic wrapper

* lisp/erc/erc-backend (erc--register-connection): Add new internal
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 ebfb4bb830..4061522259 100644
--- a/lisp/erc/erc-backend.el
+++ b/lisp/erc/erc-backend.el
@@ -638,6 +638,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))
+
 (defvar erc--server-connect-dumb-ipv6-regexp
   ;; Not for validation (gives false positives).
   (rx bot "[" (group (+ (any xdigit digit ":.")) (? "%" (+ alnum))) "]" eot))
@@ -693,7 +697,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.
@@ -894,7 +898,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: 74321 bytes --]

From 395ae5814b44215ddd6fa025e24124cddee5365d 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/6] 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                             | 150 +++++-
 etc/ERC-NEWS                                  |   7 +-
 lisp/erc/erc-compat.el                        | 104 +++++
 lisp/erc/erc-sasl.el                          | 429 ++++++++++++++++++
 lisp/erc/erc.el                               |   1 +
 test/lisp/erc/erc-sasl-tests.el               | 344 ++++++++++++++
 test/lisp/erc/erc-scenarios-sasl.el           | 202 +++++++++
 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 ++
 12 files changed, 1414 insertions(+), 5 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 dd15036b2e..790db1135e 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.
 * Integrations::                Integrations available for ERC.
 * Options::                     Options that are available for ERC.
@@ -486,6 +487,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
@@ -533,6 +538,7 @@ Advanced Usage
 
 @menu
 * Connecting::                  Ways of connecting to an IRC server.
+* SASL::                        Authenticating via SASL
 * Sample Configuration::        An example configuration file.
 * Integrations::                Integrations available for ERC.
 * Options::                     Options that are available for ERC.
@@ -605,6 +611,7 @@ Connecting
 parameters, and some, like @code{client-certificate}, will just be
 @code{nil}.
 
+@anchor{ERC client-certificate}
 To use a certificate with @code{erc-tls}, specify the optional
 @var{client-certificate} keyword argument, whose value should be as
 described in the documentation of @code{open-network-stream}: if
@@ -739,7 +746,10 @@ Connecting
 You can manually set another nickname with the /NICK command.
 @end defopt
 
+@anchor{ERC username}
 @subheading User
+@cindex user
+
 @defun erc-compute-user &optional user
 Determine a suitable value to send as the first argument of the
 opening @samp{USER} IRC command by consulting the following sources:
@@ -851,6 +861,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
@@ -863,7 +874,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
@@ -925,6 +937,142 @@ Connecting
 make the most sense, but any reasonably printable object is
 acceptable.
 
+@node SASL
+@section Authenticating via SASL
+@cindex SASL
+
+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 probably won't be needing SASL for the client-to-bouncer
+connection.  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.  To make this work, customize
+@code{erc-sasl-user} and @code{erc-sasl-password} or specify the
+@code{:user} and @code{:password} keyword arguments when invoking
+@code{erc-tls}.  Note that @code{:user} cannot be given interactively.
+@end indentedblock
+
+@var{external} (via Client TLS Certificate):
+
+@indentedblock
+This works in conjunction with the @code{:client-certificate} keyword
+offered by @code{erc-tls}.  Just ensure you've registered your
+fingerprint with the network beforehand.  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}).  These days, there's
+usually a @samp{CERT ADD} command offered by NickServ that can
+register you automatically if you issue it while connected with a
+client cert.  (@pxref{ERC client-certificate}).
+
+Additional considerations:
+@enumerate
+@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 a remote chance your
+server has something else in mind.
+@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 username, typically the same one
+registered with nickname services.  Specify this when your NickServ
+login differs from the @code{:user} you're connecting with.
+(@pxref{ERC username})
+@end defopt
+
+@defopt erc-sasl-password
+As noted elsewhere, the @code{:password} parameter for @code{erc-tls}
+was orignally intended for traditional ``server passwords,'' but these
+aren't really used any more.  As such, this option defaults to
+borrowing that parameter for its own uses, thus allowing you to call
+@code{erc-tls} with @code{:password} set to your NickServ password.
+
+You can also set this to a nonemtpy string, and ERC will send that
+when needed, no questions asked.  If you instead give a non-@code{nil}
+symbol (other than @code{:password}), 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 resolved 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, and it's
+@code{nil} by default.  As a last resort, ERC will prompt you for
+input.
+
+Lastly, 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
+
+@subheading Troubleshooting
+
+@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're struggling, remember that your SASL password is almost
+always your NickServ password.  When in doubt, try restoring all SASL
+options to their defaults and calling @code{erc-tls} with @code{:user}
+set to your NickServ account name and @code{:password} to your
+NickServ password.  If you're still having trouble, please contact us
+(@pxref{Getting Help and Reporting Bugs}).
+
+And if, for whatever reason, you do find yourself trying out
+non-default SASL settings, keep in mind that every change requires a
+fresh session, so you'll want to call @code{erc-tls} from scratch
+again rather than rely on @samp{/reconnect} or the auto-reconnect
+facility.  In fact, it's best to temporarily set
+@code{erc-server-auto-reconnect} to @code{nil} while experimenting.
 
 @node Sample Configuration
 @section Sample Configuration
diff --git a/etc/ERC-NEWS b/etc/ERC-NEWS
index 4c4b154dca..829ef25a47 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 f7e6fb7aee..47299ee3cc 100644
--- a/lisp/erc/erc-compat.el
+++ b/lisp/erc/erc-compat.el
@@ -273,6 +273,110 @@ erc-compat--auth-source-backend-parser-functions
     auth-source-backend-parser-functions))
 
 
+;;;; 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..dcbc732450
--- /dev/null
+++ b/lisp/erc/erc-sasl.el
@@ -0,0 +1,429 @@
+;;; 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.
+;;
+;; - 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 :user
+  "Account username to send when authenticating.
+This is also referred to as the authentication identity or
+\"authcid\".  A value of `:user' or `:nick' indicates that the
+corresponding connection parameter on file should be used.  These
+are most often derived from arguments provided to the `erc' and
+`erc-tls' entry points.  In the case of `:nick', a downcased
+version is used."
+  :type '(choice string (const :user) (const :nick)))
+
+(defcustom erc-sasl-password :password
+  "Optional account password to send when authenticating.
+When the value is a string, ERC will use it unconditionally for
+most mechanisms.  Likewise with `:password', except ERC will
+instead use the \"session password\" on file, which often
+originates from the entry-point commands `erc' or `erc-tls'.
+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 prompt for input.
+
+Note that, with `:password', ERC will forgo sending a traditional
+server password via the IRC \"PASS\" command.  Also, when
+`erc-sasl-mechanism' is set to `ecdsa-nist256p-challenge', this
+option should hold the file name of the key."
+  :type '(choice (const nil) (const :password) 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
+includes `erc-sasl-user' for the `:user' field and
+`erc-sasl-password' for the `:host' field, when the latter option
+is a non-nil, non-keyword symbol.  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)
+
+;; 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--get-user ()
+  (pcase (alist-get 'user erc-sasl--options)
+    (:user erc-session-username)
+    (:nick (erc-downcase (erc-current-nick)))
+    (v v)))
+
+(defun erc-sasl--read-password (prompt)
+  "Return configured option or server password.
+PROMPT is passed to `read-passwd' if necessary."
+  (if-let
+      ((found (pcase (alist-get 'password erc-sasl--options)
+                (:password erc-session-password)
+                ((and (pred stringp) v) (unless (string-empty-p v) v))
+                ((and (guard erc-sasl-auth-source-function)
+                      v (let host
+                          (or v (erc-networks--id-given erc-networks--id))))
+                 (apply erc-sasl-auth-source-function
+                        :user (erc-sasl--get-user)
+                        (and host (list :host (symbol-name host))))))))
+      (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 (list name))
+                                     (erc-sasl--get-user)
+                                     "N/A" "N/A"))
+      (sasl-client-set-property client 'authenticator-name
+                                (alist-get 'authzid erc-sasl--options))
+      client)))
+
+(cl-defmethod erc-sasl--create-client ((_ (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 (erc-sasl--get-user))
+         (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 ((_ (eql scram-sha-256)))
+  "Create and return a new SCRAM-SHA-256 client."
+  (when (featurep 'sasl-scram-sha256)
+    (cl-call-next-method)))
+
+(cl-defmethod erc-sasl--create-client ((_ (eql scram-sha-512)))
+  "Create and return a new SCRAM-SHA-512 client."
+  (when (featurep 'sasl-scram-sha256)
+    (cl-call-next-method)))
+
+(cl-defmethod erc-sasl--create-client ((_ (eql ecdsa-nist256p-challenge)))
+  "Create and return a new ECDSA-NIST256P-CHALLENGE client."
+  (let ((keyfile (cdr (assq 'password erc-sasl--options))))
+    ;; Better to signal usage errors now than inside a process filter.
+    (cond ((or (not (stringp keyfile)) (not (file-readable-p keyfile)))
+           (erc-display-error-notice
+            nil "`erc-sasl-password' not accessible as a file")
+           nil)
+          ((not (executable-find "openssl"))
+           (erc-display-error-notice nil "Could not find openssl program")
+           nil)
+          (t
+           (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 ()
+  (setq erc-sasl--state (make-erc-sasl--state))
+  ;; If the previous attempt failed during registration, this may be
+  ;; non-nil and contain erroneous values, but how can we detect that?
+  ;; What if the server dropped the connection for some other reason?
+  (setq erc-sasl--options
+        (or (and (consp erc--server-reconnecting)
+                 (alist-get 'erc-sasl--options erc--server-reconnecting))
+            `((user . ,erc-sasl-user)
+              (password . ,erc-sasl-password)
+              (mechanism . ,erc-sasl-mechanism)
+              (authzid . ,erc-sasl-authzid)))))
+
+(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 or unsupported SASL mechanism: %s" mech))
+         (erc-error "Unknown or unsupported SASL mechanism: %s" mech))
+       (setf (erc-sasl--state-client erc-sasl--state) client))))
+  ((remove-hook 'erc-server-AUTHENTICATE-functions
+                #'erc-sasl--authenticate-handler t)
+   (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")))
+  (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 (string-join (cdr (erc-response.command-args 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")
+        (if (and erc-session-password
+                 (eq :password (alist-get 'password erc-sasl--options)))
+            (let (erc-session-password)
+              (erc-login))
+          (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 ef70aa1a21..16e1533d07 100644
--- a/lisp/erc/erc.el
+++ b/lisp/erc/erc.el
@@ -1853,6 +1853,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..20a6760083
--- /dev/null
+++ b/test/lisp/erc/erc-sasl-tests.el
@@ -0,0 +1,344 @@
+;;; 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--basic ()
+  (ert-info ("Explicit erc-sasl-password")
+    (let ((erc-sasl--options '((password . "foo"))))
+      (should (string= (erc-sasl--read-password nil) "foo"))))
+
+  (ert-info ("Explicit session password")
+    (let ((erc-session-password "foo")
+          (erc-sasl--options '((password . :password))))
+      (should (string= (erc-sasl--read-password nil) "foo"))))
+
+  (ert-info ("Fallback to prompt skip auth-source")
+    (should-not erc-sasl-auth-source-function)
+    (let ((erc-session-password "bar")
+          (erc-networks--id (erc-networks--id-create nil)))
+      (should (string= (ert-simulate-keys "bar\r"
+                         (erc-sasl--read-password "?"))
+                       "bar"))))
+
+  (ert-info ("Prompt when auth-source fails and `erc-sasl-password' null")
+    (let ((erc-sasl--options '((password)))
+          (erc-sasl-auth-source-function #'ignore))
+      (should (string= (ert-simulate-keys "baz\r"
+                         (erc-sasl--read-password "pwd:"))
+                       "baz")))))
+
+(ert-deftest erc-sasl--read-password--auth-source ()
+  (ert-with-temp-file netrc-file
+    :text (string-join
+           (list
+            ;; If you swap these first 2 lines, *1 below fails
+            "machine FSF.chat port 6697 user bob password sesame"
+            "machine GNU/chat port 6697 user bob password spam"
+            "machine MyHost port irc password 123")
+           "\n")
+    (let* ((auth-sources (list netrc-file))
+           (erc-session-server "irc.gnu.org")
+           (erc-session-port 6697)
+           (erc-networks--id (erc-networks--id-create nil))
+           calls
+           (erc-sasl-auth-source-function
+            (lambda (&rest r)
+              (push r calls)
+              (apply #'erc--auth-source-search r)))
+           erc-server-announced-name ; too early
+           auth-source-do-cache)
+
+      (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"))
+          (should (equal (pop calls) '(:user "bob" :host "FSF.chat")))))
+
+      (ert-info ("ID for :host and `erc-session-username' for :user") ; *1
+        (let ((erc-session-username "bob")
+              (erc-sasl--options '((user . :user) (password)))
+              (erc-networks--id (erc-networks--id-create 'GNU/chat)))
+          (should (string= (erc-sasl--read-password nil) "spam"))
+          (should (equal (pop calls) '(:user "bob" :host "GNU/chat")))))
+
+      (ert-info ("ID for :host and current nick for :user") ; *1
+        (let ((erc-server-current-nick "bob")
+              (erc-sasl--options '((user . :nick) (password)))
+              (erc-networks--id (erc-networks--id-create 'GNU/chat)))
+          (should (string= (erc-sasl--read-password nil) "spam"))
+          (should (equal (pop calls) '(:user "bob" :host "GNU/chat")))))
+
+      (ert-info ("Symbol as password, entry lacks user field")
+        (let ((erc-server-current-nick "fake")
+              (erc-sasl--options '((user . :nick) (password . MyHost)))
+              (erc-networks--id (erc-networks--id-create 'GNU/chat)))
+          (should (string= (erc-sasl--read-password nil) "123"))
+          (should (equal (pop calls) '(:user "fake" :host "MyHost"))))))))
+
+(ert-deftest erc-sasl-create-client--plain ()
+  (let* ((erc-session-password "password123")
+         (erc-session-username "tester")
+         (erc-sasl--options '((user . :user) (password . :password)))
+         (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")
+         (erc-sasl--options '((user . :nick) (password . :password)))
+         (client (erc-sasl--create-client 'external)) ; unused ^
+         (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-sasl--options '((user . "jilles") (password . "sesame")
+                              (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 '((user . :nick) (password . :password)
+                              (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")
+         (erc-sasl--options '((user . :nick) (password . :password) (authzid)))
+         (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")
+         (erc-sasl--options '((user . :nick) (password . :password) (authzid)))
+         (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..713c9929c3
--- /dev/null
+++ b/test/lisp/erc/erc-scenarios-sasl.el
@@ -0,0 +1,202 @@
+;;; 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-password "password123")
+       (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))
+       (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 (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)
+       (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)
+       (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-user :nick)
+       (erc-sasl-mechanism mech)
+       (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"
+                                :password "sesame"
+                                :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/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


[-- Warning: decoded text below may be mangled, UTF-8 assumed --]
[-- Attachment #8: 0006-Accept-functions-in-place-of-passwords-in-ERC.patch --]
[-- Type: text/x-patch, Size: 11519 bytes --]

From a06e72aca3f14d903f5716f844067d3a224919cd 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 6/6] Accept functions in place of passwords in ERC

* lisp/erc/erc-backend.el (erc-session-password): Add comment
explaining type is now string, nil, or function.
* lisp/erc/erc-sasl.el (erc-sasl--read-password): Use `erc--unfun'.
* lisp/erc/erc-services.el (erc-nickserv-get-password,
erc-nickserv-send-identify): Use `erc--unfun'.
* lisp/erc/erc.el (erc--unfun): New function for unwrapping a
password couched in a getter.
(erc--debug-irc-protocol-mask-secrets): Add variable to indicate
whether to mask passwords in debug logs.
(erc--mask-secrets): New function to swap masked secret with question
marks in debug logs.
(erc-log-irc-protocol): Conditionally mask secrets when
`erc--debug-irc-protocol-mask-secrets' is non-nil.
(erc--auth-source-search): Don't unwrap secret from function before
returning.
(erc-server-join-channel, erc-login): Use `erc--unfun'.

* test/lisp/erc/erc-services-tests.el
(erc-services-tests--wrap-search): Add helper for `erc--unfun'.
(erc-services-tests--auth-source-standard,
erc-services-tests--auth-source-announced,
erc-services-tests--auth-source-overrides, erc-nickserv-get-password):
Use `erc--unfun'.
* test/lisp/erc/erc-tests.el (erc--debug-irc-protocol-mask-secrets):
Add test for masking secrets with `erc--unfun' and friends.
---
 lisp/erc/erc-backend.el             |  3 ++-
 lisp/erc/erc-sasl.el                |  2 +-
 lisp/erc/erc-services.el            |  5 ++--
 lisp/erc/erc.el                     | 38 +++++++++++++++++++++++++----
 test/lisp/erc/erc-services-tests.el | 16 +++++++++---
 test/lisp/erc/erc-tests.el          | 22 +++++++++++++++++
 6 files changed, 73 insertions(+), 13 deletions(-)

diff --git a/lisp/erc/erc-backend.el b/lisp/erc/erc-backend.el
index 4061522259..7bf21087c6 100644
--- a/lisp/erc/erc-backend.el
+++ b/lisp/erc/erc-backend.el
@@ -205,7 +205,8 @@ erc-whowas-on-nosuchnick
 ;;;; Variables and options
 
 (defvar-local erc-session-password nil
-  "The password used for the current session.")
+  "The password used for the current session.
+This should be a string or a function returning a string.")
 
 (defvar erc-server-responses (make-hash-table :test #'equal)
   "Hash table mapping server responses to their handler hooks.")
diff --git a/lisp/erc/erc-sasl.el b/lisp/erc/erc-sasl.el
index dcbc732450..aabb6c8a51 100644
--- a/lisp/erc/erc-sasl.el
+++ b/lisp/erc/erc-sasl.el
@@ -143,7 +143,7 @@ erc-sasl--read-password
                  (apply erc-sasl-auth-source-function
                         :user (erc-sasl--get-user)
                         (and host (list :host (symbol-name host))))))))
-      (copy-sequence found)
+      (copy-sequence (erc--unfun found))
     (read-passwd prompt)))
 
 (defun erc-sasl--plain-response (client steps)
diff --git a/lisp/erc/erc-services.el b/lisp/erc/erc-services.el
index fe9cb5b5f1..48953288d1 100644
--- a/lisp/erc/erc-services.el
+++ b/lisp/erc/erc-services.el
@@ -455,7 +455,7 @@ erc-nickserv-get-password
                   (read-passwd
                    (format "NickServ password for %s on %s (RET to cancel): "
                            nick nid)))))
-       ((not (string-empty-p ret))))
+       ((not (string-empty-p (erc--unfun ret)))))
     ret))
 
 (defvar erc-auto-discard-away)
@@ -477,7 +477,8 @@ erc-nickserv-send-identify
          (msgtype (or (erc-nickserv-alist-ident-command nil nickserv-info)
                       "PRIVMSG")))
     (erc-message msgtype
-                 (concat nickserv " " identify-word " " nick password))))
+                 (concat nickserv " " identify-word " " nick
+                         (erc--unfun password)))))
 
 (defun erc-nickserv-call-identify-function (nickname)
   "Call `erc-nickserv-identify' with NICKNAME."
diff --git a/lisp/erc/erc.el b/lisp/erc/erc.el
index 16e1533d07..60bfb909e0 100644
--- a/lisp/erc/erc.el
+++ b/lisp/erc/erc.el
@@ -2310,6 +2310,23 @@ erc-debug-irc-protocol
 WARNING: Do not set this variable directly!  Instead, use the
 function `erc-toggle-debug-irc-protocol' to toggle its value.")
 
+(defvar erc--debug-irc-protocol-mask-secrets t
+  "Whether to hide secrets in a debug log.
+They are still visible on screen but are replaced by question
+marks when yanked.")
+
+(defun erc--mask-secrets (string)
+  (when-let* ((eot (length string))
+              (beg (text-property-any 0 eot 'erc-secret t string))
+              (end (text-property-not-all beg eot 'erc-secret t string))
+              (sec (substring string beg end)))
+    (setq string (concat (substring string 0 beg)
+                         (make-string (- end beg) ??)
+                         (substring string end eot)))
+    (put-text-property beg end 'face 'erc-inverse-face string)
+    (put-text-property beg end 'display sec string))
+  string)
+
 (defun erc-log-irc-protocol (string &optional outbound)
   "Append STRING to the buffer *erc-protocol*.
 
@@ -2335,6 +2352,8 @@ erc-log-irc-protocol
                       (format "%s:%s" erc-session-server erc-session-port))))
           (ts (when erc-debug-irc-protocol-time-format
                 (format-time-string erc-debug-irc-protocol-time-format))))
+      (when erc--debug-irc-protocol-mask-secrets
+        (setq string (erc--mask-secrets string)))
       (with-current-buffer (get-buffer-create "*erc-protocol*")
         (save-excursion
           (goto-char (point-max))
@@ -3260,9 +3279,8 @@ erc--auth-source-search
       (setq plist (plist-put plist :max 5000))) ; `auth-source-netrc-parse'
     (unless (plist-get defaults :require)
       (setq plist (plist-put plist :require '(:secret))))
-    (when-let* ((sorted (sort (apply #'auth-source-search plist) test))
-                (secret (plist-get (car sorted) :secret)))
-      (if (functionp secret) (funcall secret) secret))))
+    (when-let* ((sorted (sort (apply #'auth-source-search plist) test)))
+      (plist-get (car sorted) :secret))))
 
 (defun erc-auth-source-search (&rest plist)
   "Call `auth-source-search', possibly with keyword params in PLIST."
@@ -3283,7 +3301,8 @@ erc-server-join-channel
     (setq secret (apply erc-auth-source-join-function
                         `(,@(and server (list :host server)) :user ,channel))))
   (erc-log (format "cmd: JOIN: %s" channel))
-  (erc-server-send (concat "JOIN " channel (and secret (concat " " secret)))))
+  (erc-server-send (concat "JOIN " channel
+                           (and secret (concat " " (erc--unfun secret))))))
 
 (defun erc--valid-local-channel-p (channel)
   "Non-nil when channel is server-local on a network that allows them."
@@ -6315,6 +6334,15 @@ erc-load-irc-script-lines
 
 ;; authentication
 
+(defun erc--unfun (maybe-fn)
+  "Return MAYBE-FN or whatever it returns."
+  (let ((s (if (functionp maybe-fn) (funcall maybe-fn) maybe-fn)))
+    (when (and erc-debug-irc-protocol
+               erc--debug-irc-protocol-mask-secrets
+               (stringp s))
+      (put-text-property 0 (length s) 'erc-secret t s))
+    s))
+
 (defun erc-login ()
   "Perform user authentication at the IRC server."
   (erc-log (format "login: nick: %s, user: %s %s %s :%s"
@@ -6324,7 +6352,7 @@ erc-login
                    erc-session-server
                    erc-session-user-full-name))
   (if erc-session-password
-      (erc-server-send (concat "PASS :" erc-session-password))
+      (erc-server-send (concat "PASS :" (erc--unfun erc-session-password)))
     (message "Logging in without password"))
   (erc-server-send (format "NICK %s" (erc-current-nick)))
   (erc-server-send
diff --git a/test/lisp/erc/erc-services-tests.el b/test/lisp/erc/erc-services-tests.el
index 7ff2e36e77..2547c5e01a 100644
--- a/test/lisp/erc/erc-services-tests.el
+++ b/test/lisp/erc/erc-services-tests.el
@@ -62,9 +62,13 @@ erc--auth-source-determine-params-merge
                            :x ("x")
                            :require (:secret))))))
 
+(defun erc-services-tests--wrap-search (s)
+  (lambda (&rest r) (erc--unfun (apply s r))))
+
 ;; Some of the following may be related to bug#23438.
 
 (defun erc-services-tests--auth-source-standard (search)
+  (setq search (erc-services-tests--wrap-search search))
 
   (ert-info ("Session wins")
     (let ((erc-session-server "irc.gnu.org")
@@ -93,6 +97,7 @@ erc-services-tests--auth-source-standard
       (should (string= (funcall search :user "#chan") "baz")))))
 
 (defun erc-services-tests--auth-source-announced (search)
+  (setq search (erc-services-tests--wrap-search search))
   (let* ((erc--isupport-params (make-hash-table))
          (erc-server-parameters '(("CHANTYPES" . "&#")))
          (erc--target (erc--target-from-string "&chan")))
@@ -124,6 +129,7 @@ erc-services-tests--auth-source-announced
           (should (string= (funcall search :user "#chan") "foo")))))))
 
 (defun erc-services-tests--auth-source-overrides (search)
+  (setq search (erc-services-tests--wrap-search search))
   (let* ((erc-session-server "irc.gnu.org")
          (erc-server-announced-name "my.gnu.org")
          (erc-network 'GNU.chat)
@@ -537,18 +543,20 @@ erc-nickserv-get-password
            (erc-network 'FSF.chat)
            (erc-server-current-nick "tester")
            (erc-networks--id (erc-networks--id-create nil))
-           (erc-session-port 6697))
+           (erc-session-port 6697)
+           (search (erc-services-tests--wrap-search
+                    #'erc-nickserv-get-password)))
 
       (ert-info ("Lookup custom option")
-        (should (string= (erc-nickserv-get-password "alice") "foo")))
+        (should (string= (funcall search "alice") "foo")))
 
       (ert-info ("Auth source")
         (ert-info ("Network")
-          (should (string= (erc-nickserv-get-password "bob") "sesame")))
+          (should (string= (funcall search "bob") "sesame")))
 
         (ert-info ("Network ID")
           (let ((erc-networks--id (erc-networks--id-create 'GNU/chat)))
-            (should (string= (erc-nickserv-get-password "bob") "spam")))))
+            (should (string= (funcall search "bob") "spam")))))
 
       (ert-info ("Read input")
         (should (string=
diff --git a/test/lisp/erc/erc-tests.el b/test/lisp/erc/erc-tests.el
index fecd17b10e..8b8cfa152b 100644
--- a/test/lisp/erc/erc-tests.el
+++ b/test/lisp/erc/erc-tests.el
@@ -530,6 +530,28 @@ erc-ring-previous-command
   (when noninteractive
     (kill-buffer "*#fake*")))
 
+(ert-deftest erc--debug-irc-protocol-mask-secrets ()
+  (should-not erc-debug-irc-protocol)
+  (should erc--debug-irc-protocol-mask-secrets)
+  (with-temp-buffer
+    (setq erc-server-process (start-process "fake" (current-buffer) "true")
+          erc-server-current-nick "tester"
+          erc-session-server "myproxy.localhost"
+          erc-session-port 6667)
+    (let ((inhibit-message noninteractive))
+      (erc-toggle-debug-irc-protocol)
+      (erc-log-irc-protocol
+       (concat "PASS :" (erc--unfun (lambda () "changeme")) "\r\n")
+       'outgoing)
+      (set-process-query-on-exit-flag erc-server-process nil))
+    (with-current-buffer "*erc-protocol*"
+      (goto-char (point-min))
+      (search-forward "\r\n\r\n")
+      (search-forward "myproxy.localhost:6667 >> PASS :????????" (pos-eol)))
+    (when noninteractive
+      (kill-buffer "*erc-protocol*")
+      (should-not erc-debug-irc-protocol))))
+
 (ert-deftest erc-log-irc-protocol ()
   (should-not erc-debug-irc-protocol)
   (with-temp-buffer
-- 
2.38.1


^ permalink raw reply related	[flat|nested] 54+ messages in thread

* bug#29108: 25.3; ERC SASL support
       [not found]                       ` <87leo8z79j.fsf@neverwas.me>
@ 2022-11-19 14:48                         ` J.P.
       [not found]                         ` <87tu2vroeh.fsf@neverwas.me>
  1 sibling, 0 replies; 54+ messages in thread
From: J.P. @ 2022-11-19 14:48 UTC (permalink / raw)
  To: 29108; +Cc: emacs-erc, bandali

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

v11. Move repurposing of `erc--server-reconnecting' to separate patch.
Move `local-minor-modes' compat helper to main library. Fix bug in
`erc-update-modules' and restore old signature but use new internal
analog in `erc-open'.


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

From 9e951848ba068d29138c9663e76bb4fa114660be Mon Sep 17 00:00:00 2001
From: "F. Jason Park" <jp@neverwas.me>
Date: Sat, 19 Nov 2022 05:49:40 -0800
Subject: [PATCH 0/7] *** NOT A PATCH ***

*** BLURB HERE ***

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

 doc/misc/erc.texi                             | 162 ++++++-
 etc/ERC-NEWS                                  |  13 +-
 lisp/erc/erc-backend.el                       |  35 +-
 lisp/erc/erc-common.el                        |  56 ++-
 lisp/erc/erc-compat.el                        | 104 +++++
 lisp/erc/erc-goodies.el                       |   1 +
 lisp/erc/erc-networks.el                      |  39 +-
 lisp/erc/erc-sasl.el                          | 429 ++++++++++++++++++
 lisp/erc/erc-services.el                      |   5 +-
 lisp/erc/erc.el                               | 135 ++++--
 lisp/net/sasl-scram-rfc.el                    |  21 +-
 test/lisp/erc/erc-sasl-tests.el               | 344 ++++++++++++++
 test/lisp/erc/erc-scenarios-sasl.el           | 202 +++++++++
 test/lisp/erc/erc-services-tests.el           |  16 +-
 test/lisp/erc/erc-tests.el                    |  87 ++++
 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 ++
 20 files changed, 1724 insertions(+), 107 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 790db1135e..834f1cbf2c 100644
--- a/doc/misc/erc.texi
+++ b/doc/misc/erc.texi
@@ -1067,12 +1067,13 @@ SASL
 NickServ password.  If you're still having trouble, please contact us
 (@pxref{Getting Help and Reporting Bugs}).
 
-And if, for whatever reason, you do find yourself trying out
-non-default SASL settings, keep in mind that every change requires a
-fresh session, so you'll want to call @code{erc-tls} from scratch
-again rather than rely on @samp{/reconnect} or the auto-reconnect
-facility.  In fact, it's best to temporarily set
-@code{erc-server-auto-reconnect} to @code{nil} while experimenting.
+As you try out different settings, keep in mind that it's best to
+create a fresh session for every change, for example, by calling
+@code{erc-tls} from scratch.  More experienced users may be able to
+get away with cycling @code{erc-sasl-mode} and issuing a
+@samp{/reconnect}, but that's generally not recommended.  Whatever the
+case, you'll probably want to temporarily disable
+@code{erc-server-auto-reconnect} while experimenting.
 
 @node Sample Configuration
 @section Sample Configuration
diff --git a/etc/ERC-NEWS b/etc/ERC-NEWS
index 829ef25a47..3e1b7bca95 100644
--- a/etc/ERC-NEWS
+++ b/etc/ERC-NEWS
@@ -103,14 +103,6 @@ messages during periods of heavy traffic no longer disappear.
 Although rare, server passwords containing white space are now handled
 correctly.
 
-** ERC-mode hooks are more useful.
-The function 'erc-update-modules' now supports an optional argument to
-defer enabling of local modules and instead return their mode
-commands.  'erc-open' relies on this to delay their activation, as
-well as that of all 'erc-mode-hook' members, until most local session
-variables have been initialized (minus those "server"- and
-process-focused ones in erc-backend).
-
 ** 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
@@ -132,6 +124,12 @@ The function 'erc-auto-query' was deemed too difficult to reason
 through and has thus been deprecated with no public replacement; it
 has also been removed from the client code path.
 
+The function 'erc-open' now delays running 'erc-mode-hook' members
+until most local session variables have been initialized (minus those
+connection-related ones in erc-backend).  'erc-open' also no longer
+calls 'erc-update-modules', although modules are still activated
+in an identical fashion.
+
 A few internal variables have been introduced that could just as well
 have been made public, possibly as user options.  Likewise for some
 internal functions.  As always, users needing such functionality
diff --git a/lisp/erc/erc-backend.el b/lisp/erc/erc-backend.el
index 7bf21087c6..ed1a92867b 100644
--- a/lisp/erc/erc-backend.el
+++ b/lisp/erc/erc-backend.el
@@ -312,8 +312,13 @@ erc-server-reconnecting
 (make-obsolete-variable 'erc-server-reconnecting
                         "see `erc--server-reconnecting'" "29.1")
 
-(defvar-local erc--server-reconnecting nil
-  "Non-nil when reconnecting.")
+(defvar erc--server-reconnecting nil
+  "An alist of buffer-local vars and their values when reconnecting.
+This is for the benefit of local modules and `erc-mode-hook'
+members so they can access buffer-local data from the previous
+session when reconnecting.  Once `erc-reuse-buffers' is retired
+and fully removed, modules can switch to leveraging the
+`permanent-local' property instead.")
 
 (defvar-local erc-server-timed-out nil
   "Non-nil if the IRC server failed to respond to a ping.")
@@ -669,7 +674,6 @@ erc-server-connect
       (setq erc-server-process process)
       (setq erc-server-quitting nil)
       (setq erc-server-reconnecting nil
-            erc--server-reconnecting nil
             erc--server-reconnect-timer nil)
       (setq erc-server-timed-out nil)
       (setq erc-server-banned nil)
@@ -711,11 +715,11 @@ erc-server-reconnect
     (with-current-buffer buffer
       (erc-update-mode-line)
       (erc-set-active-buffer (current-buffer))
-      (setq erc--server-reconnecting t)
       (setq erc-server-last-sent-time 0)
       (setq erc-server-lines-sent 0)
       (let ((erc-server-connect-function (or erc-session-connector
-                                             #'erc-open-network-stream)))
+                                             #'erc-open-network-stream))
+            (erc--server-reconnecting (buffer-local-variables)))
         (erc-open erc-session-server erc-session-port erc-server-current-nick
                   erc-session-user-full-name t erc-session-password
                   nil nil nil erc-session-client-certificate
@@ -829,8 +833,7 @@ erc-process-sentinel-2
         (if (not reconnect-p)
             ;; terminate, do not reconnect
             (progn
-              (setq erc--server-reconnecting nil
-                    erc--server-reconnect-timer nil)
+              (setq erc--server-reconnect-timer nil)
               (erc-display-message nil 'error (current-buffer)
                                    'terminated ?e event)
               (set-buffer-modified-p nil))
diff --git a/lisp/erc/erc-compat.el b/lisp/erc/erc-compat.el
index 47299ee3cc..8b95f8ac81 100644
--- a/lisp/erc/erc-compat.el
+++ b/lisp/erc/erc-compat.el
@@ -417,18 +417,6 @@ erc-compat--29-browse-url-irc
                  (cons '("\\`irc6?s?://" . erc-compat--29-browse-url-irc)
                        existing))))))
 
-(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-sasl.el b/lisp/erc/erc-sasl.el
index aabb6c8a51..16fe93f50d 100644
--- a/lisp/erc/erc-sasl.el
+++ b/lisp/erc/erc-sasl.el
@@ -288,7 +288,7 @@ erc-sasl--init
   ;; non-nil and contain erroneous values, but how can we detect that?
   ;; What if the server dropped the connection for some other reason?
   (setq erc-sasl--options
-        (or (and (consp erc--server-reconnecting)
+        (or (and erc--server-reconnecting
                  (alist-get 'erc-sasl--options erc--server-reconnecting))
             `((user . ,erc-sasl-user)
               (password . ,erc-sasl-password)
diff --git a/lisp/erc/erc.el b/lisp/erc/erc.el
index 60bfb909e0..0b1f44e560 100644
--- a/lisp/erc/erc.el
+++ b/lisp/erc/erc.el
@@ -1870,23 +1870,24 @@ erc-modules
     (repeat :tag "Others" :inline t symbol))
   :group 'erc)
 
-(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."
+(defun erc-update-modules ()
+  "Enable minor mode for every module in `erc-modules'.
+Except ignore all local modules, which were introduced in ERC 5.5."
+  (erc--update-modules)
+  nil)
+
+(defun erc--update-modules ()
   (let (local-modes)
-    (dolist (module erc-modules (and defer-locals local-modes))
+    (dolist (module erc-modules 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)))
-            (push mode local-modes)
-          (funcall mode 1))))))
+        (if (custom-variable-p mode)
+            (funcall mode 1)
+          (push mode local-modes))))))
 
 (defun erc-setup-buffer (buffer)
   "Consults `erc-join-buffer' to find out how to display `BUFFER'."
@@ -1917,6 +1918,17 @@ erc-setup-buffer
          (display-buffer buffer)
        (switch-to-buffer buffer)))))
 
+(defun erc--local-module-modes (old-vars new)
+  ;; Emacs 27 doesnt' have `local-minor-modes'
+  (if-let* ((old-vars)
+            (fn (pcase-lambda (`(,k . ,v))
+                  (and (string-prefix-p "erc-" (symbol-name k))
+                       (string-suffix-p "-mode" (symbol-name k))
+                       v
+                       k))))
+      (delete-dups (append (delq nil (mapcar fn old-vars)) new nil))
+    new))
+
 (defun erc-open (&optional server port nick full-name
                            connect passwd tgt-list channel process
                            client-certificate user id)
@@ -1950,20 +1962,16 @@ erc-open
          (continued-session (and erc--server-reconnecting
                                  (with-suppressed-warnings
                                      ((obsolete erc-reuse-buffers))
-                                   erc-reuse-buffers)
-                                 (buffer-local-variables))))
+                                   erc-reuse-buffers))))
     (when connect (run-hook-with-args 'erc-before-connect server port nick))
     (set-buffer buffer)
     (setq old-point (point))
-    (setq delayed-modules
-          (delete-dups (append (when continued-session
-                                 (erc-compat--local-module-modes))
-                               (erc-update-modules 'defer-locals))))
+    (setq delayed-modules (erc--local-module-modes erc--server-reconnecting
+                                                   (erc--update-modules)))
 
     (delay-mode-hooks (erc-mode))
 
-    (setq erc-server-reconnect-count old-recon-count
-          erc--server-reconnecting continued-session)
+    (setq erc-server-reconnect-count old-recon-count)
 
     (when (setq erc-server-connected (not connect))
       (setq erc-server-announced-name
@@ -3859,10 +3867,8 @@ erc--cmd-reconnect
       (with-suppressed-warnings ((obsolete erc-server-reconnecting)
                                  (obsolete erc-reuse-buffers))
         (if erc-reuse-buffers
-            (progn (cl-assert (not erc--server-reconnecting))
-                   (cl-assert (not erc-server-reconnecting)))
-          (setq erc--server-reconnecting nil
-                erc-server-reconnecting nil)))))
+            (cl-assert (not erc-server-reconnecting))
+          (setq erc-server-reconnecting nil)))))
   t)
 
 (defun erc-cmd-RECONNECT (&rest args)
diff --git a/test/lisp/erc/erc-tests.el b/test/lisp/erc/erc-tests.el
index 3617548f3b..4e646a775b 100644
--- a/test/lisp/erc/erc-tests.el
+++ b/test/lisp/erc/erc-tests.el
@@ -1206,7 +1206,7 @@ erc-migrate-modules
   ;; Default unchanged
   (should (equal (erc-migrate-modules erc-modules) erc-modules)))
 
-(ert-deftest erc-update-modules ()
+(ert-deftest erc--update-modules ()
   (let (calls
         erc-modules
         erc-kill-channel-hook erc-kill-server-hook erc-kill-buffer-hook)
@@ -1233,7 +1233,7 @@ erc-update-modules
 
       (ert-info ("Local modules")
         (setq erc-modules '(fake-foo fake-bar))
-        (should (equal (erc-update-modules t) '(erc-fake-bar-mode)))
+        (should (equal (erc--update-modules) '(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)))
@@ -1241,21 +1241,28 @@ erc-update-modules
 
       (ert-info ("Module name overrides")
         (setq erc-modules '(completion autojoin networks))
-        (should-not (erc-update-modules t)) ; no locals
+        (should-not (erc--update-modules)) ; no locals
         (should (equal (nreverse calls) '( erc-pcomplete (completion . 1)
                                            erc-join (autojoin . 1)
                                            erc-networks (networks . 1))))
         (setq calls nil)))))
 
-(ert-deftest erc-compat--local-module-modes ()
-  (with-temp-buffer
-    (if (< 27 emacs-major-version)
-        (let ((local-minor-modes '(font-lock-mode erc-fake-bar-mode)))
-          (should (equal (erc-compat--local-module-modes)
-                         '(erc-fake-bar-mode))))
-      (cl-letf (((symbol-function 'buffer-local-variables)
-                 (lambda (&rest _) '((font-lock-mode) (erc-fake-bar-mode)))))
-        (should (equal (erc-compat--local-module-modes)
-                       '(erc-fake-bar-mode)))))))
+(ert-deftest erc--local-module-modes ()
+
+  (should (equal (erc--local-module-modes nil '(erc-a-mode erc-b-mode))
+                 '(erc-a-mode erc-b-mode)))
+
+  (should (equal (erc--local-module-modes
+                  '((a) (b . t)) '(erc-c-mode erc-d-mode))
+                 '(erc-c-mode erc-d-mode)))
+
+  (should (equal (erc--local-module-modes
+                  '((a) (erc-b-mode) (c . t) (erc-d-mode . t) (erc-e-mode . t))
+                  '(erc-b-mode erc-d-mode))
+                 '(erc-d-mode erc-e-mode erc-b-mode)))
+
+  (should (equal (erc--local-module-modes
+                  '((a) (b . t) (erc-e-mode . t)) nil)
+                 '(erc-e-mode))))
 
 ;;; erc-tests.el ends here
-- 
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 030c91581ead371794e0e3a3e60a007cc308e95d 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/7] 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 e3dd6761529e472c23dd57ce51535d3c2d1ff705 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/7] 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 15fd6ac50f..ebfb4bb830 100644
--- a/lisp/erc/erc-backend.el
+++ b/lisp/erc/erc-backend.el
@@ -1619,7 +1619,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)
@@ -1629,6 +1629,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))))))
@@ -2255,6 +2258,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 b3e5fcf1a3..100d5b4b58 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))
@@ -1382,7 +1374,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)
@@ -1401,8 +1394,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 2312246e3e..95212182b5 100644
--- a/lisp/erc/erc.el
+++ b/lisp/erc/erc.el
@@ -2017,10 +2017,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
@@ -3179,7 +3181,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-Make-erc-server-reconnecting-non-buffer-local.patch --]
[-- Type: text/x-patch, Size: 4014 bytes --]

From 41712986024e025a89814cb75e300c9cac2c7f22 Mon Sep 17 00:00:00 2001
From: "F. Jason Park" <jp@neverwas.me>
Date: Fri, 18 Nov 2022 22:42:15 -0800
Subject: [PATCH 3/7] Make erc--server-reconnecting non-buffer-local

* lisp/erc/erc-backend.el (erc--server-reconnecting): Mention expected
non-nil value type in doc string.
(erc-server-connect): Don't set `erc--server-reconnecting'.
(erc-server--reconnect): Let-bind `erc--server-reconnecting' instead
of setting it locally in the server buffer.  Set it to an alist
containing the current buffer's local variables.
(erc-process-sentinel-2): Don't set `erc--server-reconnect'.
* lisp/erc/erc.el (erc--cmd-reconnect): Clean up some assertions.
(Bug#57955.)
---
 lisp/erc/erc-backend.el | 17 ++++++++++-------
 lisp/erc/erc.el         |  6 ++----
 2 files changed, 12 insertions(+), 11 deletions(-)

diff --git a/lisp/erc/erc-backend.el b/lisp/erc/erc-backend.el
index ebfb4bb830..e50593b8ba 100644
--- a/lisp/erc/erc-backend.el
+++ b/lisp/erc/erc-backend.el
@@ -311,8 +311,13 @@ erc-server-reconnecting
 (make-obsolete-variable 'erc-server-reconnecting
                         "see `erc--server-reconnecting'" "29.1")
 
-(defvar-local erc--server-reconnecting nil
-  "Non-nil when reconnecting.")
+(defvar erc--server-reconnecting nil
+  "An alist of buffer-local vars and their values when reconnecting.
+This is for the benefit of local modules and `erc-mode-hook'
+members so they can access buffer-local data from the previous
+session when reconnecting.  Once `erc-reuse-buffers' is retired
+and fully removed, modules can switch to leveraging the
+`permanent-local' property instead.")
 
 (defvar-local erc-server-timed-out nil
   "Non-nil if the IRC server failed to respond to a ping.")
@@ -664,7 +669,6 @@ erc-server-connect
       (setq erc-server-process process)
       (setq erc-server-quitting nil)
       (setq erc-server-reconnecting nil
-            erc--server-reconnecting nil
             erc--server-reconnect-timer nil)
       (setq erc-server-timed-out nil)
       (setq erc-server-banned nil)
@@ -706,11 +710,11 @@ erc-server-reconnect
     (with-current-buffer buffer
       (erc-update-mode-line)
       (erc-set-active-buffer (current-buffer))
-      (setq erc--server-reconnecting t)
       (setq erc-server-last-sent-time 0)
       (setq erc-server-lines-sent 0)
       (let ((erc-server-connect-function (or erc-session-connector
-                                             #'erc-open-network-stream)))
+                                             #'erc-open-network-stream))
+            (erc--server-reconnecting (buffer-local-variables)))
         (erc-open erc-session-server erc-session-port erc-server-current-nick
                   erc-session-user-full-name t erc-session-password
                   nil nil nil erc-session-client-certificate
@@ -824,8 +828,7 @@ erc-process-sentinel-2
         (if (not reconnect-p)
             ;; terminate, do not reconnect
             (progn
-              (setq erc--server-reconnecting nil
-                    erc--server-reconnect-timer nil)
+              (setq erc--server-reconnect-timer nil)
               (erc-display-message nil 'error (current-buffer)
                                    'terminated ?e event)
               (set-buffer-modified-p nil))
diff --git a/lisp/erc/erc.el b/lisp/erc/erc.el
index 95212182b5..7b58e2cf35 100644
--- a/lisp/erc/erc.el
+++ b/lisp/erc/erc.el
@@ -3834,10 +3834,8 @@ erc--cmd-reconnect
       (with-suppressed-warnings ((obsolete erc-server-reconnecting)
                                  (obsolete erc-reuse-buffers))
         (if erc-reuse-buffers
-            (progn (cl-assert (not erc--server-reconnecting))
-                   (cl-assert (not erc-server-reconnecting)))
-          (setq erc--server-reconnecting nil
-                erc-server-reconnecting nil)))))
+            (cl-assert (not erc-server-reconnecting))
+          (setq erc-server-reconnecting nil)))))
   t)
 
 (defun erc-cmd-RECONNECT (&rest args)
-- 
2.38.1


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

From aa641242831a95cc1486794a716fd78407141e47 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 4/7] Support local ERC modules in erc-mode buffers

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

* lisp/erc/erc.el (erc-migrate-modules): Add some missing mappings.
(erc-update-modules): Move body of `erc-update-modules' to new
internal function.
(erc--update-modules): Add new function, a renamed and slightly
modified version of `erc-update-modules'.  Specifically, change return
value from nil to a list of minor-mode commands for local modules.
Use `custom-variable-p' to detect flavor.
(erc--local-module-modes): Add helper for finding local modules
already active as minor modes in an ERC buffer.
(erc-open): Replace `erc-update-modules' with `erc--update-modules'.
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'.

* 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 toggle local modules (minor modes) unless
`erc-mode' is the major mode.  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 asserting correct
module-name mappings.
(erc--local-module-modes): Add test for helper. (Bug#57955.)
---
 doc/misc/erc.texi          | 11 +++++-
 etc/ERC-NEWS               |  6 +++
 lisp/erc/erc-common.el     | 56 ++++++++++++++++++++++----
 lisp/erc/erc-goodies.el    |  1 +
 lisp/erc/erc.el            | 81 ++++++++++++++++++++++----------------
 test/lisp/erc/erc-tests.el | 65 ++++++++++++++++++++++++++++++
 6 files changed, 177 insertions(+), 43 deletions(-)

diff --git a/doc/misc/erc.texi b/doc/misc/erc.texi
index 0d807e323e..dd15036b2e 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.
 
diff --git a/etc/ERC-NEWS b/etc/ERC-NEWS
index f638d4717a..832a9566d7 100644
--- a/etc/ERC-NEWS
+++ b/etc/ERC-NEWS
@@ -125,6 +125,12 @@ The function 'erc-auto-query' was deemed too difficult to reason
 through and has thus been deprecated with no public replacement; it
 has also been removed from the client code path.
 
+The function 'erc-open' now delays running 'erc-mode-hook' members
+until most local session variables have been initialized (minus those
+connection-related ones in erc-backend).  'erc-open' also no longer
+calls 'erc-update-modules', although modules are still activated
+in an identical fashion.
+
 A few internal variables have been introduced that could just as well
 have been made public, possibly as user options.  Likewise for some
 internal functions.  As always, users needing such functionality
diff --git a/lisp/erc/erc-common.el b/lisp/erc/erc-common.el
index 23a1933798..b791866ee2 100644
--- a/lisp/erc/erc-common.el
+++ b/lisp/erc/erc-common.el
@@ -88,6 +88,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.
@@ -101,7 +136,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:
 
@@ -114,6 +151,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))))
@@ -137,16 +175,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-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 7b58e2cf35..23c7f6d438 100644
--- a/lisp/erc/erc.el
+++ b/lisp/erc/erc.el
@@ -1791,10 +1791,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
@@ -1873,27 +1870,23 @@ erc-modules
   :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))))))
+  "Enable minor mode for every module in `erc-modules'.
+Except ignore all local modules, which were introduced in ERC 5.5."
+  (erc--update-modules)
+  nil)
+
+(defun erc--update-modules ()
+  (let (local-modes)
+    (dolist (module erc-modules 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 (custom-variable-p mode)
+            (funcall mode 1)
+          (push mode local-modes))))))
 
 (defun erc-setup-buffer (buffer)
   "Consults `erc-join-buffer' to find out how to display `BUFFER'."
@@ -1924,6 +1917,17 @@ erc-setup-buffer
          (display-buffer buffer)
        (switch-to-buffer buffer)))))
 
+(defun erc--local-module-modes (old-vars new)
+  ;; Emacs 27 doesnt' have `local-minor-modes'
+  (if-let* ((old-vars)
+            (fn (pcase-lambda (`(,k . ,v))
+                  (and (string-prefix-p "erc-" (symbol-name k))
+                       (string-suffix-p "-mode" (symbol-name k))
+                       v
+                       k))))
+      (delete-dups (append (delq nil (mapcar fn old-vars)) new nil))
+    new))
+
 (defun erc-open (&optional server port nick full-name
                            connect passwd tgt-list channel process
                            client-certificate user id)
@@ -1951,18 +1955,23 @@ 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))))
     (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--local-module-modes erc--server-reconnecting
+                                                   (erc--update-modules)))
+
+    (delay-mode-hooks (erc-mode))
+
+    (setq erc-server-reconnect-count old-recon-count)
+
     (when (setq erc-server-connected (not connect))
       (setq erc-server-announced-name
             (buffer-local-value 'erc-server-announced-name old-buffer)))
@@ -2019,14 +2028,20 @@ 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 (and erc--server-reconnecting
+                       (alist-get 'erc-networks--id 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))
@@ -2038,8 +2053,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 ff5d802697..bc4128dc9e 100644
--- a/test/lisp/erc/erc-tests.el
+++ b/test/lisp/erc/erc-tests.el
@@ -1178,4 +1178,69 @@ erc-handle-irc-url
     (kill-buffer "baznet")
     (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) '(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)) ; no locals
+        (should (equal (nreverse calls) '( erc-pcomplete (completion . 1)
+                                           erc-join (autojoin . 1)
+                                           erc-networks (networks . 1))))
+        (setq calls nil)))))
+
+(ert-deftest erc--local-module-modes ()
+
+  (should (equal (erc--local-module-modes nil '(erc-a-mode erc-b-mode))
+                 '(erc-a-mode erc-b-mode)))
+
+  (should (equal (erc--local-module-modes
+                  '((a) (b . t)) '(erc-c-mode erc-d-mode))
+                 '(erc-c-mode erc-d-mode)))
+
+  (should (equal (erc--local-module-modes
+                  '((a) (erc-b-mode) (c . t) (erc-d-mode . t) (erc-e-mode . t))
+                  '(erc-b-mode erc-d-mode))
+                 '(erc-d-mode erc-e-mode erc-b-mode)))
+
+  (should (equal (erc--local-module-modes
+                  '((a) (b . t) (erc-e-mode . t)) nil)
+                 '(erc-e-mode))))
+
 ;;; erc-tests.el ends here
-- 
2.38.1


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

From 1fa0602d570acf16639eec438a5cdbf01aa0c105 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 5/7] Call erc-login indirectly via new generic wrapper

* lisp/erc/erc-backend (erc--register-connection): Add new internal
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 e50593b8ba..87a1ae310b 100644
--- a/lisp/erc/erc-backend.el
+++ b/lisp/erc/erc-backend.el
@@ -643,6 +643,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))
+
 (defvar erc--server-connect-dumb-ipv6-regexp
   ;; Not for validation (gives false positives).
   (rx bot "[" (group (+ (any xdigit digit ":.")) (? "%" (+ alnum))) "]" eot))
@@ -697,7 +701,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.
@@ -897,7 +901,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 #8: 0006-Add-non-IRCv3-SASL-module-to-ERC.patch --]
[-- Type: text/x-patch, Size: 74366 bytes --]

From 06397315d1da2e87d06f2ccbff51b5045186554a 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 6/7] 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                             | 151 +++++-
 etc/ERC-NEWS                                  |   7 +-
 lisp/erc/erc-compat.el                        | 104 +++++
 lisp/erc/erc-sasl.el                          | 429 ++++++++++++++++++
 lisp/erc/erc.el                               |   1 +
 test/lisp/erc/erc-sasl-tests.el               | 344 ++++++++++++++
 test/lisp/erc/erc-scenarios-sasl.el           | 202 +++++++++
 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 ++
 12 files changed, 1415 insertions(+), 5 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 dd15036b2e..834f1cbf2c 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.
 * Integrations::                Integrations available for ERC.
 * Options::                     Options that are available for ERC.
@@ -486,6 +487,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
@@ -533,6 +538,7 @@ Advanced Usage
 
 @menu
 * Connecting::                  Ways of connecting to an IRC server.
+* SASL::                        Authenticating via SASL
 * Sample Configuration::        An example configuration file.
 * Integrations::                Integrations available for ERC.
 * Options::                     Options that are available for ERC.
@@ -605,6 +611,7 @@ Connecting
 parameters, and some, like @code{client-certificate}, will just be
 @code{nil}.
 
+@anchor{ERC client-certificate}
 To use a certificate with @code{erc-tls}, specify the optional
 @var{client-certificate} keyword argument, whose value should be as
 described in the documentation of @code{open-network-stream}: if
@@ -739,7 +746,10 @@ Connecting
 You can manually set another nickname with the /NICK command.
 @end defopt
 
+@anchor{ERC username}
 @subheading User
+@cindex user
+
 @defun erc-compute-user &optional user
 Determine a suitable value to send as the first argument of the
 opening @samp{USER} IRC command by consulting the following sources:
@@ -851,6 +861,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
@@ -863,7 +874,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
@@ -925,6 +937,143 @@ Connecting
 make the most sense, but any reasonably printable object is
 acceptable.
 
+@node SASL
+@section Authenticating via SASL
+@cindex SASL
+
+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 probably won't be needing SASL for the client-to-bouncer
+connection.  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.  To make this work, customize
+@code{erc-sasl-user} and @code{erc-sasl-password} or specify the
+@code{:user} and @code{:password} keyword arguments when invoking
+@code{erc-tls}.  Note that @code{:user} cannot be given interactively.
+@end indentedblock
+
+@var{external} (via Client TLS Certificate):
+
+@indentedblock
+This works in conjunction with the @code{:client-certificate} keyword
+offered by @code{erc-tls}.  Just ensure you've registered your
+fingerprint with the network beforehand.  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}).  These days, there's
+usually a @samp{CERT ADD} command offered by NickServ that can
+register you automatically if you issue it while connected with a
+client cert.  (@pxref{ERC client-certificate}).
+
+Additional considerations:
+@enumerate
+@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 a remote chance your
+server has something else in mind.
+@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 username, typically the same one
+registered with nickname services.  Specify this when your NickServ
+login differs from the @code{:user} you're connecting with.
+(@pxref{ERC username})
+@end defopt
+
+@defopt erc-sasl-password
+As noted elsewhere, the @code{:password} parameter for @code{erc-tls}
+was orignally intended for traditional ``server passwords,'' but these
+aren't really used any more.  As such, this option defaults to
+borrowing that parameter for its own uses, thus allowing you to call
+@code{erc-tls} with @code{:password} set to your NickServ password.
+
+You can also set this to a nonemtpy string, and ERC will send that
+when needed, no questions asked.  If you instead give a non-@code{nil}
+symbol (other than @code{:password}), 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 resolved 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, and it's
+@code{nil} by default.  As a last resort, ERC will prompt you for
+input.
+
+Lastly, 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
+
+@subheading Troubleshooting
+
+@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're struggling, remember that your SASL password is almost
+always your NickServ password.  When in doubt, try restoring all SASL
+options to their defaults and calling @code{erc-tls} with @code{:user}
+set to your NickServ account name and @code{:password} to your
+NickServ password.  If you're still having trouble, please contact us
+(@pxref{Getting Help and Reporting Bugs}).
+
+As you try out different settings, keep in mind that it's best to
+create a fresh session for every change, for example, by calling
+@code{erc-tls} from scratch.  More experienced users may be able to
+get away with cycling @code{erc-sasl-mode} and issuing a
+@samp{/reconnect}, but that's generally not recommended.  Whatever the
+case, you'll probably want to temporarily disable
+@code{erc-server-auto-reconnect} while experimenting.
 
 @node Sample Configuration
 @section Sample Configuration
diff --git a/etc/ERC-NEWS b/etc/ERC-NEWS
index 832a9566d7..3e1b7bca95 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 d23703394b..8b95f8ac81 100644
--- a/lisp/erc/erc-compat.el
+++ b/lisp/erc/erc-compat.el
@@ -273,6 +273,110 @@ erc-compat--auth-source-backend-parser-functions
     auth-source-backend-parser-functions))
 
 
+;;;; 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..a48e3f6a7f
--- /dev/null
+++ b/lisp/erc/erc-sasl.el
@@ -0,0 +1,429 @@
+;;; 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.
+;;
+;; - 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 :user
+  "Account username to send when authenticating.
+This is also referred to as the authentication identity or
+\"authcid\".  A value of `:user' or `:nick' indicates that the
+corresponding connection parameter on file should be used.  These
+are most often derived from arguments provided to the `erc' and
+`erc-tls' entry points.  In the case of `:nick', a downcased
+version is used."
+  :type '(choice string (const :user) (const :nick)))
+
+(defcustom erc-sasl-password :password
+  "Optional account password to send when authenticating.
+When the value is a string, ERC will use it unconditionally for
+most mechanisms.  Likewise with `:password', except ERC will
+instead use the \"session password\" on file, which often
+originates from the entry-point commands `erc' or `erc-tls'.
+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 prompt for input.
+
+Note that, with `:password', ERC will forgo sending a traditional
+server password via the IRC \"PASS\" command.  Also, when
+`erc-sasl-mechanism' is set to `ecdsa-nist256p-challenge', this
+option should hold the file name of the key."
+  :type '(choice (const nil) (const :password) 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
+includes `erc-sasl-user' for the `:user' field and
+`erc-sasl-password' for the `:host' field, when the latter option
+is a non-nil, non-keyword symbol.  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)
+
+;; 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--get-user ()
+  (pcase (alist-get 'user erc-sasl--options)
+    (:user erc-session-username)
+    (:nick (erc-downcase (erc-current-nick)))
+    (v v)))
+
+(defun erc-sasl--read-password (prompt)
+  "Return configured option or server password.
+PROMPT is passed to `read-passwd' if necessary."
+  (if-let
+      ((found (pcase (alist-get 'password erc-sasl--options)
+                (:password erc-session-password)
+                ((and (pred stringp) v) (unless (string-empty-p v) v))
+                ((and (guard erc-sasl-auth-source-function)
+                      v (let host
+                          (or v (erc-networks--id-given erc-networks--id))))
+                 (apply erc-sasl-auth-source-function
+                        :user (erc-sasl--get-user)
+                        (and host (list :host (symbol-name host))))))))
+      (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 (list name))
+                                     (erc-sasl--get-user)
+                                     "N/A" "N/A"))
+      (sasl-client-set-property client 'authenticator-name
+                                (alist-get 'authzid erc-sasl--options))
+      client)))
+
+(cl-defmethod erc-sasl--create-client ((_ (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 (erc-sasl--get-user))
+         (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 ((_ (eql scram-sha-256)))
+  "Create and return a new SCRAM-SHA-256 client."
+  (when (featurep 'sasl-scram-sha256)
+    (cl-call-next-method)))
+
+(cl-defmethod erc-sasl--create-client ((_ (eql scram-sha-512)))
+  "Create and return a new SCRAM-SHA-512 client."
+  (when (featurep 'sasl-scram-sha256)
+    (cl-call-next-method)))
+
+(cl-defmethod erc-sasl--create-client ((_ (eql ecdsa-nist256p-challenge)))
+  "Create and return a new ECDSA-NIST256P-CHALLENGE client."
+  (let ((keyfile (cdr (assq 'password erc-sasl--options))))
+    ;; Better to signal usage errors now than inside a process filter.
+    (cond ((or (not (stringp keyfile)) (not (file-readable-p keyfile)))
+           (erc-display-error-notice
+            nil "`erc-sasl-password' not accessible as a file")
+           nil)
+          ((not (executable-find "openssl"))
+           (erc-display-error-notice nil "Could not find openssl program")
+           nil)
+          (t
+           (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 ()
+  (setq erc-sasl--state (make-erc-sasl--state))
+  ;; If the previous attempt failed during registration, this may be
+  ;; non-nil and contain erroneous values, but how can we detect that?
+  ;; What if the server dropped the connection for some other reason?
+  (setq erc-sasl--options
+        (or (and erc--server-reconnecting
+                 (alist-get 'erc-sasl--options erc--server-reconnecting))
+            `((user . ,erc-sasl-user)
+              (password . ,erc-sasl-password)
+              (mechanism . ,erc-sasl-mechanism)
+              (authzid . ,erc-sasl-authzid)))))
+
+(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 or unsupported SASL mechanism: %s" mech))
+         (erc-error "Unknown or unsupported SASL mechanism: %s" mech))
+       (setf (erc-sasl--state-client erc-sasl--state) client))))
+  ((remove-hook 'erc-server-AUTHENTICATE-functions
+                #'erc-sasl--authenticate-handler t)
+   (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")))
+  (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 (string-join (cdr (erc-response.command-args 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")
+        (if (and erc-session-password
+                 (eq :password (alist-get 'password erc-sasl--options)))
+            (let (erc-session-password)
+              (erc-login))
+          (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 23c7f6d438..f55b9321aa 100644
--- a/lisp/erc/erc.el
+++ b/lisp/erc/erc.el
@@ -1853,6 +1853,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..20a6760083
--- /dev/null
+++ b/test/lisp/erc/erc-sasl-tests.el
@@ -0,0 +1,344 @@
+;;; 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--basic ()
+  (ert-info ("Explicit erc-sasl-password")
+    (let ((erc-sasl--options '((password . "foo"))))
+      (should (string= (erc-sasl--read-password nil) "foo"))))
+
+  (ert-info ("Explicit session password")
+    (let ((erc-session-password "foo")
+          (erc-sasl--options '((password . :password))))
+      (should (string= (erc-sasl--read-password nil) "foo"))))
+
+  (ert-info ("Fallback to prompt skip auth-source")
+    (should-not erc-sasl-auth-source-function)
+    (let ((erc-session-password "bar")
+          (erc-networks--id (erc-networks--id-create nil)))
+      (should (string= (ert-simulate-keys "bar\r"
+                         (erc-sasl--read-password "?"))
+                       "bar"))))
+
+  (ert-info ("Prompt when auth-source fails and `erc-sasl-password' null")
+    (let ((erc-sasl--options '((password)))
+          (erc-sasl-auth-source-function #'ignore))
+      (should (string= (ert-simulate-keys "baz\r"
+                         (erc-sasl--read-password "pwd:"))
+                       "baz")))))
+
+(ert-deftest erc-sasl--read-password--auth-source ()
+  (ert-with-temp-file netrc-file
+    :text (string-join
+           (list
+            ;; If you swap these first 2 lines, *1 below fails
+            "machine FSF.chat port 6697 user bob password sesame"
+            "machine GNU/chat port 6697 user bob password spam"
+            "machine MyHost port irc password 123")
+           "\n")
+    (let* ((auth-sources (list netrc-file))
+           (erc-session-server "irc.gnu.org")
+           (erc-session-port 6697)
+           (erc-networks--id (erc-networks--id-create nil))
+           calls
+           (erc-sasl-auth-source-function
+            (lambda (&rest r)
+              (push r calls)
+              (apply #'erc--auth-source-search r)))
+           erc-server-announced-name ; too early
+           auth-source-do-cache)
+
+      (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"))
+          (should (equal (pop calls) '(:user "bob" :host "FSF.chat")))))
+
+      (ert-info ("ID for :host and `erc-session-username' for :user") ; *1
+        (let ((erc-session-username "bob")
+              (erc-sasl--options '((user . :user) (password)))
+              (erc-networks--id (erc-networks--id-create 'GNU/chat)))
+          (should (string= (erc-sasl--read-password nil) "spam"))
+          (should (equal (pop calls) '(:user "bob" :host "GNU/chat")))))
+
+      (ert-info ("ID for :host and current nick for :user") ; *1
+        (let ((erc-server-current-nick "bob")
+              (erc-sasl--options '((user . :nick) (password)))
+              (erc-networks--id (erc-networks--id-create 'GNU/chat)))
+          (should (string= (erc-sasl--read-password nil) "spam"))
+          (should (equal (pop calls) '(:user "bob" :host "GNU/chat")))))
+
+      (ert-info ("Symbol as password, entry lacks user field")
+        (let ((erc-server-current-nick "fake")
+              (erc-sasl--options '((user . :nick) (password . MyHost)))
+              (erc-networks--id (erc-networks--id-create 'GNU/chat)))
+          (should (string= (erc-sasl--read-password nil) "123"))
+          (should (equal (pop calls) '(:user "fake" :host "MyHost"))))))))
+
+(ert-deftest erc-sasl-create-client--plain ()
+  (let* ((erc-session-password "password123")
+         (erc-session-username "tester")
+         (erc-sasl--options '((user . :user) (password . :password)))
+         (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")
+         (erc-sasl--options '((user . :nick) (password . :password)))
+         (client (erc-sasl--create-client 'external)) ; unused ^
+         (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-sasl--options '((user . "jilles") (password . "sesame")
+                              (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 '((user . :nick) (password . :password)
+                              (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")
+         (erc-sasl--options '((user . :nick) (password . :password) (authzid)))
+         (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")
+         (erc-sasl--options '((user . :nick) (password . :password) (authzid)))
+         (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..713c9929c3
--- /dev/null
+++ b/test/lisp/erc/erc-scenarios-sasl.el
@@ -0,0 +1,202 @@
+;;; 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-password "password123")
+       (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))
+       (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 (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)
+       (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)
+       (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-user :nick)
+       (erc-sasl-mechanism mech)
+       (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"
+                                :password "sesame"
+                                :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/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


[-- Warning: decoded text below may be mangled, UTF-8 assumed --]
[-- Attachment #9: 0007-Accept-functions-in-place-of-passwords-in-ERC.patch --]
[-- Type: text/x-patch, Size: 11519 bytes --]

From 9e951848ba068d29138c9663e76bb4fa114660be 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 7/7] Accept functions in place of passwords in ERC

* lisp/erc/erc-backend.el (erc-session-password): Add comment
explaining type is now string, nil, or function.
* lisp/erc/erc-sasl.el (erc-sasl--read-password): Use `erc--unfun'.
* lisp/erc/erc-services.el (erc-nickserv-get-password,
erc-nickserv-send-identify): Use `erc--unfun'.
* lisp/erc/erc.el (erc--unfun): New function for unwrapping a
password couched in a getter.
(erc--debug-irc-protocol-mask-secrets): Add variable to indicate
whether to mask passwords in debug logs.
(erc--mask-secrets): New function to swap masked secret with question
marks in debug logs.
(erc-log-irc-protocol): Conditionally mask secrets when
`erc--debug-irc-protocol-mask-secrets' is non-nil.
(erc--auth-source-search): Don't unwrap secret from function before
returning.
(erc-server-join-channel, erc-login): Use `erc--unfun'.

* test/lisp/erc/erc-services-tests.el
(erc-services-tests--wrap-search): Add helper for `erc--unfun'.
(erc-services-tests--auth-source-standard,
erc-services-tests--auth-source-announced,
erc-services-tests--auth-source-overrides, erc-nickserv-get-password):
Use `erc--unfun'.
* test/lisp/erc/erc-tests.el (erc--debug-irc-protocol-mask-secrets):
Add test for masking secrets with `erc--unfun' and friends.
---
 lisp/erc/erc-backend.el             |  3 ++-
 lisp/erc/erc-sasl.el                |  2 +-
 lisp/erc/erc-services.el            |  5 ++--
 lisp/erc/erc.el                     | 38 +++++++++++++++++++++++++----
 test/lisp/erc/erc-services-tests.el | 16 +++++++++---
 test/lisp/erc/erc-tests.el          | 22 +++++++++++++++++
 6 files changed, 73 insertions(+), 13 deletions(-)

diff --git a/lisp/erc/erc-backend.el b/lisp/erc/erc-backend.el
index 87a1ae310b..ed1a92867b 100644
--- a/lisp/erc/erc-backend.el
+++ b/lisp/erc/erc-backend.el
@@ -205,7 +205,8 @@ erc-whowas-on-nosuchnick
 ;;;; Variables and options
 
 (defvar-local erc-session-password nil
-  "The password used for the current session.")
+  "The password used for the current session.
+This should be a string or a function returning a string.")
 
 (defvar erc-server-responses (make-hash-table :test #'equal)
   "Hash table mapping server responses to their handler hooks.")
diff --git a/lisp/erc/erc-sasl.el b/lisp/erc/erc-sasl.el
index a48e3f6a7f..16fe93f50d 100644
--- a/lisp/erc/erc-sasl.el
+++ b/lisp/erc/erc-sasl.el
@@ -143,7 +143,7 @@ erc-sasl--read-password
                  (apply erc-sasl-auth-source-function
                         :user (erc-sasl--get-user)
                         (and host (list :host (symbol-name host))))))))
-      (copy-sequence found)
+      (copy-sequence (erc--unfun found))
     (read-passwd prompt)))
 
 (defun erc-sasl--plain-response (client steps)
diff --git a/lisp/erc/erc-services.el b/lisp/erc/erc-services.el
index fe9cb5b5f1..48953288d1 100644
--- a/lisp/erc/erc-services.el
+++ b/lisp/erc/erc-services.el
@@ -455,7 +455,7 @@ erc-nickserv-get-password
                   (read-passwd
                    (format "NickServ password for %s on %s (RET to cancel): "
                            nick nid)))))
-       ((not (string-empty-p ret))))
+       ((not (string-empty-p (erc--unfun ret)))))
     ret))
 
 (defvar erc-auto-discard-away)
@@ -477,7 +477,8 @@ erc-nickserv-send-identify
          (msgtype (or (erc-nickserv-alist-ident-command nil nickserv-info)
                       "PRIVMSG")))
     (erc-message msgtype
-                 (concat nickserv " " identify-word " " nick password))))
+                 (concat nickserv " " identify-word " " nick
+                         (erc--unfun password)))))
 
 (defun erc-nickserv-call-identify-function (nickname)
   "Call `erc-nickserv-identify' with NICKNAME."
diff --git a/lisp/erc/erc.el b/lisp/erc/erc.el
index f55b9321aa..0b1f44e560 100644
--- a/lisp/erc/erc.el
+++ b/lisp/erc/erc.el
@@ -2318,6 +2318,23 @@ erc-debug-irc-protocol
 WARNING: Do not set this variable directly!  Instead, use the
 function `erc-toggle-debug-irc-protocol' to toggle its value.")
 
+(defvar erc--debug-irc-protocol-mask-secrets t
+  "Whether to hide secrets in a debug log.
+They are still visible on screen but are replaced by question
+marks when yanked.")
+
+(defun erc--mask-secrets (string)
+  (when-let* ((eot (length string))
+              (beg (text-property-any 0 eot 'erc-secret t string))
+              (end (text-property-not-all beg eot 'erc-secret t string))
+              (sec (substring string beg end)))
+    (setq string (concat (substring string 0 beg)
+                         (make-string (- end beg) ??)
+                         (substring string end eot)))
+    (put-text-property beg end 'face 'erc-inverse-face string)
+    (put-text-property beg end 'display sec string))
+  string)
+
 (defun erc-log-irc-protocol (string &optional outbound)
   "Append STRING to the buffer *erc-protocol*.
 
@@ -2343,6 +2360,8 @@ erc-log-irc-protocol
                       (format "%s:%s" erc-session-server erc-session-port))))
           (ts (when erc-debug-irc-protocol-time-format
                 (format-time-string erc-debug-irc-protocol-time-format))))
+      (when erc--debug-irc-protocol-mask-secrets
+        (setq string (erc--mask-secrets string)))
       (with-current-buffer (get-buffer-create "*erc-protocol*")
         (save-excursion
           (goto-char (point-max))
@@ -3268,9 +3287,8 @@ erc--auth-source-search
       (setq plist (plist-put plist :max 5000))) ; `auth-source-netrc-parse'
     (unless (plist-get defaults :require)
       (setq plist (plist-put plist :require '(:secret))))
-    (when-let* ((sorted (sort (apply #'auth-source-search plist) test))
-                (secret (plist-get (car sorted) :secret)))
-      (if (functionp secret) (funcall secret) secret))))
+    (when-let* ((sorted (sort (apply #'auth-source-search plist) test)))
+      (plist-get (car sorted) :secret))))
 
 (defun erc-auth-source-search (&rest plist)
   "Call `auth-source-search', possibly with keyword params in PLIST."
@@ -3291,7 +3309,8 @@ erc-server-join-channel
     (setq secret (apply erc-auth-source-join-function
                         `(,@(and server (list :host server)) :user ,channel))))
   (erc-log (format "cmd: JOIN: %s" channel))
-  (erc-server-send (concat "JOIN " channel (and secret (concat " " secret)))))
+  (erc-server-send (concat "JOIN " channel
+                           (and secret (concat " " (erc--unfun secret))))))
 
 (defun erc--valid-local-channel-p (channel)
   "Non-nil when channel is server-local on a network that allows them."
@@ -6321,6 +6340,15 @@ erc-load-irc-script-lines
 
 ;; authentication
 
+(defun erc--unfun (maybe-fn)
+  "Return MAYBE-FN or whatever it returns."
+  (let ((s (if (functionp maybe-fn) (funcall maybe-fn) maybe-fn)))
+    (when (and erc-debug-irc-protocol
+               erc--debug-irc-protocol-mask-secrets
+               (stringp s))
+      (put-text-property 0 (length s) 'erc-secret t s))
+    s))
+
 (defun erc-login ()
   "Perform user authentication at the IRC server."
   (erc-log (format "login: nick: %s, user: %s %s %s :%s"
@@ -6330,7 +6358,7 @@ erc-login
                    erc-session-server
                    erc-session-user-full-name))
   (if erc-session-password
-      (erc-server-send (concat "PASS :" erc-session-password))
+      (erc-server-send (concat "PASS :" (erc--unfun erc-session-password)))
     (message "Logging in without password"))
   (erc-server-send (format "NICK %s" (erc-current-nick)))
   (erc-server-send
diff --git a/test/lisp/erc/erc-services-tests.el b/test/lisp/erc/erc-services-tests.el
index 7ff2e36e77..2547c5e01a 100644
--- a/test/lisp/erc/erc-services-tests.el
+++ b/test/lisp/erc/erc-services-tests.el
@@ -62,9 +62,13 @@ erc--auth-source-determine-params-merge
                            :x ("x")
                            :require (:secret))))))
 
+(defun erc-services-tests--wrap-search (s)
+  (lambda (&rest r) (erc--unfun (apply s r))))
+
 ;; Some of the following may be related to bug#23438.
 
 (defun erc-services-tests--auth-source-standard (search)
+  (setq search (erc-services-tests--wrap-search search))
 
   (ert-info ("Session wins")
     (let ((erc-session-server "irc.gnu.org")
@@ -93,6 +97,7 @@ erc-services-tests--auth-source-standard
       (should (string= (funcall search :user "#chan") "baz")))))
 
 (defun erc-services-tests--auth-source-announced (search)
+  (setq search (erc-services-tests--wrap-search search))
   (let* ((erc--isupport-params (make-hash-table))
          (erc-server-parameters '(("CHANTYPES" . "&#")))
          (erc--target (erc--target-from-string "&chan")))
@@ -124,6 +129,7 @@ erc-services-tests--auth-source-announced
           (should (string= (funcall search :user "#chan") "foo")))))))
 
 (defun erc-services-tests--auth-source-overrides (search)
+  (setq search (erc-services-tests--wrap-search search))
   (let* ((erc-session-server "irc.gnu.org")
          (erc-server-announced-name "my.gnu.org")
          (erc-network 'GNU.chat)
@@ -537,18 +543,20 @@ erc-nickserv-get-password
            (erc-network 'FSF.chat)
            (erc-server-current-nick "tester")
            (erc-networks--id (erc-networks--id-create nil))
-           (erc-session-port 6697))
+           (erc-session-port 6697)
+           (search (erc-services-tests--wrap-search
+                    #'erc-nickserv-get-password)))
 
       (ert-info ("Lookup custom option")
-        (should (string= (erc-nickserv-get-password "alice") "foo")))
+        (should (string= (funcall search "alice") "foo")))
 
       (ert-info ("Auth source")
         (ert-info ("Network")
-          (should (string= (erc-nickserv-get-password "bob") "sesame")))
+          (should (string= (funcall search "bob") "sesame")))
 
         (ert-info ("Network ID")
           (let ((erc-networks--id (erc-networks--id-create 'GNU/chat)))
-            (should (string= (erc-nickserv-get-password "bob") "spam")))))
+            (should (string= (funcall search "bob") "spam")))))
 
       (ert-info ("Read input")
         (should (string=
diff --git a/test/lisp/erc/erc-tests.el b/test/lisp/erc/erc-tests.el
index bc4128dc9e..4e646a775b 100644
--- a/test/lisp/erc/erc-tests.el
+++ b/test/lisp/erc/erc-tests.el
@@ -530,6 +530,28 @@ erc-ring-previous-command
   (when noninteractive
     (kill-buffer "*#fake*")))
 
+(ert-deftest erc--debug-irc-protocol-mask-secrets ()
+  (should-not erc-debug-irc-protocol)
+  (should erc--debug-irc-protocol-mask-secrets)
+  (with-temp-buffer
+    (setq erc-server-process (start-process "fake" (current-buffer) "true")
+          erc-server-current-nick "tester"
+          erc-session-server "myproxy.localhost"
+          erc-session-port 6667)
+    (let ((inhibit-message noninteractive))
+      (erc-toggle-debug-irc-protocol)
+      (erc-log-irc-protocol
+       (concat "PASS :" (erc--unfun (lambda () "changeme")) "\r\n")
+       'outgoing)
+      (set-process-query-on-exit-flag erc-server-process nil))
+    (with-current-buffer "*erc-protocol*"
+      (goto-char (point-min))
+      (search-forward "\r\n\r\n")
+      (search-forward "myproxy.localhost:6667 >> PASS :????????" (pos-eol)))
+    (when noninteractive
+      (kill-buffer "*erc-protocol*")
+      (should-not erc-debug-irc-protocol))))
+
 (ert-deftest erc-log-irc-protocol ()
   (should-not erc-debug-irc-protocol)
   (with-temp-buffer
-- 
2.38.1


^ permalink raw reply related	[flat|nested] 54+ messages in thread

* bug#29108: 25.3; ERC SASL support
       [not found]                         ` <87tu2vroeh.fsf@neverwas.me>
@ 2022-11-20 14:29                           ` J.P.
       [not found]                           ` <87wn7pog1l.fsf@neverwas.me>
  1 sibling, 0 replies; 54+ messages in thread
From: J.P. @ 2022-11-20 14:29 UTC (permalink / raw)
  To: 29108; +Cc: emacs-erc, bandali

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

v.12 Fixed regression involving buffer reassociation on forced renicks.
In so doing, made behavior slightly more aggressive. Also fixed problem
persisting local-module modes across reconnects.

WRT the latter, if you were to toggle off a local module, like
`erc-sasl-mode', and reconnect, ERC would activate it again the second
time through. (Though fixed, this needs an accompanying test.)

Also spotted:

  *** irc.foonet.org 900 * * tester You are now logged in as tester
  *** irc.foonet.org 903 * Authentication successful

Pretty redundant/noisy. We should maybe add a catalog entry for 903 and
a handler for 900 (although some servers use 900 for non-SASL purposes,
I believe).

Another possible UX annoyance is that local toggles, like
`erc-sasl-mode', don't currently work in target buffers at all. We
should maybe wrap them in an `erc-with-server-buffer'.


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

From 4cffbaec3a551faf441067b3b017438f4fddd3cc Mon Sep 17 00:00:00 2001
From: "F. Jason Park" <jp@neverwas.me>
Date: Sun, 20 Nov 2022 00:45:44 -0800
Subject: [PATCH 0/7] *** NOT A PATCH ***

*** BLURB HERE ***

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

 doc/misc/erc.texi                             | 162 ++++++-
 etc/ERC-NEWS                                  |  13 +-
 lisp/erc/erc-backend.el                       |  33 +-
 lisp/erc/erc-common.el                        |  56 ++-
 lisp/erc/erc-compat.el                        | 104 +++++
 lisp/erc/erc-goodies.el                       |   1 +
 lisp/erc/erc-networks.el                      |  53 ++-
 lisp/erc/erc-sasl.el                          | 429 ++++++++++++++++++
 lisp/erc/erc-services.el                      |   5 +-
 lisp/erc/erc.el                               | 150 ++++--
 lisp/net/sasl-scram-rfc.el                    |  21 +-
 test/lisp/erc/erc-sasl-tests.el               | 344 ++++++++++++++
 .../erc-scenarios-base-association-nick.el    |  84 ++--
 test/lisp/erc/erc-scenarios-sasl.el           | 202 +++++++++
 test/lisp/erc/erc-services-tests.el           |  16 +-
 test/lisp/erc/erc-tests.el                    |  83 ++++
 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 ++
 21 files changed, 1793 insertions(+), 145 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/lisp/erc/erc-backend.el b/lisp/erc/erc-backend.el
index ed1a92867b..3cd949ddd3 100644
--- a/lisp/erc/erc-backend.el
+++ b/lisp/erc/erc-backend.el
@@ -161,6 +161,7 @@ erc-whowas-on-nosuchnick
 (declare-function erc-login "erc" nil)
 (declare-function erc-make-notice "erc" (message))
 (declare-function erc-network "erc-networks" nil)
+(declare-function erc-networks--id-ensure-reload "erc-networks" (nid))
 (declare-function erc-networks--id-given "erc-networks" (arg &rest args))
 (declare-function erc-networks--id-reload "erc-networks" (arg &rest args))
 (declare-function erc-nickname-in-use "erc" (nick reason))
@@ -1637,9 +1638,7 @@ 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-networks--id-ensure-reload erc-networks--id)
         (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))))))
@@ -2266,8 +2265,7 @@ 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-networks--id-ensure-reload erc-networks--id)
   (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 100d5b4b58..fed9b3591b 100644
--- a/lisp/erc/erc-networks.el
+++ b/lisp/erc/erc-networks.el
@@ -985,6 +985,20 @@ erc-networks--id-reload
                   ((not (equal (buffer-name) new-name))))
         (rename-buffer new-name 'unique))))
 
+(cl-defgeneric erc-networks--id-ensure-reload (_nid)
+  "Schedule or run a reload for IDs derived from nicks."
+  nil)
+
+(cl-defmethod erc-networks--id-ensure-reload
+  ((nid erc-networks--id-qualifying))
+  (if erc-server-connected
+      (erc-networks--id-reload nid)
+    (letrec ((fn (lambda (&rest _)
+                   (when (eq nid erc-networks--id)
+                     (erc-networks--id-reload nid))
+                   (remove-hook 'erc-after-connect fn t))))
+      (add-hook 'erc-after-connect fn nil t))))
+
 (cl-defgeneric erc-networks--id-ensure-comparable (self other)
   "Take measures to ensure two net identities are in comparable states.")
 
diff --git a/lisp/erc/erc.el b/lisp/erc/erc.el
index 0b1f44e560..8daf8397d1 100644
--- a/lisp/erc/erc.el
+++ b/lisp/erc/erc.el
@@ -1918,16 +1918,23 @@ erc-setup-buffer
          (display-buffer buffer)
        (switch-to-buffer buffer)))))
 
-(defun erc--local-module-modes (old-vars new)
-  ;; Emacs 27 doesnt' have `local-minor-modes'
-  (if-let* ((old-vars)
-            (fn (pcase-lambda (`(,k . ,v))
-                  (and (string-prefix-p "erc-" (symbol-name k))
-                       (string-suffix-p "-mode" (symbol-name k))
-                       v
-                       k))))
-      (delete-dups (append (delq nil (mapcar fn old-vars)) new nil))
-    new))
+(defun erc--merge-local-modes (new-modes old-vars)
+  "Return a cons of two lists, each containing local-module modes.
+In the first, put modes to be enabled in a new ERC buffer by
+calling their associated functions.  In the second, put modes to
+be marked as disabled by setting their associated variables to
+nil."
+  (if old-vars
+      (let ((out (list (reverse new-modes))))
+        (pcase-dolist (`(,k . ,v) old-vars)
+          (when (and (string-prefix-p "erc-" (symbol-name k))
+                     (string-suffix-p "-mode" (symbol-name k)))
+            (if v
+                (cl-pushnew k (car out))
+              (setf (car out) (delq k (car out)))
+              (cl-pushnew k (cdr out)))))
+        (cons (nreverse (car out)) (nreverse (cdr out))))
+    (list new-modes)))
 
 (defun erc-open (&optional server port nick full-name
                            connect passwd tgt-list channel process
@@ -1966,8 +1973,8 @@ erc-open
     (when connect (run-hook-with-args 'erc-before-connect server port nick))
     (set-buffer buffer)
     (setq old-point (point))
-    (setq delayed-modules (erc--local-module-modes erc--server-reconnecting
-                                                   (erc--update-modules)))
+    (setq delayed-modules (erc--merge-local-modes (erc--update-modules)
+                                                  erc--server-reconnecting))
 
     (delay-mode-hooks (erc-mode))
 
@@ -2041,7 +2048,8 @@ erc-open
     (erc-determine-parameters server port nick full-name user passwd)
 
     (save-excursion (run-mode-hooks))
-    (dolist (mod delayed-modules) (funcall mod +1))
+    (dolist (mod (car delayed-modules)) (funcall mod +1))
+    (dolist (var (cdr delayed-modules)) (set var nil))
 
     ;; set up prompt
     (unless continued-session
@@ -5938,7 +5946,12 @@ erc-set-current-nick
   (with-current-buffer (if (buffer-live-p (erc-server-buffer))
                            (erc-server-buffer)
                          (current-buffer))
-    (setq erc-server-current-nick nick)))
+    (unless (equal erc-server-current-nick nick)
+      (setq erc-server-current-nick nick)
+      ;; This seems sensible but may well be superfluous.  Should
+      ;; really prove that it's actually needed via test scenario.
+      (erc-networks--id-ensure-reload erc-networks--id))
+    nick))
 
 (defun erc-current-nick ()
   "Return the current nickname."
diff --git a/test/lisp/erc/erc-scenarios-base-association-nick.el b/test/lisp/erc/erc-scenarios-base-association-nick.el
index 3e848be4df..b46c996bc0 100644
--- a/test/lisp/erc/erc-scenarios-base-association-nick.el
+++ b/test/lisp/erc/erc-scenarios-base-association-nick.el
@@ -25,13 +25,24 @@
 
 (eval-when-compile (require 'erc-join))
 
-;; You register a new nick, disconnect, and log back in, but your nick
-;; is not granted, so ERC obtains a backtick'd version.  You open a
-;; query buffer for NickServ, and ERC names it using the net-ID (which
-;; includes the backtick'd nick) as a suffix.  The original
-;; (disconnected) NickServ buffer gets renamed with *its* net-ID as
-;; well.  You then identify to NickServ, and the dead session is no
-;; longer considered distinct.
+;; You register a new nick in a dedicated query buffer, disconnect,
+;; and log back in, but your nick is not granted (maybe you just
+;; turned off SASL).  In any case, ERC obtains a backtick'd version.
+;; You open a query buffer for NickServ, and ERC gives you the
+;; existing one.  And after you identify, all buffers retain their
+;; names, although your net ID has changed internally.
+;;
+;; If ERC would've instead failed (or intentionally refused) to make
+;; the association, you would've ended up with a new NickServ buffer
+;; named after the new net ID as a suffix (based on the backtick'd
+;; nick), for example, NickServ@foonet/tester`.  And the original
+;; (disconnected) NickServ buffer would've gotten suffixed with *its*
+;; net-ID as well, e.g., NickServ@foonet/tester.  And after
+;; identifying, you would've seen ERC merge the two as well as their
+;; server buffers.  While this alternate behavior may arguably be a
+;; more honest reflection of reality, it's also quite inconvenient.
+;; For a clearer example, see the original version of this file
+;; introduced by "Add user-oriented test scenarios for ERC".
 
 (ert-deftest erc-scenarios-base-association-nick-bumped ()
   :tags '(:expensive-test)
@@ -67,30 +78,29 @@ erc-scenarios-base-association-nick-bumped
           (funcall expect 5 "ERC finished"))))
 
     (with-current-buffer "foonet"
-      (erc-cmd-RECONNECT))
+      (erc-cmd-RECONNECT)
+      (funcall expect 10 "User modes for tester`"))
 
-    (erc-d-t-wait-for 10 "Nick request rejection prevents reassociation (good)"
-      (get-buffer "foonet/tester`"))
+    (ert-info ("Server buffer reassociated with new nick")
+      (should-not (get-buffer "foonet/tester`")))
 
     (ert-info ("Ask NickServ to change nick")
-      (with-current-buffer "foonet/tester`"
-        (funcall expect 3 "already in use")
+      (with-current-buffer "foonet"
         (funcall expect 3 "debug mode")
         (erc-cmd-QUERY "NickServ"))
 
-      (erc-d-t-wait-for 1 "Dead NickServ query buffer renamed, now qualified"
-        (get-buffer "NickServ@foonet/tester"))
+      (ert-info ( "NickServ buffer reassociated")
+        (should-not (get-buffer "NickServ@foonet/tester`"))
+        (should-not (get-buffer "NickServ@foonet/tester")))
 
-      (with-current-buffer "NickServ@foonet/tester`" ; new one
+      (with-current-buffer "NickServ" ; new one
         (erc-scenarios-common-say "IDENTIFY tester changeme")
-        (funcall expect 5 "You're now logged in as tester")
-        (ert-info ("Original buffer found, reused")
-          (erc-d-t-wait-for 2 (equal (buffer-name) "NickServ")))))
+        (funcall expect 5 "You're now logged in as tester")))
 
-    (ert-info ("Ours is the only NickServ buffer that remains")
+    (ert-info ("Still just one NickServ buffer")
       (should-not (cdr (erc-scenarios-common-buflist "NickServ"))))
 
-    (ert-info ("Visible network ID truncated to one component")
+    (ert-info ("As well as one server buffer")
       (should (not (get-buffer "foonet/tester`")))
       (should (not (get-buffer "foonet/tester")))
       (should (get-buffer "foonet")))))
@@ -135,29 +145,29 @@ erc-scenarios-base-association-nick-bumped-mandated-renick
     ;; Since we use reconnect, a new buffer won't be created
     ;; TODO add variant with clean `erc' invocation
     (with-current-buffer "foonet"
-      (erc-cmd-RECONNECT))
+      (erc-cmd-RECONNECT)
+      (funcall expect 10 "User modes for dummy"))
 
-    (ert-info ("Server-initiated renick")
-      (with-current-buffer (erc-d-t-wait-for 10 (get-buffer "foonet/dummy"))
-        (should-not (get-buffer "foonet/tester"))
-        (funcall expect 15 "debug mode"))
+    (ert-info ("Server-initiated renick associated correctly")
+      (with-current-buffer "foonet"
+        (funcall expect 15 "debug mode")
+        (should-not (get-buffer "foonet/dummy"))
+        (should-not (get-buffer "foonet/tester")))
 
-      (erc-d-t-wait-for 1 "Old query renamed, now qualified"
-        (get-buffer "bob@foonet/tester"))
+      (ert-info ("Old query reassociated")
+        (should (get-buffer "bob"))
+        (should-not (get-buffer "bob@foonet/tester"))
+        (should-not (get-buffer "bob@foonet/dummy")))
 
-      (with-current-buffer (erc-d-t-wait-for 5 (get-buffer "bob@foonet/dummy"))
+      (with-current-buffer "foonet"
         (erc-cmd-NICK "tester")
-        (ert-info ("Buffers combined")
-          (erc-d-t-wait-for 2 (equal (buffer-name) "bob")))))
+        (funcall expect 5 "You're now logged in as tester")))
 
-    (with-current-buffer "foonet"
-      (funcall expect 5 "You're now logged in as tester"))
-
-    (ert-info ("Ours is the only bob buffer that remains")
+    (ert-info ("Ours is still the only bob buffer that remains")
       (should-not (cdr (erc-scenarios-common-buflist "bob"))))
 
-    (ert-info ("Visible network ID truncated to one component")
-      (should (not (get-buffer "foonet/dummy")))
-      (should (get-buffer "foonet")))))
+    (ert-info ("Visible network ID still truncated to one component")
+      (should (not (get-buffer "foonet/tester")))
+      (should (not (get-buffer "foonet/dummy"))))))
 
 ;;; erc-scenarios-base-association-nick.el ends here
diff --git a/test/lisp/erc/erc-tests.el b/test/lisp/erc/erc-tests.el
index 4e646a775b..c74c2e4747 100644
--- a/test/lisp/erc/erc-tests.el
+++ b/test/lisp/erc/erc-tests.el
@@ -1247,22 +1247,18 @@ erc--update-modules
                                            erc-networks (networks . 1))))
         (setq calls nil)))))
 
-(ert-deftest erc--local-module-modes ()
-
-  (should (equal (erc--local-module-modes nil '(erc-a-mode erc-b-mode))
-                 '(erc-a-mode erc-b-mode)))
-
-  (should (equal (erc--local-module-modes
-                  '((a) (b . t)) '(erc-c-mode erc-d-mode))
-                 '(erc-c-mode erc-d-mode)))
-
-  (should (equal (erc--local-module-modes
-                  '((a) (erc-b-mode) (c . t) (erc-d-mode . t) (erc-e-mode . t))
-                  '(erc-b-mode erc-d-mode))
-                 '(erc-d-mode erc-e-mode erc-b-mode)))
-
-  (should (equal (erc--local-module-modes
-                  '((a) (b . t) (erc-e-mode . t)) nil)
-                 '(erc-e-mode))))
+(ert-deftest erc--merge-local-modes ()
+
+  (ert-info ("No existing modes")
+    (let ((old '((a) (b . t)))
+          (new '(erc-c-mode erc-d-mode)))
+      (should (equal (erc--merge-local-modes new old)
+                     '((erc-c-mode erc-d-mode))))))
+
+  (ert-info ("Active existing added, inactive existing removed, deduped")
+    (let ((old '((a) (erc-b-mode) (c . t) (erc-d-mode . t) (erc-e-mode . t)))
+          (new '(erc-b-mode erc-d-mode)))
+      (should (equal (erc--merge-local-modes new old)
+                     '((erc-d-mode erc-e-mode) . (erc-b-mode)))))))
 
 ;;; erc-tests.el ends here
-- 
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 030c91581ead371794e0e3a3e60a007cc308e95d 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/7] 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: 15810 bytes --]

From ca345724a16b79eb6627c0c590711da38e2510ba 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/7] 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--id-ensure-reload): Add new function to schedule a
reload if not already connected.
(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.
(erc-set-current-nick): Call `erc-networks--id-ensure-reload' but
leave comment warning that it may be unneeded.

* lisp/erc/erc-backend.el (erc-server-NICK, erc-server-433): Unless
already connected, schedule ID reload when server rejects or mandates
a nick change.

* test/lisp/erc/erc-scenarios-base-association-nick.el
(erc-scenarios-base-association-nick-bumped,
erc-scenarios-base-association-nick-bumped-mandated-renick): Update to
reflect more liberal association behavior when renamed by IRCd.
---
 lisp/erc/erc-backend.el                       |  5 +-
 lisp/erc/erc-networks.el                      | 53 +++++++-----
 lisp/erc/erc.el                               | 20 +++--
 .../erc-scenarios-base-association-nick.el    | 84 +++++++++++--------
 4 files changed, 95 insertions(+), 67 deletions(-)

diff --git a/lisp/erc/erc-backend.el b/lisp/erc/erc-backend.el
index 15fd6ac50f..27927d453d 100644
--- a/lisp/erc/erc-backend.el
+++ b/lisp/erc/erc-backend.el
@@ -161,6 +161,7 @@ erc-whowas-on-nosuchnick
 (declare-function erc-login "erc" nil)
 (declare-function erc-make-notice "erc" (message))
 (declare-function erc-network "erc-networks" nil)
+(declare-function erc-networks--id-ensure-reload "erc-networks" (nid))
 (declare-function erc-networks--id-given "erc-networks" (arg &rest args))
 (declare-function erc-networks--id-reload "erc-networks" (arg &rest args))
 (declare-function erc-nickname-in-use "erc" (nick reason))
@@ -1619,7 +1620,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)
@@ -1629,6 +1630,7 @@ define-erc-response-handler
          'NICK-you ?n nick ?N nn)
         (run-hook-with-args 'erc-nick-changed-functions nn nick))
        (t
+        (erc-networks--id-ensure-reload erc-networks--id)
         (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))))))
@@ -2255,6 +2257,7 @@ erc-server-322-message
 
 (define-erc-response-handler (433)
   "Login-time \"nick in use\"." nil
+  (erc-networks--id-ensure-reload erc-networks--id)
   (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 b3e5fcf1a3..fed9b3591b 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))
@@ -993,6 +985,20 @@ erc-networks--id-reload
                   ((not (equal (buffer-name) new-name))))
         (rename-buffer new-name 'unique))))
 
+(cl-defgeneric erc-networks--id-ensure-reload (_nid)
+  "Schedule or run a reload for IDs derived from nicks."
+  nil)
+
+(cl-defmethod erc-networks--id-ensure-reload
+  ((nid erc-networks--id-qualifying))
+  (if erc-server-connected
+      (erc-networks--id-reload nid)
+    (letrec ((fn (lambda (&rest _)
+                   (when (eq nid erc-networks--id)
+                     (erc-networks--id-reload nid))
+                   (remove-hook 'erc-after-connect fn t))))
+      (add-hook 'erc-after-connect fn nil t))))
+
 (cl-defgeneric erc-networks--id-ensure-comparable (self other)
   "Take measures to ensure two net identities are in comparable states.")
 
@@ -1382,7 +1388,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)
@@ -1401,8 +1408,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 2312246e3e..4d1378f024 100644
--- a/lisp/erc/erc.el
+++ b/lisp/erc/erc.el
@@ -2017,10 +2017,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
@@ -3179,7 +3181,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
@@ -5904,7 +5907,12 @@ erc-set-current-nick
   (with-current-buffer (if (buffer-live-p (erc-server-buffer))
                            (erc-server-buffer)
                          (current-buffer))
-    (setq erc-server-current-nick nick)))
+    (unless (equal erc-server-current-nick nick)
+      (setq erc-server-current-nick nick)
+      ;; This seems sensible but may well be superfluous.  Should
+      ;; really prove that it's actually needed via test scenario.
+      (erc-networks--id-ensure-reload erc-networks--id))
+    nick))
 
 (defun erc-current-nick ()
   "Return the current nickname."
diff --git a/test/lisp/erc/erc-scenarios-base-association-nick.el b/test/lisp/erc/erc-scenarios-base-association-nick.el
index 3e848be4df..b46c996bc0 100644
--- a/test/lisp/erc/erc-scenarios-base-association-nick.el
+++ b/test/lisp/erc/erc-scenarios-base-association-nick.el
@@ -25,13 +25,24 @@
 
 (eval-when-compile (require 'erc-join))
 
-;; You register a new nick, disconnect, and log back in, but your nick
-;; is not granted, so ERC obtains a backtick'd version.  You open a
-;; query buffer for NickServ, and ERC names it using the net-ID (which
-;; includes the backtick'd nick) as a suffix.  The original
-;; (disconnected) NickServ buffer gets renamed with *its* net-ID as
-;; well.  You then identify to NickServ, and the dead session is no
-;; longer considered distinct.
+;; You register a new nick in a dedicated query buffer, disconnect,
+;; and log back in, but your nick is not granted (maybe you just
+;; turned off SASL).  In any case, ERC obtains a backtick'd version.
+;; You open a query buffer for NickServ, and ERC gives you the
+;; existing one.  And after you identify, all buffers retain their
+;; names, although your net ID has changed internally.
+;;
+;; If ERC would've instead failed (or intentionally refused) to make
+;; the association, you would've ended up with a new NickServ buffer
+;; named after the new net ID as a suffix (based on the backtick'd
+;; nick), for example, NickServ@foonet/tester`.  And the original
+;; (disconnected) NickServ buffer would've gotten suffixed with *its*
+;; net-ID as well, e.g., NickServ@foonet/tester.  And after
+;; identifying, you would've seen ERC merge the two as well as their
+;; server buffers.  While this alternate behavior may arguably be a
+;; more honest reflection of reality, it's also quite inconvenient.
+;; For a clearer example, see the original version of this file
+;; introduced by "Add user-oriented test scenarios for ERC".
 
 (ert-deftest erc-scenarios-base-association-nick-bumped ()
   :tags '(:expensive-test)
@@ -67,30 +78,29 @@ erc-scenarios-base-association-nick-bumped
           (funcall expect 5 "ERC finished"))))
 
     (with-current-buffer "foonet"
-      (erc-cmd-RECONNECT))
+      (erc-cmd-RECONNECT)
+      (funcall expect 10 "User modes for tester`"))
 
-    (erc-d-t-wait-for 10 "Nick request rejection prevents reassociation (good)"
-      (get-buffer "foonet/tester`"))
+    (ert-info ("Server buffer reassociated with new nick")
+      (should-not (get-buffer "foonet/tester`")))
 
     (ert-info ("Ask NickServ to change nick")
-      (with-current-buffer "foonet/tester`"
-        (funcall expect 3 "already in use")
+      (with-current-buffer "foonet"
         (funcall expect 3 "debug mode")
         (erc-cmd-QUERY "NickServ"))
 
-      (erc-d-t-wait-for 1 "Dead NickServ query buffer renamed, now qualified"
-        (get-buffer "NickServ@foonet/tester"))
+      (ert-info ( "NickServ buffer reassociated")
+        (should-not (get-buffer "NickServ@foonet/tester`"))
+        (should-not (get-buffer "NickServ@foonet/tester")))
 
-      (with-current-buffer "NickServ@foonet/tester`" ; new one
+      (with-current-buffer "NickServ" ; new one
         (erc-scenarios-common-say "IDENTIFY tester changeme")
-        (funcall expect 5 "You're now logged in as tester")
-        (ert-info ("Original buffer found, reused")
-          (erc-d-t-wait-for 2 (equal (buffer-name) "NickServ")))))
+        (funcall expect 5 "You're now logged in as tester")))
 
-    (ert-info ("Ours is the only NickServ buffer that remains")
+    (ert-info ("Still just one NickServ buffer")
       (should-not (cdr (erc-scenarios-common-buflist "NickServ"))))
 
-    (ert-info ("Visible network ID truncated to one component")
+    (ert-info ("As well as one server buffer")
       (should (not (get-buffer "foonet/tester`")))
       (should (not (get-buffer "foonet/tester")))
       (should (get-buffer "foonet")))))
@@ -135,29 +145,29 @@ erc-scenarios-base-association-nick-bumped-mandated-renick
     ;; Since we use reconnect, a new buffer won't be created
     ;; TODO add variant with clean `erc' invocation
     (with-current-buffer "foonet"
-      (erc-cmd-RECONNECT))
+      (erc-cmd-RECONNECT)
+      (funcall expect 10 "User modes for dummy"))
 
-    (ert-info ("Server-initiated renick")
-      (with-current-buffer (erc-d-t-wait-for 10 (get-buffer "foonet/dummy"))
-        (should-not (get-buffer "foonet/tester"))
-        (funcall expect 15 "debug mode"))
+    (ert-info ("Server-initiated renick associated correctly")
+      (with-current-buffer "foonet"
+        (funcall expect 15 "debug mode")
+        (should-not (get-buffer "foonet/dummy"))
+        (should-not (get-buffer "foonet/tester")))
 
-      (erc-d-t-wait-for 1 "Old query renamed, now qualified"
-        (get-buffer "bob@foonet/tester"))
+      (ert-info ("Old query reassociated")
+        (should (get-buffer "bob"))
+        (should-not (get-buffer "bob@foonet/tester"))
+        (should-not (get-buffer "bob@foonet/dummy")))
 
-      (with-current-buffer (erc-d-t-wait-for 5 (get-buffer "bob@foonet/dummy"))
+      (with-current-buffer "foonet"
         (erc-cmd-NICK "tester")
-        (ert-info ("Buffers combined")
-          (erc-d-t-wait-for 2 (equal (buffer-name) "bob")))))
+        (funcall expect 5 "You're now logged in as tester")))
 
-    (with-current-buffer "foonet"
-      (funcall expect 5 "You're now logged in as tester"))
-
-    (ert-info ("Ours is the only bob buffer that remains")
+    (ert-info ("Ours is still the only bob buffer that remains")
       (should-not (cdr (erc-scenarios-common-buflist "bob"))))
 
-    (ert-info ("Visible network ID truncated to one component")
-      (should (not (get-buffer "foonet/dummy")))
-      (should (get-buffer "foonet")))))
+    (ert-info ("Visible network ID still truncated to one component")
+      (should (not (get-buffer "foonet/tester")))
+      (should (not (get-buffer "foonet/dummy"))))))
 
 ;;; erc-scenarios-base-association-nick.el ends here
-- 
2.38.1


[-- Warning: decoded text below may be mangled, UTF-8 assumed --]
[-- Attachment #5: 0003-Make-erc-server-reconnecting-non-buffer-local.patch --]
[-- Type: text/x-patch, Size: 4014 bytes --]

From c2f1bb1dbf1c177407338ebe0137c6f912c28dc3 Mon Sep 17 00:00:00 2001
From: "F. Jason Park" <jp@neverwas.me>
Date: Fri, 18 Nov 2022 22:42:15 -0800
Subject: [PATCH 3/7] Make erc--server-reconnecting non-buffer-local

* lisp/erc/erc-backend.el (erc--server-reconnecting): Mention expected
non-nil value type in doc string.
(erc-server-connect): Don't set `erc--server-reconnecting'.
(erc-server--reconnect): Let-bind `erc--server-reconnecting' instead
of setting it locally in the server buffer.  Set it to an alist
containing the current buffer's local variables.
(erc-process-sentinel-2): Don't set `erc--server-reconnect'.
* lisp/erc/erc.el (erc--cmd-reconnect): Clean up some assertions.
(Bug#57955.)
---
 lisp/erc/erc-backend.el | 17 ++++++++++-------
 lisp/erc/erc.el         |  6 ++----
 2 files changed, 12 insertions(+), 11 deletions(-)

diff --git a/lisp/erc/erc-backend.el b/lisp/erc/erc-backend.el
index 27927d453d..094a03c430 100644
--- a/lisp/erc/erc-backend.el
+++ b/lisp/erc/erc-backend.el
@@ -312,8 +312,13 @@ erc-server-reconnecting
 (make-obsolete-variable 'erc-server-reconnecting
                         "see `erc--server-reconnecting'" "29.1")
 
-(defvar-local erc--server-reconnecting nil
-  "Non-nil when reconnecting.")
+(defvar erc--server-reconnecting nil
+  "An alist of buffer-local vars and their values when reconnecting.
+This is for the benefit of local modules and `erc-mode-hook'
+members so they can access buffer-local data from the previous
+session when reconnecting.  Once `erc-reuse-buffers' is retired
+and fully removed, modules can switch to leveraging the
+`permanent-local' property instead.")
 
 (defvar-local erc-server-timed-out nil
   "Non-nil if the IRC server failed to respond to a ping.")
@@ -665,7 +670,6 @@ erc-server-connect
       (setq erc-server-process process)
       (setq erc-server-quitting nil)
       (setq erc-server-reconnecting nil
-            erc--server-reconnecting nil
             erc--server-reconnect-timer nil)
       (setq erc-server-timed-out nil)
       (setq erc-server-banned nil)
@@ -707,11 +711,11 @@ erc-server-reconnect
     (with-current-buffer buffer
       (erc-update-mode-line)
       (erc-set-active-buffer (current-buffer))
-      (setq erc--server-reconnecting t)
       (setq erc-server-last-sent-time 0)
       (setq erc-server-lines-sent 0)
       (let ((erc-server-connect-function (or erc-session-connector
-                                             #'erc-open-network-stream)))
+                                             #'erc-open-network-stream))
+            (erc--server-reconnecting (buffer-local-variables)))
         (erc-open erc-session-server erc-session-port erc-server-current-nick
                   erc-session-user-full-name t erc-session-password
                   nil nil nil erc-session-client-certificate
@@ -825,8 +829,7 @@ erc-process-sentinel-2
         (if (not reconnect-p)
             ;; terminate, do not reconnect
             (progn
-              (setq erc--server-reconnecting nil
-                    erc--server-reconnect-timer nil)
+              (setq erc--server-reconnect-timer nil)
               (erc-display-message nil 'error (current-buffer)
                                    'terminated ?e event)
               (set-buffer-modified-p nil))
diff --git a/lisp/erc/erc.el b/lisp/erc/erc.el
index 4d1378f024..51f8d86487 100644
--- a/lisp/erc/erc.el
+++ b/lisp/erc/erc.el
@@ -3834,10 +3834,8 @@ erc--cmd-reconnect
       (with-suppressed-warnings ((obsolete erc-server-reconnecting)
                                  (obsolete erc-reuse-buffers))
         (if erc-reuse-buffers
-            (progn (cl-assert (not erc--server-reconnecting))
-                   (cl-assert (not erc-server-reconnecting)))
-          (setq erc--server-reconnecting nil
-                erc-server-reconnecting nil)))))
+            (cl-assert (not erc-server-reconnecting))
+          (setq erc-server-reconnecting nil)))))
   t)
 
 (defun erc-cmd-RECONNECT (&rest args)
-- 
2.38.1


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

From 16a462f28a0522207e5743af14183c0d6d9e6662 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 4/7] Support local ERC modules in erc-mode buffers

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

* etc/ERC-NEWS: Mention changes to `erc-update-modules'.

* lisp/erc/erc.el (erc-migrate-modules): Add some missing mappings.
(erc-update-modules): Move body of `erc-update-modules' to new
internal function.
(erc--update-modules): Add new function, a renamed and slightly
modified version of `erc-update-modules'.  Specifically, change return
value from nil to a list of minor-mode commands for local modules.
Use `custom-variable-p' to detect flavor.
(erc--merge-local-modes): Add helper for finding local modules
already active as minor modes in an ERC buffer.
(erc-open): Replace `erc-update-modules' with `erc--update-modules'.
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'.

* 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 toggle local modules (minor modes) unless
`erc-mode' is the major mode.  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 asserting correct
module-name mappings.
(erc--merge-local-modes): Add test for helper. (Bug#57955.)
---
 doc/misc/erc.texi          | 11 ++++-
 etc/ERC-NEWS               |  6 +++
 lisp/erc/erc-common.el     | 56 +++++++++++++++++++++---
 lisp/erc/erc-goodies.el    |  1 +
 lisp/erc/erc.el            | 89 +++++++++++++++++++++++---------------
 test/lisp/erc/erc-tests.el | 61 ++++++++++++++++++++++++++
 6 files changed, 181 insertions(+), 43 deletions(-)

diff --git a/doc/misc/erc.texi b/doc/misc/erc.texi
index 0d807e323e..dd15036b2e 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.
 
diff --git a/etc/ERC-NEWS b/etc/ERC-NEWS
index f638d4717a..832a9566d7 100644
--- a/etc/ERC-NEWS
+++ b/etc/ERC-NEWS
@@ -125,6 +125,12 @@ The function 'erc-auto-query' was deemed too difficult to reason
 through and has thus been deprecated with no public replacement; it
 has also been removed from the client code path.
 
+The function 'erc-open' now delays running 'erc-mode-hook' members
+until most local session variables have been initialized (minus those
+connection-related ones in erc-backend).  'erc-open' also no longer
+calls 'erc-update-modules', although modules are still activated
+in an identical fashion.
+
 A few internal variables have been introduced that could just as well
 have been made public, possibly as user options.  Likewise for some
 internal functions.  As always, users needing such functionality
diff --git a/lisp/erc/erc-common.el b/lisp/erc/erc-common.el
index 23a1933798..b791866ee2 100644
--- a/lisp/erc/erc-common.el
+++ b/lisp/erc/erc-common.el
@@ -88,6 +88,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.
@@ -101,7 +136,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:
 
@@ -114,6 +151,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))))
@@ -137,16 +175,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-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 51f8d86487..e58bc4f887 100644
--- a/lisp/erc/erc.el
+++ b/lisp/erc/erc.el
@@ -1791,10 +1791,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
@@ -1873,27 +1870,23 @@ erc-modules
   :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))))))
+  "Enable minor mode for every module in `erc-modules'.
+Except ignore all local modules, which were introduced in ERC 5.5."
+  (erc--update-modules)
+  nil)
+
+(defun erc--update-modules ()
+  (let (local-modes)
+    (dolist (module erc-modules 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 (custom-variable-p mode)
+            (funcall mode 1)
+          (push mode local-modes))))))
 
 (defun erc-setup-buffer (buffer)
   "Consults `erc-join-buffer' to find out how to display `BUFFER'."
@@ -1924,6 +1917,24 @@ erc-setup-buffer
          (display-buffer buffer)
        (switch-to-buffer buffer)))))
 
+(defun erc--merge-local-modes (new-modes old-vars)
+  "Return a cons of two lists, each containing local-module modes.
+In the first, put modes to be enabled in a new ERC buffer by
+calling their associated functions.  In the second, put modes to
+be marked as disabled by setting their associated variables to
+nil."
+  (if old-vars
+      (let ((out (list (reverse new-modes))))
+        (pcase-dolist (`(,k . ,v) old-vars)
+          (when (and (string-prefix-p "erc-" (symbol-name k))
+                     (string-suffix-p "-mode" (symbol-name k)))
+            (if v
+                (cl-pushnew k (car out))
+              (setf (car out) (delq k (car out)))
+              (cl-pushnew k (cdr out)))))
+        (cons (nreverse (car out)) (nreverse (cdr out))))
+    (list new-modes)))
+
 (defun erc-open (&optional server port nick full-name
                            connect passwd tgt-list channel process
                            client-certificate user id)
@@ -1951,18 +1962,23 @@ 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))))
     (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--merge-local-modes (erc--update-modules)
+                                                  erc--server-reconnecting))
+
+    (delay-mode-hooks (erc-mode))
+
+    (setq erc-server-reconnect-count old-recon-count)
+
     (when (setq erc-server-connected (not connect))
       (setq erc-server-announced-name
             (buffer-local-value 'erc-server-announced-name old-buffer)))
@@ -2019,14 +2035,21 @@ 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 (and erc--server-reconnecting
+                       (alist-get 'erc-networks--id 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 (car delayed-modules)) (funcall mod +1))
+    (dolist (var (cdr delayed-modules)) (set var nil))
+
     ;; set up prompt
     (unless continued-session
       (goto-char (point-max))
@@ -2038,8 +2061,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 ff5d802697..199b97858e 100644
--- a/test/lisp/erc/erc-tests.el
+++ b/test/lisp/erc/erc-tests.el
@@ -1178,4 +1178,65 @@ erc-handle-irc-url
     (kill-buffer "baznet")
     (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) '(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)) ; no locals
+        (should (equal (nreverse calls) '( erc-pcomplete (completion . 1)
+                                           erc-join (autojoin . 1)
+                                           erc-networks (networks . 1))))
+        (setq calls nil)))))
+
+(ert-deftest erc--merge-local-modes ()
+
+  (ert-info ("No existing modes")
+    (let ((old '((a) (b . t)))
+          (new '(erc-c-mode erc-d-mode)))
+      (should (equal (erc--merge-local-modes new old)
+                     '((erc-c-mode erc-d-mode))))))
+
+  (ert-info ("Active existing added, inactive existing removed, deduped")
+    (let ((old '((a) (erc-b-mode) (c . t) (erc-d-mode . t) (erc-e-mode . t)))
+          (new '(erc-b-mode erc-d-mode)))
+      (should (equal (erc--merge-local-modes new old)
+                     '((erc-d-mode erc-e-mode) . (erc-b-mode)))))))
+
 ;;; erc-tests.el ends here
-- 
2.38.1


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

From f6fbd63e83287722988d87413d0e5b82c8d89c08 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 5/7] Call erc-login indirectly via new generic wrapper

* lisp/erc/erc-backend (erc--register-connection): Add new internal
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 094a03c430..8540e9c9d0 100644
--- a/lisp/erc/erc-backend.el
+++ b/lisp/erc/erc-backend.el
@@ -644,6 +644,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))
+
 (defvar erc--server-connect-dumb-ipv6-regexp
   ;; Not for validation (gives false positives).
   (rx bot "[" (group (+ (any xdigit digit ":.")) (? "%" (+ alnum))) "]" eot))
@@ -698,7 +702,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.
@@ -898,7 +902,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 #8: 0006-Add-non-IRCv3-SASL-module-to-ERC.patch --]
[-- Type: text/x-patch, Size: 74366 bytes --]

From 60982fee648852f5f5a746567e1fc89a7761368a 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 6/7] 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                             | 151 +++++-
 etc/ERC-NEWS                                  |   7 +-
 lisp/erc/erc-compat.el                        | 104 +++++
 lisp/erc/erc-sasl.el                          | 429 ++++++++++++++++++
 lisp/erc/erc.el                               |   1 +
 test/lisp/erc/erc-sasl-tests.el               | 344 ++++++++++++++
 test/lisp/erc/erc-scenarios-sasl.el           | 202 +++++++++
 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 ++
 12 files changed, 1415 insertions(+), 5 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 dd15036b2e..834f1cbf2c 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.
 * Integrations::                Integrations available for ERC.
 * Options::                     Options that are available for ERC.
@@ -486,6 +487,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
@@ -533,6 +538,7 @@ Advanced Usage
 
 @menu
 * Connecting::                  Ways of connecting to an IRC server.
+* SASL::                        Authenticating via SASL
 * Sample Configuration::        An example configuration file.
 * Integrations::                Integrations available for ERC.
 * Options::                     Options that are available for ERC.
@@ -605,6 +611,7 @@ Connecting
 parameters, and some, like @code{client-certificate}, will just be
 @code{nil}.
 
+@anchor{ERC client-certificate}
 To use a certificate with @code{erc-tls}, specify the optional
 @var{client-certificate} keyword argument, whose value should be as
 described in the documentation of @code{open-network-stream}: if
@@ -739,7 +746,10 @@ Connecting
 You can manually set another nickname with the /NICK command.
 @end defopt
 
+@anchor{ERC username}
 @subheading User
+@cindex user
+
 @defun erc-compute-user &optional user
 Determine a suitable value to send as the first argument of the
 opening @samp{USER} IRC command by consulting the following sources:
@@ -851,6 +861,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
@@ -863,7 +874,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
@@ -925,6 +937,143 @@ Connecting
 make the most sense, but any reasonably printable object is
 acceptable.
 
+@node SASL
+@section Authenticating via SASL
+@cindex SASL
+
+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 probably won't be needing SASL for the client-to-bouncer
+connection.  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.  To make this work, customize
+@code{erc-sasl-user} and @code{erc-sasl-password} or specify the
+@code{:user} and @code{:password} keyword arguments when invoking
+@code{erc-tls}.  Note that @code{:user} cannot be given interactively.
+@end indentedblock
+
+@var{external} (via Client TLS Certificate):
+
+@indentedblock
+This works in conjunction with the @code{:client-certificate} keyword
+offered by @code{erc-tls}.  Just ensure you've registered your
+fingerprint with the network beforehand.  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}).  These days, there's
+usually a @samp{CERT ADD} command offered by NickServ that can
+register you automatically if you issue it while connected with a
+client cert.  (@pxref{ERC client-certificate}).
+
+Additional considerations:
+@enumerate
+@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 a remote chance your
+server has something else in mind.
+@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 username, typically the same one
+registered with nickname services.  Specify this when your NickServ
+login differs from the @code{:user} you're connecting with.
+(@pxref{ERC username})
+@end defopt
+
+@defopt erc-sasl-password
+As noted elsewhere, the @code{:password} parameter for @code{erc-tls}
+was orignally intended for traditional ``server passwords,'' but these
+aren't really used any more.  As such, this option defaults to
+borrowing that parameter for its own uses, thus allowing you to call
+@code{erc-tls} with @code{:password} set to your NickServ password.
+
+You can also set this to a nonemtpy string, and ERC will send that
+when needed, no questions asked.  If you instead give a non-@code{nil}
+symbol (other than @code{:password}), 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 resolved 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, and it's
+@code{nil} by default.  As a last resort, ERC will prompt you for
+input.
+
+Lastly, 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
+
+@subheading Troubleshooting
+
+@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're struggling, remember that your SASL password is almost
+always your NickServ password.  When in doubt, try restoring all SASL
+options to their defaults and calling @code{erc-tls} with @code{:user}
+set to your NickServ account name and @code{:password} to your
+NickServ password.  If you're still having trouble, please contact us
+(@pxref{Getting Help and Reporting Bugs}).
+
+As you try out different settings, keep in mind that it's best to
+create a fresh session for every change, for example, by calling
+@code{erc-tls} from scratch.  More experienced users may be able to
+get away with cycling @code{erc-sasl-mode} and issuing a
+@samp{/reconnect}, but that's generally not recommended.  Whatever the
+case, you'll probably want to temporarily disable
+@code{erc-server-auto-reconnect} while experimenting.
 
 @node Sample Configuration
 @section Sample Configuration
diff --git a/etc/ERC-NEWS b/etc/ERC-NEWS
index 832a9566d7..3e1b7bca95 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 d23703394b..8b95f8ac81 100644
--- a/lisp/erc/erc-compat.el
+++ b/lisp/erc/erc-compat.el
@@ -273,6 +273,110 @@ erc-compat--auth-source-backend-parser-functions
     auth-source-backend-parser-functions))
 
 
+;;;; 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..a48e3f6a7f
--- /dev/null
+++ b/lisp/erc/erc-sasl.el
@@ -0,0 +1,429 @@
+;;; 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.
+;;
+;; - 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 :user
+  "Account username to send when authenticating.
+This is also referred to as the authentication identity or
+\"authcid\".  A value of `:user' or `:nick' indicates that the
+corresponding connection parameter on file should be used.  These
+are most often derived from arguments provided to the `erc' and
+`erc-tls' entry points.  In the case of `:nick', a downcased
+version is used."
+  :type '(choice string (const :user) (const :nick)))
+
+(defcustom erc-sasl-password :password
+  "Optional account password to send when authenticating.
+When the value is a string, ERC will use it unconditionally for
+most mechanisms.  Likewise with `:password', except ERC will
+instead use the \"session password\" on file, which often
+originates from the entry-point commands `erc' or `erc-tls'.
+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 prompt for input.
+
+Note that, with `:password', ERC will forgo sending a traditional
+server password via the IRC \"PASS\" command.  Also, when
+`erc-sasl-mechanism' is set to `ecdsa-nist256p-challenge', this
+option should hold the file name of the key."
+  :type '(choice (const nil) (const :password) 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
+includes `erc-sasl-user' for the `:user' field and
+`erc-sasl-password' for the `:host' field, when the latter option
+is a non-nil, non-keyword symbol.  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)
+
+;; 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--get-user ()
+  (pcase (alist-get 'user erc-sasl--options)
+    (:user erc-session-username)
+    (:nick (erc-downcase (erc-current-nick)))
+    (v v)))
+
+(defun erc-sasl--read-password (prompt)
+  "Return configured option or server password.
+PROMPT is passed to `read-passwd' if necessary."
+  (if-let
+      ((found (pcase (alist-get 'password erc-sasl--options)
+                (:password erc-session-password)
+                ((and (pred stringp) v) (unless (string-empty-p v) v))
+                ((and (guard erc-sasl-auth-source-function)
+                      v (let host
+                          (or v (erc-networks--id-given erc-networks--id))))
+                 (apply erc-sasl-auth-source-function
+                        :user (erc-sasl--get-user)
+                        (and host (list :host (symbol-name host))))))))
+      (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 (list name))
+                                     (erc-sasl--get-user)
+                                     "N/A" "N/A"))
+      (sasl-client-set-property client 'authenticator-name
+                                (alist-get 'authzid erc-sasl--options))
+      client)))
+
+(cl-defmethod erc-sasl--create-client ((_ (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 (erc-sasl--get-user))
+         (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 ((_ (eql scram-sha-256)))
+  "Create and return a new SCRAM-SHA-256 client."
+  (when (featurep 'sasl-scram-sha256)
+    (cl-call-next-method)))
+
+(cl-defmethod erc-sasl--create-client ((_ (eql scram-sha-512)))
+  "Create and return a new SCRAM-SHA-512 client."
+  (when (featurep 'sasl-scram-sha256)
+    (cl-call-next-method)))
+
+(cl-defmethod erc-sasl--create-client ((_ (eql ecdsa-nist256p-challenge)))
+  "Create and return a new ECDSA-NIST256P-CHALLENGE client."
+  (let ((keyfile (cdr (assq 'password erc-sasl--options))))
+    ;; Better to signal usage errors now than inside a process filter.
+    (cond ((or (not (stringp keyfile)) (not (file-readable-p keyfile)))
+           (erc-display-error-notice
+            nil "`erc-sasl-password' not accessible as a file")
+           nil)
+          ((not (executable-find "openssl"))
+           (erc-display-error-notice nil "Could not find openssl program")
+           nil)
+          (t
+           (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 ()
+  (setq erc-sasl--state (make-erc-sasl--state))
+  ;; If the previous attempt failed during registration, this may be
+  ;; non-nil and contain erroneous values, but how can we detect that?
+  ;; What if the server dropped the connection for some other reason?
+  (setq erc-sasl--options
+        (or (and erc--server-reconnecting
+                 (alist-get 'erc-sasl--options erc--server-reconnecting))
+            `((user . ,erc-sasl-user)
+              (password . ,erc-sasl-password)
+              (mechanism . ,erc-sasl-mechanism)
+              (authzid . ,erc-sasl-authzid)))))
+
+(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 or unsupported SASL mechanism: %s" mech))
+         (erc-error "Unknown or unsupported SASL mechanism: %s" mech))
+       (setf (erc-sasl--state-client erc-sasl--state) client))))
+  ((remove-hook 'erc-server-AUTHENTICATE-functions
+                #'erc-sasl--authenticate-handler t)
+   (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")))
+  (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 (string-join (cdr (erc-response.command-args 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")
+        (if (and erc-session-password
+                 (eq :password (alist-get 'password erc-sasl--options)))
+            (let (erc-session-password)
+              (erc-login))
+          (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 e58bc4f887..4b83cde7e8 100644
--- a/lisp/erc/erc.el
+++ b/lisp/erc/erc.el
@@ -1853,6 +1853,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..20a6760083
--- /dev/null
+++ b/test/lisp/erc/erc-sasl-tests.el
@@ -0,0 +1,344 @@
+;;; 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--basic ()
+  (ert-info ("Explicit erc-sasl-password")
+    (let ((erc-sasl--options '((password . "foo"))))
+      (should (string= (erc-sasl--read-password nil) "foo"))))
+
+  (ert-info ("Explicit session password")
+    (let ((erc-session-password "foo")
+          (erc-sasl--options '((password . :password))))
+      (should (string= (erc-sasl--read-password nil) "foo"))))
+
+  (ert-info ("Fallback to prompt skip auth-source")
+    (should-not erc-sasl-auth-source-function)
+    (let ((erc-session-password "bar")
+          (erc-networks--id (erc-networks--id-create nil)))
+      (should (string= (ert-simulate-keys "bar\r"
+                         (erc-sasl--read-password "?"))
+                       "bar"))))
+
+  (ert-info ("Prompt when auth-source fails and `erc-sasl-password' null")
+    (let ((erc-sasl--options '((password)))
+          (erc-sasl-auth-source-function #'ignore))
+      (should (string= (ert-simulate-keys "baz\r"
+                         (erc-sasl--read-password "pwd:"))
+                       "baz")))))
+
+(ert-deftest erc-sasl--read-password--auth-source ()
+  (ert-with-temp-file netrc-file
+    :text (string-join
+           (list
+            ;; If you swap these first 2 lines, *1 below fails
+            "machine FSF.chat port 6697 user bob password sesame"
+            "machine GNU/chat port 6697 user bob password spam"
+            "machine MyHost port irc password 123")
+           "\n")
+    (let* ((auth-sources (list netrc-file))
+           (erc-session-server "irc.gnu.org")
+           (erc-session-port 6697)
+           (erc-networks--id (erc-networks--id-create nil))
+           calls
+           (erc-sasl-auth-source-function
+            (lambda (&rest r)
+              (push r calls)
+              (apply #'erc--auth-source-search r)))
+           erc-server-announced-name ; too early
+           auth-source-do-cache)
+
+      (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"))
+          (should (equal (pop calls) '(:user "bob" :host "FSF.chat")))))
+
+      (ert-info ("ID for :host and `erc-session-username' for :user") ; *1
+        (let ((erc-session-username "bob")
+              (erc-sasl--options '((user . :user) (password)))
+              (erc-networks--id (erc-networks--id-create 'GNU/chat)))
+          (should (string= (erc-sasl--read-password nil) "spam"))
+          (should (equal (pop calls) '(:user "bob" :host "GNU/chat")))))
+
+      (ert-info ("ID for :host and current nick for :user") ; *1
+        (let ((erc-server-current-nick "bob")
+              (erc-sasl--options '((user . :nick) (password)))
+              (erc-networks--id (erc-networks--id-create 'GNU/chat)))
+          (should (string= (erc-sasl--read-password nil) "spam"))
+          (should (equal (pop calls) '(:user "bob" :host "GNU/chat")))))
+
+      (ert-info ("Symbol as password, entry lacks user field")
+        (let ((erc-server-current-nick "fake")
+              (erc-sasl--options '((user . :nick) (password . MyHost)))
+              (erc-networks--id (erc-networks--id-create 'GNU/chat)))
+          (should (string= (erc-sasl--read-password nil) "123"))
+          (should (equal (pop calls) '(:user "fake" :host "MyHost"))))))))
+
+(ert-deftest erc-sasl-create-client--plain ()
+  (let* ((erc-session-password "password123")
+         (erc-session-username "tester")
+         (erc-sasl--options '((user . :user) (password . :password)))
+         (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")
+         (erc-sasl--options '((user . :nick) (password . :password)))
+         (client (erc-sasl--create-client 'external)) ; unused ^
+         (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-sasl--options '((user . "jilles") (password . "sesame")
+                              (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 '((user . :nick) (password . :password)
+                              (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")
+         (erc-sasl--options '((user . :nick) (password . :password) (authzid)))
+         (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")
+         (erc-sasl--options '((user . :nick) (password . :password) (authzid)))
+         (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..713c9929c3
--- /dev/null
+++ b/test/lisp/erc/erc-scenarios-sasl.el
@@ -0,0 +1,202 @@
+;;; 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-password "password123")
+       (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))
+       (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 (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)
+       (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)
+       (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-user :nick)
+       (erc-sasl-mechanism mech)
+       (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"
+                                :password "sesame"
+                                :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/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


[-- Warning: decoded text below may be mangled, UTF-8 assumed --]
[-- Attachment #9: 0007-Accept-functions-in-place-of-passwords-in-ERC.patch --]
[-- Type: text/x-patch, Size: 11519 bytes --]

From 4cffbaec3a551faf441067b3b017438f4fddd3cc 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 7/7] Accept functions in place of passwords in ERC

* lisp/erc/erc-backend.el (erc-session-password): Add comment
explaining type is now string, nil, or function.
* lisp/erc/erc-sasl.el (erc-sasl--read-password): Use `erc--unfun'.
* lisp/erc/erc-services.el (erc-nickserv-get-password,
erc-nickserv-send-identify): Use `erc--unfun'.
* lisp/erc/erc.el (erc--unfun): New function for unwrapping a
password couched in a getter.
(erc--debug-irc-protocol-mask-secrets): Add variable to indicate
whether to mask passwords in debug logs.
(erc--mask-secrets): New function to swap masked secret with question
marks in debug logs.
(erc-log-irc-protocol): Conditionally mask secrets when
`erc--debug-irc-protocol-mask-secrets' is non-nil.
(erc--auth-source-search): Don't unwrap secret from function before
returning.
(erc-server-join-channel, erc-login): Use `erc--unfun'.

* test/lisp/erc/erc-services-tests.el
(erc-services-tests--wrap-search): Add helper for `erc--unfun'.
(erc-services-tests--auth-source-standard,
erc-services-tests--auth-source-announced,
erc-services-tests--auth-source-overrides, erc-nickserv-get-password):
Use `erc--unfun'.
* test/lisp/erc/erc-tests.el (erc--debug-irc-protocol-mask-secrets):
Add test for masking secrets with `erc--unfun' and friends.
---
 lisp/erc/erc-backend.el             |  3 ++-
 lisp/erc/erc-sasl.el                |  2 +-
 lisp/erc/erc-services.el            |  5 ++--
 lisp/erc/erc.el                     | 38 +++++++++++++++++++++++++----
 test/lisp/erc/erc-services-tests.el | 16 +++++++++---
 test/lisp/erc/erc-tests.el          | 22 +++++++++++++++++
 6 files changed, 73 insertions(+), 13 deletions(-)

diff --git a/lisp/erc/erc-backend.el b/lisp/erc/erc-backend.el
index 8540e9c9d0..3cd949ddd3 100644
--- a/lisp/erc/erc-backend.el
+++ b/lisp/erc/erc-backend.el
@@ -206,7 +206,8 @@ erc-whowas-on-nosuchnick
 ;;;; Variables and options
 
 (defvar-local erc-session-password nil
-  "The password used for the current session.")
+  "The password used for the current session.
+This should be a string or a function returning a string.")
 
 (defvar erc-server-responses (make-hash-table :test #'equal)
   "Hash table mapping server responses to their handler hooks.")
diff --git a/lisp/erc/erc-sasl.el b/lisp/erc/erc-sasl.el
index a48e3f6a7f..16fe93f50d 100644
--- a/lisp/erc/erc-sasl.el
+++ b/lisp/erc/erc-sasl.el
@@ -143,7 +143,7 @@ erc-sasl--read-password
                  (apply erc-sasl-auth-source-function
                         :user (erc-sasl--get-user)
                         (and host (list :host (symbol-name host))))))))
-      (copy-sequence found)
+      (copy-sequence (erc--unfun found))
     (read-passwd prompt)))
 
 (defun erc-sasl--plain-response (client steps)
diff --git a/lisp/erc/erc-services.el b/lisp/erc/erc-services.el
index fe9cb5b5f1..48953288d1 100644
--- a/lisp/erc/erc-services.el
+++ b/lisp/erc/erc-services.el
@@ -455,7 +455,7 @@ erc-nickserv-get-password
                   (read-passwd
                    (format "NickServ password for %s on %s (RET to cancel): "
                            nick nid)))))
-       ((not (string-empty-p ret))))
+       ((not (string-empty-p (erc--unfun ret)))))
     ret))
 
 (defvar erc-auto-discard-away)
@@ -477,7 +477,8 @@ erc-nickserv-send-identify
          (msgtype (or (erc-nickserv-alist-ident-command nil nickserv-info)
                       "PRIVMSG")))
     (erc-message msgtype
-                 (concat nickserv " " identify-word " " nick password))))
+                 (concat nickserv " " identify-word " " nick
+                         (erc--unfun password)))))
 
 (defun erc-nickserv-call-identify-function (nickname)
   "Call `erc-nickserv-identify' with NICKNAME."
diff --git a/lisp/erc/erc.el b/lisp/erc/erc.el
index 4b83cde7e8..8daf8397d1 100644
--- a/lisp/erc/erc.el
+++ b/lisp/erc/erc.el
@@ -2326,6 +2326,23 @@ erc-debug-irc-protocol
 WARNING: Do not set this variable directly!  Instead, use the
 function `erc-toggle-debug-irc-protocol' to toggle its value.")
 
+(defvar erc--debug-irc-protocol-mask-secrets t
+  "Whether to hide secrets in a debug log.
+They are still visible on screen but are replaced by question
+marks when yanked.")
+
+(defun erc--mask-secrets (string)
+  (when-let* ((eot (length string))
+              (beg (text-property-any 0 eot 'erc-secret t string))
+              (end (text-property-not-all beg eot 'erc-secret t string))
+              (sec (substring string beg end)))
+    (setq string (concat (substring string 0 beg)
+                         (make-string (- end beg) ??)
+                         (substring string end eot)))
+    (put-text-property beg end 'face 'erc-inverse-face string)
+    (put-text-property beg end 'display sec string))
+  string)
+
 (defun erc-log-irc-protocol (string &optional outbound)
   "Append STRING to the buffer *erc-protocol*.
 
@@ -2351,6 +2368,8 @@ erc-log-irc-protocol
                       (format "%s:%s" erc-session-server erc-session-port))))
           (ts (when erc-debug-irc-protocol-time-format
                 (format-time-string erc-debug-irc-protocol-time-format))))
+      (when erc--debug-irc-protocol-mask-secrets
+        (setq string (erc--mask-secrets string)))
       (with-current-buffer (get-buffer-create "*erc-protocol*")
         (save-excursion
           (goto-char (point-max))
@@ -3276,9 +3295,8 @@ erc--auth-source-search
       (setq plist (plist-put plist :max 5000))) ; `auth-source-netrc-parse'
     (unless (plist-get defaults :require)
       (setq plist (plist-put plist :require '(:secret))))
-    (when-let* ((sorted (sort (apply #'auth-source-search plist) test))
-                (secret (plist-get (car sorted) :secret)))
-      (if (functionp secret) (funcall secret) secret))))
+    (when-let* ((sorted (sort (apply #'auth-source-search plist) test)))
+      (plist-get (car sorted) :secret))))
 
 (defun erc-auth-source-search (&rest plist)
   "Call `auth-source-search', possibly with keyword params in PLIST."
@@ -3299,7 +3317,8 @@ erc-server-join-channel
     (setq secret (apply erc-auth-source-join-function
                         `(,@(and server (list :host server)) :user ,channel))))
   (erc-log (format "cmd: JOIN: %s" channel))
-  (erc-server-send (concat "JOIN " channel (and secret (concat " " secret)))))
+  (erc-server-send (concat "JOIN " channel
+                           (and secret (concat " " (erc--unfun secret))))))
 
 (defun erc--valid-local-channel-p (channel)
   "Non-nil when channel is server-local on a network that allows them."
@@ -6334,6 +6353,15 @@ erc-load-irc-script-lines
 
 ;; authentication
 
+(defun erc--unfun (maybe-fn)
+  "Return MAYBE-FN or whatever it returns."
+  (let ((s (if (functionp maybe-fn) (funcall maybe-fn) maybe-fn)))
+    (when (and erc-debug-irc-protocol
+               erc--debug-irc-protocol-mask-secrets
+               (stringp s))
+      (put-text-property 0 (length s) 'erc-secret t s))
+    s))
+
 (defun erc-login ()
   "Perform user authentication at the IRC server."
   (erc-log (format "login: nick: %s, user: %s %s %s :%s"
@@ -6343,7 +6371,7 @@ erc-login
                    erc-session-server
                    erc-session-user-full-name))
   (if erc-session-password
-      (erc-server-send (concat "PASS :" erc-session-password))
+      (erc-server-send (concat "PASS :" (erc--unfun erc-session-password)))
     (message "Logging in without password"))
   (erc-server-send (format "NICK %s" (erc-current-nick)))
   (erc-server-send
diff --git a/test/lisp/erc/erc-services-tests.el b/test/lisp/erc/erc-services-tests.el
index 7ff2e36e77..2547c5e01a 100644
--- a/test/lisp/erc/erc-services-tests.el
+++ b/test/lisp/erc/erc-services-tests.el
@@ -62,9 +62,13 @@ erc--auth-source-determine-params-merge
                            :x ("x")
                            :require (:secret))))))
 
+(defun erc-services-tests--wrap-search (s)
+  (lambda (&rest r) (erc--unfun (apply s r))))
+
 ;; Some of the following may be related to bug#23438.
 
 (defun erc-services-tests--auth-source-standard (search)
+  (setq search (erc-services-tests--wrap-search search))
 
   (ert-info ("Session wins")
     (let ((erc-session-server "irc.gnu.org")
@@ -93,6 +97,7 @@ erc-services-tests--auth-source-standard
       (should (string= (funcall search :user "#chan") "baz")))))
 
 (defun erc-services-tests--auth-source-announced (search)
+  (setq search (erc-services-tests--wrap-search search))
   (let* ((erc--isupport-params (make-hash-table))
          (erc-server-parameters '(("CHANTYPES" . "&#")))
          (erc--target (erc--target-from-string "&chan")))
@@ -124,6 +129,7 @@ erc-services-tests--auth-source-announced
           (should (string= (funcall search :user "#chan") "foo")))))))
 
 (defun erc-services-tests--auth-source-overrides (search)
+  (setq search (erc-services-tests--wrap-search search))
   (let* ((erc-session-server "irc.gnu.org")
          (erc-server-announced-name "my.gnu.org")
          (erc-network 'GNU.chat)
@@ -537,18 +543,20 @@ erc-nickserv-get-password
            (erc-network 'FSF.chat)
            (erc-server-current-nick "tester")
            (erc-networks--id (erc-networks--id-create nil))
-           (erc-session-port 6697))
+           (erc-session-port 6697)
+           (search (erc-services-tests--wrap-search
+                    #'erc-nickserv-get-password)))
 
       (ert-info ("Lookup custom option")
-        (should (string= (erc-nickserv-get-password "alice") "foo")))
+        (should (string= (funcall search "alice") "foo")))
 
       (ert-info ("Auth source")
         (ert-info ("Network")
-          (should (string= (erc-nickserv-get-password "bob") "sesame")))
+          (should (string= (funcall search "bob") "sesame")))
 
         (ert-info ("Network ID")
           (let ((erc-networks--id (erc-networks--id-create 'GNU/chat)))
-            (should (string= (erc-nickserv-get-password "bob") "spam")))))
+            (should (string= (funcall search "bob") "spam")))))
 
       (ert-info ("Read input")
         (should (string=
diff --git a/test/lisp/erc/erc-tests.el b/test/lisp/erc/erc-tests.el
index 199b97858e..c74c2e4747 100644
--- a/test/lisp/erc/erc-tests.el
+++ b/test/lisp/erc/erc-tests.el
@@ -530,6 +530,28 @@ erc-ring-previous-command
   (when noninteractive
     (kill-buffer "*#fake*")))
 
+(ert-deftest erc--debug-irc-protocol-mask-secrets ()
+  (should-not erc-debug-irc-protocol)
+  (should erc--debug-irc-protocol-mask-secrets)
+  (with-temp-buffer
+    (setq erc-server-process (start-process "fake" (current-buffer) "true")
+          erc-server-current-nick "tester"
+          erc-session-server "myproxy.localhost"
+          erc-session-port 6667)
+    (let ((inhibit-message noninteractive))
+      (erc-toggle-debug-irc-protocol)
+      (erc-log-irc-protocol
+       (concat "PASS :" (erc--unfun (lambda () "changeme")) "\r\n")
+       'outgoing)
+      (set-process-query-on-exit-flag erc-server-process nil))
+    (with-current-buffer "*erc-protocol*"
+      (goto-char (point-min))
+      (search-forward "\r\n\r\n")
+      (search-forward "myproxy.localhost:6667 >> PASS :????????" (pos-eol)))
+    (when noninteractive
+      (kill-buffer "*erc-protocol*")
+      (should-not erc-debug-irc-protocol))))
+
 (ert-deftest erc-log-irc-protocol ()
   (should-not erc-debug-irc-protocol)
   (with-temp-buffer
-- 
2.38.1


^ permalink raw reply related	[flat|nested] 54+ messages in thread

* bug#29108: 25.3; ERC SASL support
       [not found]                           ` <87wn7pog1l.fsf@neverwas.me>
@ 2022-11-21 15:09                             ` J.P.
       [not found]                             ` <87y1s4mjj6.fsf@neverwas.me>
  1 sibling, 0 replies; 54+ messages in thread
From: J.P. @ 2022-11-21 15:09 UTC (permalink / raw)
  To: 29108; +Cc: emacs-erc, bandali

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

v13. Persist local modules in target buffers. Make `erc-modules' custom
`:set' function local-module aware. Add helper for defining toggles in
`define-erc-module'. Add more tests.

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

> Another possible UX annoyance is that local toggles, like
> `erc-sasl-mode', don't currently work in target buffers at all. We
> should maybe wrap them in an `erc-with-server-buffer'.

Actually, nah. Some local modules may operate in target buffers.


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

From ab824f5b33ec3977684af25266a658fd44c93b3d Mon Sep 17 00:00:00 2001
From: "F. Jason Park" <jp@neverwas.me>
Date: Mon, 21 Nov 2022 05:50:53 -0800
Subject: [PATCH 0/8] *** NOT A PATCH ***

*** BLURB HERE ***

F. Jason Park (8):
  Add GS2 authorization to sasl-scram-rfc
  Don't set erc-networks--id until network is known
  Make erc--server-reconnecting non-buffer-local
  Support local ERC modules in erc-mode buffers
  Call erc-login indirectly via new generic wrapper
  Add non-IRCv3 SASL module to ERC
  Accept functions in place of passwords in ERC
  Add test scenarios for local ERC modules

 doc/misc/erc.texi                             | 188 +++++++-
 etc/ERC-NEWS                                  |  13 +-
 lisp/erc/erc-backend.el                       |  34 +-
 lisp/erc/erc-common.el                        |  82 +++-
 lisp/erc/erc-compat.el                        | 104 +++++
 lisp/erc/erc-goodies.el                       |   1 +
 lisp/erc/erc-networks.el                      |  53 +--
 lisp/erc/erc-sasl.el                          | 428 ++++++++++++++++++
 lisp/erc/erc-services.el                      |   5 +-
 lisp/erc/erc.el                               | 166 +++++--
 lisp/net/sasl-scram-rfc.el                    |  21 +-
 test/lisp/erc/erc-sasl-tests.el               | 344 ++++++++++++++
 .../erc-scenarios-base-association-nick.el    |  84 ++--
 .../erc/erc-scenarios-base-local-modules.el   | 243 ++++++++++
 test/lisp/erc/erc-scenarios-sasl.el           | 144 ++++++
 test/lisp/erc/erc-services-tests.el           |  16 +-
 test/lisp/erc/erc-tests.el                    | 178 ++++++++
 .../resources/base/local-modules/first.eld    |  53 +++
 .../resources/base/local-modules/fourth.eld   |  53 +++
 .../resources/base/local-modules/second.eld   |  47 ++
 .../resources/base/local-modules/third.eld    |  43 ++
 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 ++
 26 files changed, 2320 insertions(+), 162 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-base-local-modules.el
 create mode 100644 test/lisp/erc/erc-scenarios-sasl.el
 create mode 100644 test/lisp/erc/resources/base/local-modules/first.eld
 create mode 100644 test/lisp/erc/resources/base/local-modules/fourth.eld
 create mode 100644 test/lisp/erc/resources/base/local-modules/second.eld
 create mode 100644 test/lisp/erc/resources/base/local-modules/third.eld
 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 834f1cbf2c..0c3137999a 100644
--- a/doc/misc/erc.texi
+++ b/doc/misc/erc.texi
@@ -391,15 +391,11 @@ 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 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}.
+When removing a module outside of the Customize ecosystem, you may
+wish to ensure it's disabled by invoking its associated minor-mode
+toggle, such as @kbd{M-x erc-spelling-mode @key{RET}}.  It may also be
+worth noting that, these days, calling @code{erc-update-modules} in an
+init file is typically unnecessary.
 
 The following is a list of available modules.
 
@@ -529,6 +525,36 @@ Modules
 
 @end table
 
+@subheading Local Modules
+
+All modules operate as minor modes under the hood, and some newer ones
+may be defined as buffer-local.  For everyday use, the only practical
+differences are
+
+@enumerate
+@item
+``control variables,'' like @code{erc-sasl-mode}, are stateful across
+IRC sessions and override @code{erc-module} membership when influencing
+module activation in new sessions
+@item
+removing a local module from @code{erc-modules} via Customize not only
+disables its mode but also kills its control variable in all ERC
+buffers
+@item
+``toggle commands,'' like @code{erc-sasl-mode} and
+@code{erc-sasl-enable}, behave differently, both from each other and
+from their global counterparts
+@end enumerate
+
+By default, all local-mode toggles, like @code{erc-sasl-mode}, only
+affect the current buffer, but their ``non-mode'' variants, such as
+@code{erc-sasl-enable}, operate on all buffers belonging to a
+connection when called interactively.  Keep in mind that whether
+enabled or not, a module may effectively be ``inert'' in certain types
+of buffers, such as queries and channels.  Whatever the case, a local
+toggle never mutates @code{erc-modules}.
+
+
 @c PRE5_4: Document every option of every module in its own subnode
 
 
diff --git a/lisp/erc/erc-backend.el b/lisp/erc/erc-backend.el
index 3cd949ddd3..f387491d4c 100644
--- a/lisp/erc/erc-backend.el
+++ b/lisp/erc/erc-backend.el
@@ -161,7 +161,6 @@ erc-whowas-on-nosuchnick
 (declare-function erc-login "erc" nil)
 (declare-function erc-make-notice "erc" (message))
 (declare-function erc-network "erc-networks" nil)
-(declare-function erc-networks--id-ensure-reload "erc-networks" (nid))
 (declare-function erc-networks--id-given "erc-networks" (arg &rest args))
 (declare-function erc-networks--id-reload "erc-networks" (arg &rest args))
 (declare-function erc-nickname-in-use "erc" (nick reason))
@@ -1638,7 +1637,8 @@ define-erc-response-handler
          'NICK-you ?n nick ?N nn)
         (run-hook-with-args 'erc-nick-changed-functions nn nick))
        (t
-        (erc-networks--id-ensure-reload erc-networks--id)
+        (when erc-server-connected
+          (erc-networks--id-reload erc-networks--id proc parsed))
         (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))))))
@@ -2265,7 +2265,8 @@ erc-server-322-message
 
 (define-erc-response-handler (433)
   "Login-time \"nick in use\"." nil
-  (erc-networks--id-ensure-reload erc-networks--id)
+  (when erc-server-connected
+    (erc-networks--id-reload erc-networks--id proc parsed))
   (erc-nickname-in-use (cadr (erc-response.command-args parsed))
                        "already in use"))
 
diff --git a/lisp/erc/erc-common.el b/lisp/erc/erc-common.el
index b791866ee2..a4046ba9b3 100644
--- a/lisp/erc/erc-common.el
+++ b/lisp/erc/erc-common.el
@@ -123,6 +123,30 @@ erc--normalize-module-symbol
   (setq symbol (intern (downcase (symbol-name symbol))))
   (or (cdr (assq symbol erc--module-name-migrations)) symbol))
 
+(defun erc--assemble-toggle (localp name ablsym mode val body)
+  (let ((arg (make-symbol "arg")))
+    `(defun ,ablsym ,(if localp `(&optional ,arg) '())
+       ,(concat
+         (if val "Enable" "Disable")
+         " ERC " (symbol-name name) " mode."
+         (when localp
+           "\nWith ARG, do so in all buffers for the current connection."))
+       (interactive ,@(when localp '("p")))
+       ,@(if localp
+             `((when (derived-mode-p 'erc-mode)
+                 (if ,arg
+                     (erc-with-all-buffers-of-server erc-server-process nil
+                       (,ablsym))
+                   (setq ,mode ,val)
+                   ,@body)))
+           `(,(if val
+                  `(cl-pushnew ',(erc--normalize-module-symbol name)
+                               erc-modules)
+                `(setq erc-modules (delq ',(erc--normalize-module-symbol name)
+                                         erc-modules)))
+             (setq ,mode ,val)
+             ,@body)))))
+
 (defmacro define-erc-module (name alias doc enable-body disable-body
                                   &optional local-p)
   "Define a new minor mode using ERC conventions.
@@ -136,9 +160,14 @@ 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.  Beware that for global
-modules, these helpers, as well as the minor-mode toggle, all mutate
-the user option `erc-modules'.
+erc-NAME-enable, and erc-NAME-disable.
+
+With LOCAL-P, these helpers take on an optional argument that,
+when non-nil, causes them to act on all buffers of a connection.
+This feature is mainly intended for interactive use and does not
+carry over to their respective minor-mode toggles.  Beware that
+for global modules, these helpers and toggles all mutate
+`erc-modules'.
 
 Example:
 
@@ -151,7 +180,6 @@ 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))))
@@ -171,24 +199,8 @@ define-erc-module
          (if ,mode
              (,enable)
            (,disable)))
-       (defun ,enable ()
-         ,(format "Enable ERC %S mode."
-                  name)
-         (interactive)
-         ,@(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)
-         ,@(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)))
+       ,(erc--assemble-toggle local-p name enable mode t enable-body)
+       ,(erc--assemble-toggle local-p name disable mode nil disable-body)
        ,(when (and alias (not (eq name alias)))
           `(defalias
              ',(intern
diff --git a/lisp/erc/erc-networks.el b/lisp/erc/erc-networks.el
index fed9b3591b..19a7ab8643 100644
--- a/lisp/erc/erc-networks.el
+++ b/lisp/erc/erc-networks.el
@@ -985,20 +985,6 @@ erc-networks--id-reload
                   ((not (equal (buffer-name) new-name))))
         (rename-buffer new-name 'unique))))
 
-(cl-defgeneric erc-networks--id-ensure-reload (_nid)
-  "Schedule or run a reload for IDs derived from nicks."
-  nil)
-
-(cl-defmethod erc-networks--id-ensure-reload
-  ((nid erc-networks--id-qualifying))
-  (if erc-server-connected
-      (erc-networks--id-reload nid)
-    (letrec ((fn (lambda (&rest _)
-                   (when (eq nid erc-networks--id)
-                     (erc-networks--id-reload nid))
-                   (remove-hook 'erc-after-connect fn t))))
-      (add-hook 'erc-after-connect fn nil t))))
-
 (cl-defgeneric erc-networks--id-ensure-comparable (self other)
   "Take measures to ensure two net identities are in comparable states.")
 
@@ -1404,16 +1390,17 @@ erc-networks--update-server-identity
 ;; server buffer, whereas `erc-networks--rename-server-buffer' can run
 ;; mid-session, after an identity's core components have changed.
 
-(defun erc-networks--init-identity (_proc _parsed)
+(defun erc-networks--init-identity (proc parsed)
   "Update identity with real network name."
   ;; Initialize identity for real now that we know the network
   (cl-assert erc-network)
-  (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)
-  ;;
+  (if erc-networks--id
+      (erc-networks--id-reload erc-networks--id proc parsed)
+    (setq erc-networks--id (erc-networks--id-create nil))
+    ;; Find duplicate identities or other conflicting ones and act
+    ;; accordingly.
+    (erc-networks--update-server-identity)
+    (erc-networks--rename-server-buffer proc parsed))
   nil)
 
 (defun erc-networks--rename-server-buffer (new-proc &optional _parsed)
@@ -1481,8 +1468,7 @@ erc-networks-on-MOTD-end
   ;; For now, retain compatibility with erc-server-NNN-functions.
   (or (erc-networks--ensure-announced proc parsed)
       (erc-networks--set-name proc parsed)
-      (erc-networks--init-identity proc parsed)
-      (erc-networks--rename-server-buffer proc parsed)))
+      (erc-networks--init-identity proc parsed)))
 
 (define-erc-module networks nil
   "Provide data about IRC networks."
diff --git a/lisp/erc/erc-sasl.el b/lisp/erc/erc-sasl.el
index 16fe93f50d..0158161b84 100644
--- a/lisp/erc/erc-sasl.el
+++ b/lisp/erc/erc-sasl.el
@@ -345,8 +345,7 @@ erc-sasl--authenticate-handler
 (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.
+  ;; See bug#49860 for a CAP 3.2-aware WIP implementation.
   ((unless erc--target
      (add-hook 'erc-server-AUTHENTICATE-functions
                #'erc-sasl--authenticate-handler 0 t)
diff --git a/lisp/erc/erc.el b/lisp/erc/erc.el
index 8daf8397d1..f18e214d55 100644
--- a/lisp/erc/erc.el
+++ b/lisp/erc/erc.el
@@ -1810,9 +1810,16 @@ erc-modules
            (dolist (module erc-modules)
              (unless (member module val)
                (let ((f (intern-soft (format "erc-%s-mode" module))))
-                 (when (and (fboundp f) (boundp f) (symbol-value f))
-                   (message "Disabling `erc-%s'" module)
-                   (funcall f 0))))))
+                 (when (and (fboundp f) (boundp f))
+                   (when (symbol-value f)
+                     (message "Disabling `erc-%s'" module)
+                     (funcall f 0))
+                   (unless (or (custom-variable-p f)
+                               (not (fboundp 'erc-buffer-filter)))
+                     (erc-buffer-filter (lambda ()
+                                          (when (symbol-value f)
+                                            (funcall f 0))
+                                          (kill-local-variable f)))))))))
          (set sym val)
          ;; this test is for the case where erc hasn't been loaded yet
          (when (fboundp 'erc-update-modules)
@@ -1963,6 +1970,7 @@ 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-vars (and (not connect) (buffer-local-variables)))
          (old-recon-count erc-server-reconnect-count)
          (old-point nil)
          (delayed-modules nil)
@@ -1973,8 +1981,9 @@ erc-open
     (when connect (run-hook-with-args 'erc-before-connect server port nick))
     (set-buffer buffer)
     (setq old-point (point))
-    (setq delayed-modules (erc--merge-local-modes (erc--update-modules)
-                                                  erc--server-reconnecting))
+    (setq delayed-modules
+          (erc--merge-local-modes (erc--update-modules)
+                                  (or erc--server-reconnecting old-vars)))
 
     (delay-mode-hooks (erc-mode))
 
@@ -5950,7 +5959,8 @@ erc-set-current-nick
       (setq erc-server-current-nick nick)
       ;; This seems sensible but may well be superfluous.  Should
       ;; really prove that it's actually needed via test scenario.
-      (erc-networks--id-ensure-reload erc-networks--id))
+      (when erc-server-connected
+        (erc-networks--id-reload erc-networks--id)))
     nick))
 
 (defun erc-current-nick ()
diff --git a/test/lisp/erc/erc-scenarios-base-local-modules.el b/test/lisp/erc/erc-scenarios-base-local-modules.el
new file mode 100644
index 0000000000..417705de09
--- /dev/null
+++ b/test/lisp/erc/erc-scenarios-base-local-modules.el
@@ -0,0 +1,243 @@
+;;; erc-scenarios-local-modules.el --- Local modules 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:
+
+;;; Commentary:
+
+;; These tests all use `sasl' because, as of ERC 5.5, it's the one
+;; and only local module.
+
+(require 'ert-x)
+(eval-and-compile
+  (let ((load-path (cons (ert-resource-directory) load-path)))
+    (require 'erc-scenarios-common)))
+
+(require 'erc-sasl)
+
+;; This asserts that a local module's options and its inclusion in
+;; (and absence from) `erc-update-modules' can be let-bound.
+
+(ert-deftest erc-scenarios-base-local-modules--reconnect-let ()
+  :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))
+       (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 (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")))))
+
+;; After quitting a session for which `sasl' is enabled, you
+;; disconnect and toggle `erc-sasl-mode' off.  You then reconnect
+;; using an alternate nickname.  You again disconnect and reconnect,
+;; this time immediately, and the mode stays disabled.  Finally, you
+;; once again disconnect, toggle the mode back on, and reconnect.  You
+;; are authenticated successfully, just like in the initial session.
+;;
+;; This is meant to show that a user's local mode settings persist
+;; between sessions.  It also happens to show (in round four, below)
+;; that a server renicking a user on 001 after a 903 is handled just
+;; like a user-initiated renick, although this is not the main thrust.
+
+(ert-deftest erc-scenarios-base-local-modules--mode-persistence ()
+  :tags '(:expensive-test)
+  (erc-scenarios-common-with-cleanup
+      ((erc-scenarios-common-dialog "base/local-modules")
+       (erc-server-flood-penalty 0.1)
+       (dumb-server (erc-d-run "localhost" t 'first 'second 'third 'fourth))
+       (port (process-contact dumb-server :service))
+       (erc-modules (cons 'sasl erc-modules))
+       (expect (erc-d-t-make-expecter))
+       (server-buffer-name (format "127.0.0.1:%d" port)))
+
+    (ert-info ("Round one, initial authentication succeeds as expected")
+      (with-current-buffer (erc :server "127.0.0.1"
+                                :port port
+                                :nick "tester"
+                                :user "tester"
+                                :password "changeme"
+                                :full-name "tester")
+        (should (string= (buffer-name) server-buffer-name))
+        (funcall expect 10 "You are now logged in as tester"))
+
+      (with-current-buffer (erc-d-t-wait-for 10 (get-buffer "foonet"))
+        (funcall expect 10 "This server is in debug mode")
+        (erc-cmd-JOIN "#chan")
+
+        (with-current-buffer (erc-d-t-wait-for 10 (get-buffer "#chan"))
+          (funcall expect 20 "She is Lavinia, therefore must"))
+
+        (erc-cmd-QUIT "")
+        (funcall expect 10 "finished")))
+
+    (ert-info ("Round two, nick rejected, alternate granted")
+      (with-current-buffer "foonet"
+
+        (ert-info ("Toggle mode off, reconnect")
+          (erc-sasl-mode -1)
+          (erc-cmd-RECONNECT))
+
+        (funcall expect 10 "User modes for tester`")
+        (should-not (cdr (erc-scenarios-common-buflist "foonet")))
+        (should (equal (buffer-name) "foonet"))
+        (should-not (cdr (erc-scenarios-common-buflist "#chan")))
+
+        (with-current-buffer "#chan"
+          (funcall expect 10 "Some enigma, some riddle"))
+
+        (erc-cmd-QUIT "")
+        (funcall expect 10 "finished")))
+
+    (ert-info ("Round three, send alternate nick initially")
+      (with-current-buffer "foonet"
+
+        (ert-info ("Keep mode off, reconnect")
+          (should-not erc-sasl-mode)
+          (should (local-variable-p 'erc-sasl-mode))
+          (erc-cmd-RECONNECT))
+
+        (funcall expect 10 "User modes for tester`")
+        (should-not (cdr (erc-scenarios-common-buflist "foonet")))
+        (should (equal (buffer-name) "foonet"))
+        (should-not (cdr (erc-scenarios-common-buflist "#chan")))
+
+        (with-current-buffer "#chan"
+          (funcall expect 10 "Let our reciprocal vows be remembered."))
+
+        (erc-cmd-QUIT "")
+        (funcall expect 10 "finished")))
+
+    (ert-info ("Round four, authenticated successfully again")
+      (with-current-buffer "foonet"
+
+        (ert-info ("Toggle mode on, reconnect")
+          (should-not erc-sasl-mode)
+          (should (local-variable-p 'erc-sasl-mode))
+          (erc-sasl-mode +1)
+          (erc-cmd-RECONNECT))
+
+        (funcall expect 10 "User modes for tester")
+        (should-not (cdr (erc-scenarios-common-buflist "foonet")))
+        (should (equal (buffer-name) "foonet"))
+        (should-not (cdr (erc-scenarios-common-buflist "#chan")))
+
+        (with-current-buffer "#chan"
+          (funcall expect 10 "Well met; good morrow, Titus and Hortensius."))
+
+        (erc-cmd-QUIT "")))))
+
+;; For local modules, the twin toggle commands `erc-FOO-enable' and
+;; `erc-FOO-disable' affect all buffers of a connection, whereas
+;; `erc-FOO-mode' continues to operate only on the current buffer.
+
+(ert-deftest erc-scenarios-base-local-modules--toggle-helpers ()
+  :tags '(:expensive-test)
+  (erc-scenarios-common-with-cleanup
+      ((erc-scenarios-common-dialog "base/local-modules")
+       (erc-server-flood-penalty 0.1)
+       (dumb-server (erc-d-run "localhost" t 'first 'second 'fourth))
+       (port (process-contact dumb-server :service))
+       (erc-modules (cons 'sasl erc-modules))
+       (expect (erc-d-t-make-expecter))
+       (server-buffer-name (format "127.0.0.1:%d" port)))
+
+    (ert-info ("Initial authentication succeeds as expected")
+      (with-current-buffer (erc :server "127.0.0.1"
+                                :port port
+                                :nick "tester"
+                                :user "tester"
+                                :password "changeme"
+                                :full-name "tester")
+        (should (string= (buffer-name) server-buffer-name))
+        (funcall expect 10 "You are now logged in as tester"))
+
+      (with-current-buffer (erc-d-t-wait-for 10 (get-buffer "foonet"))
+        (funcall expect 10 "This server is in debug mode")
+        (erc-cmd-JOIN "#chan")
+
+        (with-current-buffer (erc-d-t-wait-for 10 (get-buffer "#chan"))
+          (funcall expect 20 "She is Lavinia, therefore must"))
+
+        (erc-cmd-QUIT "")
+        (funcall expect 10 "finished")))
+
+    (ert-info ("Disabling works from a target buffer.")
+      (with-current-buffer "#chan"
+        (should erc-sasl-mode)
+        (call-interactively #'erc-sasl-disable)
+        (should-not erc-sasl-mode)
+        (should (local-variable-p 'erc-sasl-mode))
+        (should-not (buffer-local-value 'erc-sasl-mode (get-buffer "foonet")))
+        (erc-cmd-RECONNECT)
+        (with-current-buffer "#chan"
+          (funcall expect 10 "Some enigma, some riddle")
+          (should-not erc-sasl-mode) ; regression
+          (should (local-variable-p 'erc-sasl-mode))))
+
+      (with-current-buffer "foonet"
+        (should (local-variable-p 'erc-sasl-mode))
+        (funcall expect 10 "User modes for tester`")
+        (erc-cmd-QUIT "")
+        (funcall expect 10 "finished")))
+
+    (ert-info ("Enabling works from a target buffer")
+      (with-current-buffer "#chan"
+        (call-interactively #'erc-sasl-enable)
+        (should (local-variable-p 'erc-sasl-mode))
+        (should erc-sasl-mode)
+        (erc-cmd-RECONNECT)
+        (funcall expect 10 "Well met; good morrow, Titus and Hortensius.")
+        (erc-cmd-QUIT ""))
+
+      (with-current-buffer "foonet"
+        (should (local-variable-p 'erc-sasl-mode))
+        (should erc-sasl-mode)
+        (funcall expect 10 "User modes for tester")))))
+
+;;; erc-scenarios-local-modules.el ends here
diff --git a/test/lisp/erc/erc-scenarios-sasl.el b/test/lisp/erc/erc-scenarios-sasl.el
index 713c9929c3..6c5e78d0c8 100644
--- a/test/lisp/erc/erc-scenarios-sasl.el
+++ b/test/lisp/erc/erc-scenarios-sasl.el
@@ -25,22 +25,17 @@
   (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-password "password123")
-       (inhibit-message noninteractive)
        (expect (erc-d-t-make-expecter)))
 
     (ert-info ("Connect")
@@ -51,66 +46,21 @@ erc-scenarios-sasl--plain
                                 :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"
+      (with-current-buffer (erc-d-t-wait-for 10 (get-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))
-       (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 (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)
-       (inhibit-message noninteractive)
        (expect (erc-d-t-make-expecter)))
 
     (ert-info ("Connect")
@@ -121,25 +71,21 @@ erc-scenarios-sasl--external
                                 :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")
+      (with-current-buffer (erc-d-t-wait-for 10 (get-buffer "ExampleOrg"))
+        (funcall expect 10 "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)
-       (inhibit-message noninteractive)
        (expect (erc-d-t-make-expecter))
        (buf nil))
 
@@ -161,7 +107,6 @@ erc-scenarios-sasl--plain-fail
 (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))
@@ -170,7 +115,6 @@ erc-scenarios--common--sasl
        (erc-sasl-mechanism mech)
        (mock-rvs (list "c5RqLCZy0L4fGkKAZ0hujFBs" ""))
        (sasl-unique-id-function (lambda () (pop mock-rvs)))
-       (inhibit-message noninteractive)
        (expect (erc-d-t-make-expecter)))
 
     (ert-info ("Connect")
@@ -181,10 +125,8 @@ erc-scenarios--common--sasl
                                 :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"
+      (with-current-buffer (erc-d-t-wait-for 10 (get-buffer "jaguar"))
         (funcall expect 10 "Found your hostname")
         (funcall expect 20 "marked as being away")))))
 
diff --git a/test/lisp/erc/erc-tests.el b/test/lisp/erc/erc-tests.el
index c74c2e4747..4d0d69cd7b 100644
--- a/test/lisp/erc/erc-tests.el
+++ b/test/lisp/erc/erc-tests.el
@@ -1261,4 +1261,99 @@ erc--merge-local-modes
       (should (equal (erc--merge-local-modes new old)
                      '((erc-d-mode erc-e-mode) . (erc-b-mode)))))))
 
+(ert-deftest define-erc-module--global ()
+  (let ((global-module '(define-erc-module mname malias
+                          "Some docstring"
+                          ((ignore a) (ignore b))
+                          ((ignore c) (ignore d)))))
+
+    (should (equal (macroexpand global-module)
+                   `(progn
+
+                      (define-minor-mode erc-mname-mode
+                        "Toggle ERC mname mode.
+With a prefix argument ARG, enable mname if ARG is positive,
+and disable it otherwise.  If called from Lisp, enable the mode
+if ARG is omitted or nil.
+Some docstring"
+                        :global t
+                        :group 'erc-mname
+                        (if erc-mname-mode
+                            (erc-mname-enable)
+                          (erc-mname-disable)))
+
+                      (defun erc-mname-enable ()
+                        "Enable ERC mname mode."
+                        (interactive)
+                        (cl-pushnew 'mname erc-modules)
+                        (setq erc-mname-mode t)
+                        (ignore a) (ignore b))
+
+                      (defun erc-mname-disable ()
+                        "Disable ERC mname mode."
+                        (interactive)
+                        (setq erc-modules (delq 'mname erc-modules))
+                        (setq erc-mname-mode nil)
+                        (ignore c) (ignore d))
+
+                      (defalias 'erc-malias-mode #'erc-mname-mode)
+
+                      (put 'erc-mname-mode 'definition-name 'mname)
+                      (put 'erc-mname-enable 'definition-name 'mname)
+                      (put 'erc-mname-disable 'definition-name 'mname))))))
+
+(ert-deftest define-erc-module--local ()
+  (let* ((global-module '(define-erc-module mname malias
+                           "Some docstring"
+                           ((ignore a) (ignore b))
+                           ((ignore c) (ignore d))
+                           'local))
+         (got (macroexpand global-module))
+         (arg-en (cadr (nth 2 (nth 2 got))))
+         (arg-dis (cadr (nth 2 (nth 3 got)))))
+
+    (should (equal got
+                   `(progn
+                      (define-minor-mode erc-mname-mode
+                        "Toggle ERC mname mode.
+With a prefix argument ARG, enable mname if ARG is positive,
+and disable it otherwise.  If called from Lisp, enable the mode
+if ARG is omitted or nil.
+Some docstring"
+                        :global nil
+                        :group 'erc-mname
+                        (if erc-mname-mode
+                            (erc-mname-enable)
+                          (erc-mname-disable)))
+
+                      (defun erc-mname-enable (&optional ,arg-en)
+                        "Enable ERC mname mode.
+With ARG, do so in all buffers for the current connection."
+                        (interactive "p")
+                        (when (derived-mode-p 'erc-mode)
+                          (if ,arg-en
+                              (erc-with-all-buffers-of-server
+                                  erc-server-process nil
+                                (erc-mname-enable))
+                            (setq erc-mname-mode t)
+                            (ignore a) (ignore b))))
+
+                      (defun erc-mname-disable (&optional ,arg-dis)
+                        "Disable ERC mname mode.
+With ARG, do so in all buffers for the current connection."
+                        (interactive "p")
+                        (when (derived-mode-p 'erc-mode)
+                          (if ,arg-dis
+                              (erc-with-all-buffers-of-server
+                                  erc-server-process nil
+                                (erc-mname-disable))
+                            (setq erc-mname-mode nil)
+                            (ignore c) (ignore d))))
+
+                      (defalias 'erc-malias-mode #'erc-mname-mode)
+
+                      (put 'erc-mname-mode 'definition-name 'mname)
+                      (put 'erc-mname-enable 'definition-name 'mname)
+                      (put 'erc-mname-disable 'definition-name 'mname))))))
+
 ;;; erc-tests.el ends here
diff --git a/test/lisp/erc/resources/base/local-modules/first.eld b/test/lisp/erc/resources/base/local-modules/first.eld
new file mode 100644
index 0000000000..f9181a80fb
--- /dev/null
+++ b/test/lisp/erc/resources/base/local-modules/first.eld
@@ -0,0 +1,53 @@
+;; -*- mode: lisp-data; -*-
+((cap 10 "CAP REQ :sasl"))
+((nick 1 "NICK tester"))
+((user 1 "USER tester 0 * :tester"))
+
+((authenticate 5 "AUTHENTICATE PLAIN")
+ (0.0 ":irc.foonet.org CAP * ACK sasl")
+ (0.0 "AUTHENTICATE +"))
+
+((authenticate 5 "AUTHENTICATE AHRlc3RlcgBjaGFuZ2VtZQ==")
+ (0.0 ":irc.foonet.org 900 * * tester :You are now logged in as tester")
+ (0.01 ":irc.foonet.org 903 * :Authentication successful"))
+
+((cap 3.2 "CAP END")
+ (0.0 ":irc.foonet.org 001 tester :Welcome to the foonet IRC Network tester")
+ (0.0 ":irc.foonet.org 002 tester :Your host is irc.foonet.org, running version ergo-v2.8.0")
+ (0.2 ":irc.foonet.org 003 tester :This server was created Sun, 20 Nov 2022 23:10:36 UTC")
+ (0.0 ":irc.foonet.org 004 tester irc.foonet.org ergo-v2.8.0 BERTZios CEIMRUabefhiklmnoqstuv Iabefhkloqv")
+ (0.0 ":irc.foonet.org 005 tester AWAYLEN=390 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.0 ":irc.foonet.org 005 tester MAXLIST=beI:60 MAXTARGETS=4 MODES MONITOR=100 NETWORK=foonet NICKLEN=32 PREFIX=(qaohv)~&@%+ STATUSMSG=~&@%+ TARGMAX=NAMES:1,LIST:1,KICK:,WHOIS:1,USERHOST:10,PRIVMSG:4,TAGMSG:4,NOTICE:4,MONITOR:100 TOPICLEN=390 UTF8MAPPING=rfc8265 UTF8ONLY WHOX :are supported by this server")
+ (0.0 ":irc.foonet.org 005 tester draft/CHATHISTORY=100 :are supported by this server")
+ (0.0 ":irc.foonet.org 251 tester :There are 0 users and 3 invisible on 1 server(s)")
+ (0.0 ":irc.foonet.org 252 tester 0 :IRC Operators online")
+ (0.0 ":irc.foonet.org 253 tester 0 :unregistered connections")
+ (0.0 ":irc.foonet.org 254 tester 1 :channels formed")
+ (0.0 ":irc.foonet.org 255 tester :I have 3 clients and 0 servers")
+ (0.0 ":irc.foonet.org 265 tester 3 3 :Current local users 3, max 3")
+ (0.0 ":irc.foonet.org 266 tester 3 3 :Current global users 3, max 3")
+ (0.0 ":irc.foonet.org 422 tester :MOTD File is missing")
+ (0.0 ":irc.foonet.org 221 tester +i")
+ (0.0 ":irc.foonet.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."))
+
+((mode 10 "MODE tester +i")
+ (0.02 ":irc.foonet.org 221 tester +i"))
+
+((join 10 "JOIN #chan")
+ (0.00 ":tester!~u@u9iqi96sfwk9s.irc JOIN #chan")
+ (0.06 ":irc.foonet.org 353 tester = #chan :@bob alice tester")
+ (0.01 ":irc.foonet.org 366 tester #chan :End of NAMES list")
+ (0.02 ":bob!~u@2fzfcku68ehqa.irc PRIVMSG #chan :tester, welcome!")
+ (0.01 ":alice!~u@2fzfcku68ehqa.irc PRIVMSG #chan :tester, welcome!")
+ (0.04 ":alice!~u@2fzfcku68ehqa.irc PRIVMSG #chan :bob: Either your unparagoned mistress is dead, or she's outprized by a trifle."))
+
+((mode 12 "MODE #chan")
+ (0.00 ":irc.foonet.org 324 tester #chan +nt")
+ (0.02 ":irc.foonet.org 329 tester #chan 1668985854")
+ (0.98 ":bob!~u@2fzfcku68ehqa.irc PRIVMSG #chan :alice: Come, you are a tedious fool: to the purpose. What was done to Elbow's wife, that he hath cause to complain of ? Come me to what was done to her.")
+ (0.01 ":alice!~u@2fzfcku68ehqa.irc PRIVMSG #chan :bob: She is Lavinia, therefore must be lov'd."))
+
+((quit 10 "QUIT :\2ERC\2")
+ (0.02 ":tester!~u@u9iqi96sfwk9s.irc QUIT :Quit"))
+
+((drop 0 DROP))
diff --git a/test/lisp/erc/resources/base/local-modules/fourth.eld b/test/lisp/erc/resources/base/local-modules/fourth.eld
new file mode 100644
index 0000000000..fd6d62b6cc
--- /dev/null
+++ b/test/lisp/erc/resources/base/local-modules/fourth.eld
@@ -0,0 +1,53 @@
+;; -*- mode: lisp-data; -*-
+((cap 10 "CAP REQ :sasl"))
+((nick 10 "NICK tester`"))
+((user 10 "USER tester 0 * :tester"))
+
+((authenticate 10 "AUTHENTICATE PLAIN")
+ (0.0 ":irc.foonet.org CAP * ACK sasl")
+ (0.0 "AUTHENTICATE +"))
+
+((authenticate 10 "AUTHENTICATE AHRlc3RlcgBjaGFuZ2VtZQ==")
+ (0.00 ":irc.foonet.org 900 * * tester :You are now logged in as tester")
+ (0.01 ":irc.foonet.org 903 * :Authentication successful"))
+
+((cap 10 "CAP END")
+ (0.00 ":irc.foonet.org 001 tester :Welcome to the foonet IRC Network tester")
+ (0.01 ":irc.foonet.org 002 tester :Your host is irc.foonet.org, running version ergo-v2.8.0")
+ (0.01 ":irc.foonet.org 003 tester :This server was created Sun, 20 Nov 2022 23:10:36 UTC")
+ (0.01 ":irc.foonet.org 004 tester irc.foonet.org ergo-v2.8.0 BERTZios CEIMRUabefhiklmnoqstuv Iabefhkloqv")
+ (0.13 ":irc.foonet.org 005 tester AWAYLEN=390 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.foonet.org 005 tester MAXLIST=beI:60 MAXTARGETS=4 MODES MONITOR=100 NETWORK=foonet NICKLEN=32 PREFIX=(qaohv)~&@%+ STATUSMSG=~&@%+ TARGMAX=NAMES:1,LIST:1,KICK:,WHOIS:1,USERHOST:10,PRIVMSG:4,TAGMSG:4,NOTICE:4,MONITOR:100 TOPICLEN=390 UTF8MAPPING=rfc8265 UTF8ONLY WHOX :are supported by this server")
+ (0.03 ":irc.foonet.org 005 tester draft/CHATHISTORY=100 :are supported by this server")
+ (0.00 ":irc.foonet.org 251 tester :There are 0 users and 3 invisible on 1 server(s)")
+ (0.00 ":irc.foonet.org 252 tester 0 :IRC Operators online")
+ (0.00 ":irc.foonet.org 253 tester 0 :unregistered connections")
+ (0.00 ":irc.foonet.org 254 tester 1 :channels formed")
+ (0.00 ":irc.foonet.org 255 tester :I have 3 clients and 0 servers")
+ (0.00 ":irc.foonet.org 265 tester 3 3 :Current local users 3, max 3")
+ (0.00 ":irc.foonet.org 266 tester 3 3 :Current global users 3, max 3")
+ (0.03 ":irc.foonet.org 422 tester :MOTD File is missing")
+ (0.02 ":irc.foonet.org 221 tester +i")
+ (0.00 ":irc.foonet.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."))
+
+((mode 10 "MODE tester +i")
+ (0.0 ":irc.foonet.org 221 tester +i"))
+
+((join 10 "JOIN #chan")
+ (0.00 ":tester!~u@u9iqi96sfwk9s.irc JOIN #chan")
+ (0.09 ":irc.foonet.org 353 tester = #chan :alice tester @bob")
+ (0.01 ":irc.foonet.org 366 tester #chan :End of NAMES list")
+ (0.00 ":alice!~u@2fzfcku68ehqa.irc PRIVMSG #chan :tester, welcome!")
+ (0.00 ":bob!~u@2fzfcku68ehqa.irc PRIVMSG #chan :tester, welcome!")
+ (0.03 ":alice!~u@2fzfcku68ehqa.irc PRIVMSG #chan :bob: And both shall cease, without your remedy.")
+ (0.02 ":bob!~u@2fzfcku68ehqa.irc PRIVMSG #chan :alice: Nay, tarry; I'll go along with thee: I can tell thee pretty tales of the duke."))
+
+((mode 10 "MODE #chan")
+ (0.00 ":irc.foonet.org 324 tester #chan +nt")
+ (0.01 ":irc.foonet.org 329 tester #chan 1668985854")
+ (0.03 ":alice!~u@2fzfcku68ehqa.irc PRIVMSG #chan :bob: Do: I'll take the sacrament on't, how and which way you will.")
+ (0.00 ":bob!~u@2fzfcku68ehqa.irc PRIVMSG #chan :alice: Worthy Macbeth, we stay upon your leisure.")
+ (0.00 ":alice!~u@2fzfcku68ehqa.irc PRIVMSG #chan :bob: Well met; good morrow, Titus and Hortensius."))
+
+((quit 10 "QUIT :\2ERC\2")
+ (0.03 ":tester!~u@u9iqi96sfwk9s.irc QUIT :Quit"))
diff --git a/test/lisp/erc/resources/base/local-modules/second.eld b/test/lisp/erc/resources/base/local-modules/second.eld
new file mode 100644
index 0000000000..a96103b2aa
--- /dev/null
+++ b/test/lisp/erc/resources/base/local-modules/second.eld
@@ -0,0 +1,47 @@
+;; -*- mode: lisp-data; -*-
+((pass 10 "PASS :changeme"))
+((nick 1 "NICK tester"))
+((user 1 "USER tester 0 * :tester")
+ (0.0 ":irc.foonet.org 433 * tester :Nickname is reserved by a different account"))
+
+((nick 10 "NICK tester`")
+ (0.01 ":irc.foonet.org FAIL NICK NICKNAME_RESERVED tester :Nickname is reserved by a different account")
+ (0.06 ":irc.foonet.org 001 tester` :Welcome to the foonet IRC Network tester`")
+ (0.01 ":irc.foonet.org 002 tester` :Your host is irc.foonet.org, running version ergo-v2.8.0")
+ (0.01 ":irc.foonet.org 003 tester` :This server was created Sun, 20 Nov 2022 23:10:36 UTC")
+ (0.01 ":irc.foonet.org 004 tester` irc.foonet.org ergo-v2.8.0 BERTZios CEIMRUabefhiklmnoqstuv Iabefhkloqv")
+ (0.01 ":irc.foonet.org 005 tester` AWAYLEN=390 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.foonet.org 005 tester` MAXLIST=beI:60 MAXTARGETS=4 MODES MONITOR=100 NETWORK=foonet NICKLEN=32 PREFIX=(qaohv)~&@%+ STATUSMSG=~&@%+ TARGMAX=NAMES:1,LIST:1,KICK:,WHOIS:1,USERHOST:10,PRIVMSG:4,TAGMSG:4,NOTICE:4,MONITOR:100 TOPICLEN=390 UTF8MAPPING=rfc8265 UTF8ONLY WHOX :are supported by this server")
+ (0.01 ":irc.foonet.org 005 tester` draft/CHATHISTORY=100 :are supported by this server")
+ (0.00 ":irc.foonet.org 251 tester` :There are 0 users and 3 invisible on 1 server(s)")
+ (0.00 ":irc.foonet.org 252 tester` 0 :IRC Operators online")
+ (0.02 ":irc.foonet.org 253 tester` 0 :unregistered connections")
+ (0.00 ":irc.foonet.org 254 tester` 1 :channels formed")
+ (0.00 ":irc.foonet.org 255 tester` :I have 3 clients and 0 servers")
+ (0.00 ":irc.foonet.org 265 tester` 3 3 :Current local users 3, max 3")
+ (0.00 ":irc.foonet.org 266 tester` 3 3 :Current global users 3, max 3")
+ (0.00 ":irc.foonet.org 422 tester` :MOTD File is missing")
+ (0.02 ":irc.foonet.org 221 tester` +i")
+ (0.00 ":irc.foonet.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."))
+
+((mode 12 "MODE tester` +i")
+ (0.0 ":irc.foonet.org 221 tester` +i"))
+
+((join 10 "JOIN #chan")
+ (0.00 ":tester`!~u@u9iqi96sfwk9s.irc JOIN #chan")
+ (0.08 ":irc.foonet.org 353 tester` = #chan :@bob alice tester`")
+ (0.01 ":irc.foonet.org 366 tester` #chan :End of NAMES list")
+ (0.00 ":alice!~u@2fzfcku68ehqa.irc PRIVMSG #chan :tester`, welcome!")
+ (0.01 ":bob!~u@2fzfcku68ehqa.irc PRIVMSG #chan :tester`, welcome!")
+ (0.05 ":bob!~u@2fzfcku68ehqa.irc PRIVMSG #chan :alice: And Jove, for your love, would infringe an oath."))
+
+((mode 10 "MODE #chan")
+ (0.00 ":irc.foonet.org 324 tester` #chan +nt")
+ (0.02 ":irc.foonet.org 329 tester` #chan 1668985854")
+ (0.07 ":alice!~u@2fzfcku68ehqa.irc PRIVMSG #chan :bob: To you that know them not. This to my mother.")
+ (0.00 ":bob!~u@2fzfcku68ehqa.irc PRIVMSG #chan :alice: Some enigma, some riddle: come, thy l'envoy; begin."))
+
+((quit 1 "QUIT :\2ERC\2")
+ (0.03 ":tester`!~u@u9iqi96sfwk9s.irc QUIT"))
+
+((drop 0 DROP))
diff --git a/test/lisp/erc/resources/base/local-modules/third.eld b/test/lisp/erc/resources/base/local-modules/third.eld
new file mode 100644
index 0000000000..060083656a
--- /dev/null
+++ b/test/lisp/erc/resources/base/local-modules/third.eld
@@ -0,0 +1,43 @@
+;; -*- mode: lisp-data; -*-
+((pass 10 "PASS :changeme"))
+((nick 1 "NICK tester`"))
+((user 1 "USER tester 0 * :tester")
+ (0.06 ":irc.foonet.org 001 tester` :Welcome to the foonet IRC Network tester`")
+ (0.01 ":irc.foonet.org 002 tester` :Your host is irc.foonet.org, running version ergo-v2.8.0")
+ (0.01 ":irc.foonet.org 003 tester` :This server was created Sun, 20 Nov 2022 23:10:36 UTC")
+ (0.01 ":irc.foonet.org 004 tester` irc.foonet.org ergo-v2.8.0 BERTZios CEIMRUabefhiklmnoqstuv Iabefhkloqv")
+ (0.01 ":irc.foonet.org 005 tester` AWAYLEN=390 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.foonet.org 005 tester` MAXLIST=beI:60 MAXTARGETS=4 MODES MONITOR=100 NETWORK=foonet NICKLEN=32 PREFIX=(qaohv)~&@%+ STATUSMSG=~&@%+ TARGMAX=NAMES:1,LIST:1,KICK:,WHOIS:1,USERHOST:10,PRIVMSG:4,TAGMSG:4,NOTICE:4,MONITOR:100 TOPICLEN=390 UTF8MAPPING=rfc8265 UTF8ONLY WHOX :are supported by this server")
+ (0.01 ":irc.foonet.org 005 tester` draft/CHATHISTORY=100 :are supported by this server")
+ (0.00 ":irc.foonet.org 251 tester` :There are 0 users and 3 invisible on 1 server(s)")
+ (0.00 ":irc.foonet.org 252 tester` 0 :IRC Operators online")
+ (0.02 ":irc.foonet.org 253 tester` 0 :unregistered connections")
+ (0.00 ":irc.foonet.org 254 tester` 1 :channels formed")
+ (0.00 ":irc.foonet.org 255 tester` :I have 3 clients and 0 servers")
+ (0.00 ":irc.foonet.org 265 tester` 3 3 :Current local users 3, max 3")
+ (0.00 ":irc.foonet.org 266 tester` 3 3 :Current global users 3, max 3")
+ (0.00 ":irc.foonet.org 422 tester` :MOTD File is missing")
+ (0.02 ":irc.foonet.org 221 tester` +i")
+ (0.00 ":irc.foonet.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."))
+
+((mode 12 "MODE tester` +i")
+ (0.0 ":irc.foonet.org 221 tester` +i"))
+
+((join 10 "JOIN #chan")
+ (0.00 ":tester`!~u@u9iqi96sfwk9s.irc JOIN #chan")
+ (0.08 ":irc.foonet.org 353 tester` = #chan :@bob alice tester`")
+ (0.01 ":irc.foonet.org 366 tester` #chan :End of NAMES list")
+ (0.00 ":alice!~u@2fzfcku68ehqa.irc PRIVMSG #chan :tester`, welcome!")
+ (0.01 ":bob!~u@2fzfcku68ehqa.irc PRIVMSG #chan :tester`, welcome!")
+ (0.05 ":bob!~u@2fzfcku68ehqa.irc PRIVMSG #chan :alice: With pomp, with triumph, and with revelling."))
+
+((mode 10 "MODE #chan")
+ (0.00 ":irc.foonet.org 324 tester` #chan +nt")
+ (0.02 ":irc.foonet.org 329 tester` #chan 1668985854")
+ (0.00 ":alice!~u@2fzfcku68ehqa.irc PRIVMSG #chan :bob: No remedy, my lord, when walls are so wilful to hear without warning.")
+ (0.01 ":bob!~u@2fzfcku68ehqa.irc PRIVMSG #chan :alice: Let our reciprocal vows be remembered. You have many opportunities to cut him off; if your will want not, time and place will be fruitfully offered. There is nothing done if he return the conqueror; then am I the prisoner, and his bed my gaol; from the loathed warmth whereof deliver me, and supply the place for your labour."))
+
+((quit 1 "QUIT :\2ERC\2")
+ (0.03 ":tester`!~u@u9iqi96sfwk9s.irc QUIT :Quit"))
+
+((drop 0 DROP))
-- 
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 030c91581ead371794e0e3a3e60a007cc308e95d 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/8] 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: 15610 bytes --]

From 29d0f61aadf80b49d39a5b7f6f6948f0e0666046 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/8] 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.  Add branch condition to reload ID on non-nil case.

* 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.
(erc-set-current-nick): When connected, reload network ID.  Leave
comment warning that it may be unneeded.

* lisp/erc/erc-backend.el (erc-server-NICK, erc-server-433): Unless
already connected, schedule ID reload when server rejects or mandates
a nick change.

* test/lisp/erc/erc-scenarios-base-association-nick.el
(erc-scenarios-base-association-nick-bumped,
erc-scenarios-base-association-nick-bumped-mandated-renick): Update to
reflect more liberal association behavior when renamed by IRCd.
---
 lisp/erc/erc-backend.el                       |  6 +-
 lisp/erc/erc-networks.el                      | 53 +++++-------
 lisp/erc/erc.el                               | 21 +++--
 .../erc-scenarios-base-association-nick.el    | 84 +++++++++++--------
 4 files changed, 90 insertions(+), 74 deletions(-)

diff --git a/lisp/erc/erc-backend.el b/lisp/erc/erc-backend.el
index 15fd6ac50f..f899b866f0 100644
--- a/lisp/erc/erc-backend.el
+++ b/lisp/erc/erc-backend.el
@@ -1619,7 +1619,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)
@@ -1629,6 +1629,8 @@ define-erc-response-handler
          'NICK-you ?n nick ?N nn)
         (run-hook-with-args 'erc-nick-changed-functions nn nick))
        (t
+        (when erc-server-connected
+          (erc-networks--id-reload erc-networks--id proc parsed))
         (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))))))
@@ -2255,6 +2257,8 @@ erc-server-322-message
 
 (define-erc-response-handler (433)
   "Login-time \"nick in use\"." nil
+  (when erc-server-connected
+    (erc-networks--id-reload erc-networks--id proc parsed))
   (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 b3e5fcf1a3..19a7ab8643 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))
@@ -1382,7 +1374,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)
@@ -1397,16 +1390,17 @@ erc-networks--update-server-identity
 ;; server buffer, whereas `erc-networks--rename-server-buffer' can run
 ;; mid-session, after an identity's core components have changed.
 
-(defun erc-networks--init-identity (_proc _parsed)
+(defun erc-networks--init-identity (proc parsed)
   "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))
-  ;; Find duplicate identities or other conflicting ones and act
-  ;; accordingly.
-  (erc-networks--update-server-identity)
-  ;;
+  (if erc-networks--id
+      (erc-networks--id-reload erc-networks--id proc parsed)
+    (setq erc-networks--id (erc-networks--id-create nil))
+    ;; Find duplicate identities or other conflicting ones and act
+    ;; accordingly.
+    (erc-networks--update-server-identity)
+    (erc-networks--rename-server-buffer proc parsed))
   nil)
 
 (defun erc-networks--rename-server-buffer (new-proc &optional _parsed)
@@ -1474,8 +1468,7 @@ erc-networks-on-MOTD-end
   ;; For now, retain compatibility with erc-server-NNN-functions.
   (or (erc-networks--ensure-announced proc parsed)
       (erc-networks--set-name proc parsed)
-      (erc-networks--init-identity proc parsed)
-      (erc-networks--rename-server-buffer proc parsed)))
+      (erc-networks--init-identity proc parsed)))
 
 (define-erc-module networks nil
   "Provide data about IRC networks."
diff --git a/lisp/erc/erc.el b/lisp/erc/erc.el
index 2312246e3e..1052c8c4c0 100644
--- a/lisp/erc/erc.el
+++ b/lisp/erc/erc.el
@@ -2017,10 +2017,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
@@ -3179,7 +3181,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
@@ -5904,7 +5907,13 @@ erc-set-current-nick
   (with-current-buffer (if (buffer-live-p (erc-server-buffer))
                            (erc-server-buffer)
                          (current-buffer))
-    (setq erc-server-current-nick nick)))
+    (unless (equal erc-server-current-nick nick)
+      (setq erc-server-current-nick nick)
+      ;; This seems sensible but may well be superfluous.  Should
+      ;; really prove that it's actually needed via test scenario.
+      (when erc-server-connected
+        (erc-networks--id-reload erc-networks--id)))
+    nick))
 
 (defun erc-current-nick ()
   "Return the current nickname."
diff --git a/test/lisp/erc/erc-scenarios-base-association-nick.el b/test/lisp/erc/erc-scenarios-base-association-nick.el
index 3e848be4df..b46c996bc0 100644
--- a/test/lisp/erc/erc-scenarios-base-association-nick.el
+++ b/test/lisp/erc/erc-scenarios-base-association-nick.el
@@ -25,13 +25,24 @@
 
 (eval-when-compile (require 'erc-join))
 
-;; You register a new nick, disconnect, and log back in, but your nick
-;; is not granted, so ERC obtains a backtick'd version.  You open a
-;; query buffer for NickServ, and ERC names it using the net-ID (which
-;; includes the backtick'd nick) as a suffix.  The original
-;; (disconnected) NickServ buffer gets renamed with *its* net-ID as
-;; well.  You then identify to NickServ, and the dead session is no
-;; longer considered distinct.
+;; You register a new nick in a dedicated query buffer, disconnect,
+;; and log back in, but your nick is not granted (maybe you just
+;; turned off SASL).  In any case, ERC obtains a backtick'd version.
+;; You open a query buffer for NickServ, and ERC gives you the
+;; existing one.  And after you identify, all buffers retain their
+;; names, although your net ID has changed internally.
+;;
+;; If ERC would've instead failed (or intentionally refused) to make
+;; the association, you would've ended up with a new NickServ buffer
+;; named after the new net ID as a suffix (based on the backtick'd
+;; nick), for example, NickServ@foonet/tester`.  And the original
+;; (disconnected) NickServ buffer would've gotten suffixed with *its*
+;; net-ID as well, e.g., NickServ@foonet/tester.  And after
+;; identifying, you would've seen ERC merge the two as well as their
+;; server buffers.  While this alternate behavior may arguably be a
+;; more honest reflection of reality, it's also quite inconvenient.
+;; For a clearer example, see the original version of this file
+;; introduced by "Add user-oriented test scenarios for ERC".
 
 (ert-deftest erc-scenarios-base-association-nick-bumped ()
   :tags '(:expensive-test)
@@ -67,30 +78,29 @@ erc-scenarios-base-association-nick-bumped
           (funcall expect 5 "ERC finished"))))
 
     (with-current-buffer "foonet"
-      (erc-cmd-RECONNECT))
+      (erc-cmd-RECONNECT)
+      (funcall expect 10 "User modes for tester`"))
 
-    (erc-d-t-wait-for 10 "Nick request rejection prevents reassociation (good)"
-      (get-buffer "foonet/tester`"))
+    (ert-info ("Server buffer reassociated with new nick")
+      (should-not (get-buffer "foonet/tester`")))
 
     (ert-info ("Ask NickServ to change nick")
-      (with-current-buffer "foonet/tester`"
-        (funcall expect 3 "already in use")
+      (with-current-buffer "foonet"
         (funcall expect 3 "debug mode")
         (erc-cmd-QUERY "NickServ"))
 
-      (erc-d-t-wait-for 1 "Dead NickServ query buffer renamed, now qualified"
-        (get-buffer "NickServ@foonet/tester"))
+      (ert-info ( "NickServ buffer reassociated")
+        (should-not (get-buffer "NickServ@foonet/tester`"))
+        (should-not (get-buffer "NickServ@foonet/tester")))
 
-      (with-current-buffer "NickServ@foonet/tester`" ; new one
+      (with-current-buffer "NickServ" ; new one
         (erc-scenarios-common-say "IDENTIFY tester changeme")
-        (funcall expect 5 "You're now logged in as tester")
-        (ert-info ("Original buffer found, reused")
-          (erc-d-t-wait-for 2 (equal (buffer-name) "NickServ")))))
+        (funcall expect 5 "You're now logged in as tester")))
 
-    (ert-info ("Ours is the only NickServ buffer that remains")
+    (ert-info ("Still just one NickServ buffer")
       (should-not (cdr (erc-scenarios-common-buflist "NickServ"))))
 
-    (ert-info ("Visible network ID truncated to one component")
+    (ert-info ("As well as one server buffer")
       (should (not (get-buffer "foonet/tester`")))
       (should (not (get-buffer "foonet/tester")))
       (should (get-buffer "foonet")))))
@@ -135,29 +145,29 @@ erc-scenarios-base-association-nick-bumped-mandated-renick
     ;; Since we use reconnect, a new buffer won't be created
     ;; TODO add variant with clean `erc' invocation
     (with-current-buffer "foonet"
-      (erc-cmd-RECONNECT))
+      (erc-cmd-RECONNECT)
+      (funcall expect 10 "User modes for dummy"))
 
-    (ert-info ("Server-initiated renick")
-      (with-current-buffer (erc-d-t-wait-for 10 (get-buffer "foonet/dummy"))
-        (should-not (get-buffer "foonet/tester"))
-        (funcall expect 15 "debug mode"))
+    (ert-info ("Server-initiated renick associated correctly")
+      (with-current-buffer "foonet"
+        (funcall expect 15 "debug mode")
+        (should-not (get-buffer "foonet/dummy"))
+        (should-not (get-buffer "foonet/tester")))
 
-      (erc-d-t-wait-for 1 "Old query renamed, now qualified"
-        (get-buffer "bob@foonet/tester"))
+      (ert-info ("Old query reassociated")
+        (should (get-buffer "bob"))
+        (should-not (get-buffer "bob@foonet/tester"))
+        (should-not (get-buffer "bob@foonet/dummy")))
 
-      (with-current-buffer (erc-d-t-wait-for 5 (get-buffer "bob@foonet/dummy"))
+      (with-current-buffer "foonet"
         (erc-cmd-NICK "tester")
-        (ert-info ("Buffers combined")
-          (erc-d-t-wait-for 2 (equal (buffer-name) "bob")))))
+        (funcall expect 5 "You're now logged in as tester")))
 
-    (with-current-buffer "foonet"
-      (funcall expect 5 "You're now logged in as tester"))
-
-    (ert-info ("Ours is the only bob buffer that remains")
+    (ert-info ("Ours is still the only bob buffer that remains")
       (should-not (cdr (erc-scenarios-common-buflist "bob"))))
 
-    (ert-info ("Visible network ID truncated to one component")
-      (should (not (get-buffer "foonet/dummy")))
-      (should (get-buffer "foonet")))))
+    (ert-info ("Visible network ID still truncated to one component")
+      (should (not (get-buffer "foonet/tester")))
+      (should (not (get-buffer "foonet/dummy"))))))
 
 ;;; erc-scenarios-base-association-nick.el ends here
-- 
2.38.1


[-- Warning: decoded text below may be mangled, UTF-8 assumed --]
[-- Attachment #5: 0003-Make-erc-server-reconnecting-non-buffer-local.patch --]
[-- Type: text/x-patch, Size: 4014 bytes --]

From 2a5835f627b7fd396e2a47312c45bc0dd7170d74 Mon Sep 17 00:00:00 2001
From: "F. Jason Park" <jp@neverwas.me>
Date: Fri, 18 Nov 2022 22:42:15 -0800
Subject: [PATCH 3/8] Make erc--server-reconnecting non-buffer-local

* lisp/erc/erc-backend.el (erc--server-reconnecting): Mention expected
non-nil value type in doc string.
(erc-server-connect): Don't set `erc--server-reconnecting'.
(erc-server--reconnect): Let-bind `erc--server-reconnecting' instead
of setting it locally in the server buffer.  Set it to an alist
containing the current buffer's local variables.
(erc-process-sentinel-2): Don't set `erc--server-reconnect'.
* lisp/erc/erc.el (erc--cmd-reconnect): Clean up some assertions.
(Bug#57955.)
---
 lisp/erc/erc-backend.el | 17 ++++++++++-------
 lisp/erc/erc.el         |  6 ++----
 2 files changed, 12 insertions(+), 11 deletions(-)

diff --git a/lisp/erc/erc-backend.el b/lisp/erc/erc-backend.el
index f899b866f0..30b53dfd8e 100644
--- a/lisp/erc/erc-backend.el
+++ b/lisp/erc/erc-backend.el
@@ -311,8 +311,13 @@ erc-server-reconnecting
 (make-obsolete-variable 'erc-server-reconnecting
                         "see `erc--server-reconnecting'" "29.1")
 
-(defvar-local erc--server-reconnecting nil
-  "Non-nil when reconnecting.")
+(defvar erc--server-reconnecting nil
+  "An alist of buffer-local vars and their values when reconnecting.
+This is for the benefit of local modules and `erc-mode-hook'
+members so they can access buffer-local data from the previous
+session when reconnecting.  Once `erc-reuse-buffers' is retired
+and fully removed, modules can switch to leveraging the
+`permanent-local' property instead.")
 
 (defvar-local erc-server-timed-out nil
   "Non-nil if the IRC server failed to respond to a ping.")
@@ -664,7 +669,6 @@ erc-server-connect
       (setq erc-server-process process)
       (setq erc-server-quitting nil)
       (setq erc-server-reconnecting nil
-            erc--server-reconnecting nil
             erc--server-reconnect-timer nil)
       (setq erc-server-timed-out nil)
       (setq erc-server-banned nil)
@@ -706,11 +710,11 @@ erc-server-reconnect
     (with-current-buffer buffer
       (erc-update-mode-line)
       (erc-set-active-buffer (current-buffer))
-      (setq erc--server-reconnecting t)
       (setq erc-server-last-sent-time 0)
       (setq erc-server-lines-sent 0)
       (let ((erc-server-connect-function (or erc-session-connector
-                                             #'erc-open-network-stream)))
+                                             #'erc-open-network-stream))
+            (erc--server-reconnecting (buffer-local-variables)))
         (erc-open erc-session-server erc-session-port erc-server-current-nick
                   erc-session-user-full-name t erc-session-password
                   nil nil nil erc-session-client-certificate
@@ -824,8 +828,7 @@ erc-process-sentinel-2
         (if (not reconnect-p)
             ;; terminate, do not reconnect
             (progn
-              (setq erc--server-reconnecting nil
-                    erc--server-reconnect-timer nil)
+              (setq erc--server-reconnect-timer nil)
               (erc-display-message nil 'error (current-buffer)
                                    'terminated ?e event)
               (set-buffer-modified-p nil))
diff --git a/lisp/erc/erc.el b/lisp/erc/erc.el
index 1052c8c4c0..352f72e617 100644
--- a/lisp/erc/erc.el
+++ b/lisp/erc/erc.el
@@ -3834,10 +3834,8 @@ erc--cmd-reconnect
       (with-suppressed-warnings ((obsolete erc-server-reconnecting)
                                  (obsolete erc-reuse-buffers))
         (if erc-reuse-buffers
-            (progn (cl-assert (not erc--server-reconnecting))
-                   (cl-assert (not erc-server-reconnecting)))
-          (setq erc--server-reconnecting nil
-                erc-server-reconnecting nil)))))
+            (cl-assert (not erc-server-reconnecting))
+          (setq erc-server-reconnecting nil)))))
   t)
 
 (defun erc-cmd-RECONNECT (&rest args)
-- 
2.38.1


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

From 2a2e8d942a55b0fdd9f19008b60359546cfc1a44 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 4/8] Support local ERC modules in erc-mode buffers

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

* etc/ERC-NEWS: Mention changes to `erc-update-modules'.

* lisp/erc/erc.el (erc-migrate-modules): Add some missing mappings.
(erc-modules): When a user removes a module, disable it and kill its
local variable in all ERC buffers.
(erc-update-modules): Move body of `erc-update-modules' to new
internal function.
(erc--update-modules): Add new function, a renamed and slightly
modified version of `erc-update-modules'.  Specifically, change return
value from nil to a list of minor-mode commands for local modules.
Use `custom-variable-p' to detect flavor.
(erc--merge-local-modes): Add helper for finding local modules
already active as minor modes in an ERC buffer.
(erc-open): Replace `erc-update-modules' with `erc--update-modules'.
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'.

* 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.
(erc--assemble-toggle): Add new helper for constructing mode toggles,
like `erc-sasl-enable'.
(define-erc-modules): Defer to `erc--assemble-toggle' to create toggle
commands.
(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 asserting correct
module-name mappings.
(erc--merge-local-modes): Add test for helper.
(define-erc-module--global, define-erc-module--local): Add tests
asserting module-creation macro.  (Bug#57955.)
---
 doc/misc/erc.texi          |  37 ++++++++-
 etc/ERC-NEWS               |   6 ++
 lisp/erc/erc-common.el     |  82 +++++++++++++++----
 lisp/erc/erc-goodies.el    |   1 +
 lisp/erc/erc.el            | 104 ++++++++++++++++---------
 test/lisp/erc/erc-tests.el | 156 +++++++++++++++++++++++++++++++++++++
 6 files changed, 333 insertions(+), 53 deletions(-)

diff --git a/doc/misc/erc.texi b/doc/misc/erc.texi
index 0d807e323e..0e016c6d8f 100644
--- a/doc/misc/erc.texi
+++ b/doc/misc/erc.texi
@@ -390,8 +390,11 @@ 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}.
+When removing a module outside of the Customize ecosystem, you may
+wish to ensure it's disabled by invoking its associated minor-mode
+toggle, such as @kbd{M-x erc-spelling-mode @key{RET}}.  It may also be
+worth noting that, these days, calling @code{erc-update-modules} in an
+init file is typically unnecessary.
 
 The following is a list of available modules.
 
@@ -517,6 +520,36 @@ Modules
 
 @end table
 
+@subheading Local Modules
+
+All modules operate as minor modes under the hood, and some newer ones
+may be defined as buffer-local.  For everyday use, the only practical
+differences are
+
+@enumerate
+@item
+``control variables,'' like @code{erc-sasl-mode}, are stateful across
+IRC sessions and override @code{erc-module} membership when influencing
+module activation in new sessions
+@item
+removing a local module from @code{erc-modules} via Customize not only
+disables its mode but also kills its control variable in all ERC
+buffers
+@item
+``toggle commands,'' like @code{erc-sasl-mode} and
+@code{erc-sasl-enable}, behave differently, both from each other and
+from their global counterparts
+@end enumerate
+
+By default, all local-mode toggles, like @code{erc-sasl-mode}, only
+affect the current buffer, but their ``non-mode'' variants, such as
+@code{erc-sasl-enable}, operate on all buffers belonging to a
+connection when called interactively.  Keep in mind that whether
+enabled or not, a module may effectively be ``inert'' in certain types
+of buffers, such as queries and channels.  Whatever the case, a local
+toggle never mutates @code{erc-modules}.
+
+
 @c PRE5_4: Document every option of every module in its own subnode
 
 
diff --git a/etc/ERC-NEWS b/etc/ERC-NEWS
index f638d4717a..832a9566d7 100644
--- a/etc/ERC-NEWS
+++ b/etc/ERC-NEWS
@@ -125,6 +125,12 @@ The function 'erc-auto-query' was deemed too difficult to reason
 through and has thus been deprecated with no public replacement; it
 has also been removed from the client code path.
 
+The function 'erc-open' now delays running 'erc-mode-hook' members
+until most local session variables have been initialized (minus those
+connection-related ones in erc-backend).  'erc-open' also no longer
+calls 'erc-update-modules', although modules are still activated
+in an identical fashion.
+
 A few internal variables have been introduced that could just as well
 have been made public, possibly as user options.  Likewise for some
 internal functions.  As always, users needing such functionality
diff --git a/lisp/erc/erc-common.el b/lisp/erc/erc-common.el
index 23a1933798..a4046ba9b3 100644
--- a/lisp/erc/erc-common.el
+++ b/lisp/erc/erc-common.el
@@ -88,6 +88,65 @@ 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))
+
+(defun erc--assemble-toggle (localp name ablsym mode val body)
+  (let ((arg (make-symbol "arg")))
+    `(defun ,ablsym ,(if localp `(&optional ,arg) '())
+       ,(concat
+         (if val "Enable" "Disable")
+         " ERC " (symbol-name name) " mode."
+         (when localp
+           "\nWith ARG, do so in all buffers for the current connection."))
+       (interactive ,@(when localp '("p")))
+       ,@(if localp
+             `((when (derived-mode-p 'erc-mode)
+                 (if ,arg
+                     (erc-with-all-buffers-of-server erc-server-process nil
+                       (,ablsym))
+                   (setq ,mode ,val)
+                   ,@body)))
+           `(,(if val
+                  `(cl-pushnew ',(erc--normalize-module-symbol name)
+                               erc-modules)
+                `(setq erc-modules (delq ',(erc--normalize-module-symbol name)
+                                         erc-modules)))
+             (setq ,mode ,val)
+             ,@body)))))
+
 (defmacro define-erc-module (name alias doc enable-body disable-body
                                   &optional local-p)
   "Define a new minor mode using ERC conventions.
@@ -103,6 +162,13 @@ define-erc-module
 an alias erc-ALIAS-mode, as well as the helper functions
 erc-NAME-enable, and erc-NAME-disable.
 
+With LOCAL-P, these helpers take on an optional argument that,
+when non-nil, causes them to act on all buffers of a connection.
+This feature is mainly intended for interactive use and does not
+carry over to their respective minor-mode toggles.  Beware that
+for global modules, these helpers and toggles all mutate
+`erc-modules'.
+
 Example:
 
   ;;;###autoload(autoload \\='erc-replace-mode \"erc-replace\")
@@ -133,20 +199,8 @@ define-erc-module
          (if ,mode
              (,enable)
            (,disable)))
-       (defun ,enable ()
-         ,(format "Enable ERC %S mode."
-                  name)
-         (interactive)
-         (add-to-list 'erc-modules (quote ,name))
-         (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)
+       ,(erc--assemble-toggle local-p name enable mode t enable-body)
+       ,(erc--assemble-toggle local-p name disable mode nil disable-body)
        ,(when (and alias (not (eq name alias)))
           `(defalias
              ',(intern
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 352f72e617..384d92e624 100644
--- a/lisp/erc/erc.el
+++ b/lisp/erc/erc.el
@@ -1791,10 +1791,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
@@ -1813,9 +1810,16 @@ erc-modules
            (dolist (module erc-modules)
              (unless (member module val)
                (let ((f (intern-soft (format "erc-%s-mode" module))))
-                 (when (and (fboundp f) (boundp f) (symbol-value f))
-                   (message "Disabling `erc-%s'" module)
-                   (funcall f 0))))))
+                 (when (and (fboundp f) (boundp f))
+                   (when (symbol-value f)
+                     (message "Disabling `erc-%s'" module)
+                     (funcall f 0))
+                   (unless (or (custom-variable-p f)
+                               (not (fboundp 'erc-buffer-filter)))
+                     (erc-buffer-filter (lambda ()
+                                          (when (symbol-value f)
+                                            (funcall f 0))
+                                          (kill-local-variable f)))))))))
          (set sym val)
          ;; this test is for the case where erc hasn't been loaded yet
          (when (fboundp 'erc-update-modules)
@@ -1873,27 +1877,23 @@ erc-modules
   :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))))))
+  "Enable minor mode for every module in `erc-modules'.
+Except ignore all local modules, which were introduced in ERC 5.5."
+  (erc--update-modules)
+  nil)
+
+(defun erc--update-modules ()
+  (let (local-modes)
+    (dolist (module erc-modules 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 (custom-variable-p mode)
+            (funcall mode 1)
+          (push mode local-modes))))))
 
 (defun erc-setup-buffer (buffer)
   "Consults `erc-join-buffer' to find out how to display `BUFFER'."
@@ -1924,6 +1924,24 @@ erc-setup-buffer
          (display-buffer buffer)
        (switch-to-buffer buffer)))))
 
+(defun erc--merge-local-modes (new-modes old-vars)
+  "Return a cons of two lists, each containing local-module modes.
+In the first, put modes to be enabled in a new ERC buffer by
+calling their associated functions.  In the second, put modes to
+be marked as disabled by setting their associated variables to
+nil."
+  (if old-vars
+      (let ((out (list (reverse new-modes))))
+        (pcase-dolist (`(,k . ,v) old-vars)
+          (when (and (string-prefix-p "erc-" (symbol-name k))
+                     (string-suffix-p "-mode" (symbol-name k)))
+            (if v
+                (cl-pushnew k (car out))
+              (setf (car out) (delq k (car out)))
+              (cl-pushnew k (cdr out)))))
+        (cons (nreverse (car out)) (nreverse (cdr out))))
+    (list new-modes)))
+
 (defun erc-open (&optional server port nick full-name
                            connect passwd tgt-list channel process
                            client-certificate user id)
@@ -1951,18 +1969,25 @@ 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-vars (and (not connect) (buffer-local-variables)))
+         (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))))
     (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--merge-local-modes (erc--update-modules)
+                                  (or erc--server-reconnecting old-vars)))
+
+    (delay-mode-hooks (erc-mode))
+
+    (setq erc-server-reconnect-count old-recon-count)
+
     (when (setq erc-server-connected (not connect))
       (setq erc-server-announced-name
             (buffer-local-value 'erc-server-announced-name old-buffer)))
@@ -2019,14 +2044,21 @@ 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 (and erc--server-reconnecting
+                       (alist-get 'erc-networks--id 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 (car delayed-modules)) (funcall mod +1))
+    (dolist (var (cdr delayed-modules)) (set var nil))
+
     ;; set up prompt
     (unless continued-session
       (goto-char (point-max))
@@ -2038,8 +2070,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 ff5d802697..b185d850a6 100644
--- a/test/lisp/erc/erc-tests.el
+++ b/test/lisp/erc/erc-tests.el
@@ -1178,4 +1178,160 @@ erc-handle-irc-url
     (kill-buffer "baznet")
     (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) '(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)) ; no locals
+        (should (equal (nreverse calls) '( erc-pcomplete (completion . 1)
+                                           erc-join (autojoin . 1)
+                                           erc-networks (networks . 1))))
+        (setq calls nil)))))
+
+(ert-deftest erc--merge-local-modes ()
+
+  (ert-info ("No existing modes")
+    (let ((old '((a) (b . t)))
+          (new '(erc-c-mode erc-d-mode)))
+      (should (equal (erc--merge-local-modes new old)
+                     '((erc-c-mode erc-d-mode))))))
+
+  (ert-info ("Active existing added, inactive existing removed, deduped")
+    (let ((old '((a) (erc-b-mode) (c . t) (erc-d-mode . t) (erc-e-mode . t)))
+          (new '(erc-b-mode erc-d-mode)))
+      (should (equal (erc--merge-local-modes new old)
+                     '((erc-d-mode erc-e-mode) . (erc-b-mode)))))))
+
+(ert-deftest define-erc-module--global ()
+  (let ((global-module '(define-erc-module mname malias
+                          "Some docstring"
+                          ((ignore a) (ignore b))
+                          ((ignore c) (ignore d)))))
+
+    (should (equal (macroexpand global-module)
+                   `(progn
+
+                      (define-minor-mode erc-mname-mode
+                        "Toggle ERC mname mode.
+With a prefix argument ARG, enable mname if ARG is positive,
+and disable it otherwise.  If called from Lisp, enable the mode
+if ARG is omitted or nil.
+Some docstring"
+                        :global t
+                        :group 'erc-mname
+                        (if erc-mname-mode
+                            (erc-mname-enable)
+                          (erc-mname-disable)))
+
+                      (defun erc-mname-enable ()
+                        "Enable ERC mname mode."
+                        (interactive)
+                        (cl-pushnew 'mname erc-modules)
+                        (setq erc-mname-mode t)
+                        (ignore a) (ignore b))
+
+                      (defun erc-mname-disable ()
+                        "Disable ERC mname mode."
+                        (interactive)
+                        (setq erc-modules (delq 'mname erc-modules))
+                        (setq erc-mname-mode nil)
+                        (ignore c) (ignore d))
+
+                      (defalias 'erc-malias-mode #'erc-mname-mode)
+
+                      (put 'erc-mname-mode 'definition-name 'mname)
+                      (put 'erc-mname-enable 'definition-name 'mname)
+                      (put 'erc-mname-disable 'definition-name 'mname))))))
+
+(ert-deftest define-erc-module--local ()
+  (let* ((global-module '(define-erc-module mname malias
+                           "Some docstring"
+                           ((ignore a) (ignore b))
+                           ((ignore c) (ignore d))
+                           'local))
+         (got (macroexpand global-module))
+         (arg-en (cadr (nth 2 (nth 2 got))))
+         (arg-dis (cadr (nth 2 (nth 3 got)))))
+
+    (should (equal got
+                   `(progn
+                      (define-minor-mode erc-mname-mode
+                        "Toggle ERC mname mode.
+With a prefix argument ARG, enable mname if ARG is positive,
+and disable it otherwise.  If called from Lisp, enable the mode
+if ARG is omitted or nil.
+Some docstring"
+                        :global nil
+                        :group 'erc-mname
+                        (if erc-mname-mode
+                            (erc-mname-enable)
+                          (erc-mname-disable)))
+
+                      (defun erc-mname-enable (&optional ,arg-en)
+                        "Enable ERC mname mode.
+With ARG, do so in all buffers for the current connection."
+                        (interactive "p")
+                        (when (derived-mode-p 'erc-mode)
+                          (if ,arg-en
+                              (erc-with-all-buffers-of-server
+                                  erc-server-process nil
+                                (erc-mname-enable))
+                            (setq erc-mname-mode t)
+                            (ignore a) (ignore b))))
+
+                      (defun erc-mname-disable (&optional ,arg-dis)
+                        "Disable ERC mname mode.
+With ARG, do so in all buffers for the current connection."
+                        (interactive "p")
+                        (when (derived-mode-p 'erc-mode)
+                          (if ,arg-dis
+                              (erc-with-all-buffers-of-server
+                                  erc-server-process nil
+                                (erc-mname-disable))
+                            (setq erc-mname-mode nil)
+                            (ignore c) (ignore d))))
+
+                      (defalias 'erc-malias-mode #'erc-mname-mode)
+
+                      (put 'erc-mname-mode 'definition-name 'mname)
+                      (put 'erc-mname-enable 'definition-name 'mname)
+                      (put 'erc-mname-disable 'definition-name 'mname))))))
+
 ;;; erc-tests.el ends here
-- 
2.38.1


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

From 88af1655fe0bc88ace12713c0efde0ad2844a87f 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 5/8] Call erc-login indirectly via new generic wrapper

* lisp/erc/erc-backend (erc--register-connection): Add new internal
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 30b53dfd8e..973616bc37 100644
--- a/lisp/erc/erc-backend.el
+++ b/lisp/erc/erc-backend.el
@@ -643,6 +643,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))
+
 (defvar erc--server-connect-dumb-ipv6-regexp
   ;; Not for validation (gives false positives).
   (rx bot "[" (group (+ (any xdigit digit ":.")) (? "%" (+ alnum))) "]" eot))
@@ -697,7 +701,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.
@@ -897,7 +901,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 #8: 0006-Add-non-IRCv3-SASL-module-to-ERC.patch --]
[-- Type: text/x-patch, Size: 72103 bytes --]

From f359e90c1cffac8004fce100c577d8366fc335d6 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 6/8] 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                             | 151 +++++-
 etc/ERC-NEWS                                  |   7 +-
 lisp/erc/erc-compat.el                        | 104 +++++
 lisp/erc/erc-sasl.el                          | 428 ++++++++++++++++++
 lisp/erc/erc.el                               |   1 +
 test/lisp/erc/erc-sasl-tests.el               | 344 ++++++++++++++
 test/lisp/erc/erc-scenarios-sasl.el           | 144 ++++++
 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 ++
 12 files changed, 1356 insertions(+), 5 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 0e016c6d8f..0c3137999a 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.
 * Integrations::                Integrations available for ERC.
 * Options::                     Options that are available for ERC.
@@ -482,6 +483,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
@@ -559,6 +564,7 @@ Advanced Usage
 
 @menu
 * Connecting::                  Ways of connecting to an IRC server.
+* SASL::                        Authenticating via SASL
 * Sample Configuration::        An example configuration file.
 * Integrations::                Integrations available for ERC.
 * Options::                     Options that are available for ERC.
@@ -631,6 +637,7 @@ Connecting
 parameters, and some, like @code{client-certificate}, will just be
 @code{nil}.
 
+@anchor{ERC client-certificate}
 To use a certificate with @code{erc-tls}, specify the optional
 @var{client-certificate} keyword argument, whose value should be as
 described in the documentation of @code{open-network-stream}: if
@@ -765,7 +772,10 @@ Connecting
 You can manually set another nickname with the /NICK command.
 @end defopt
 
+@anchor{ERC username}
 @subheading User
+@cindex user
+
 @defun erc-compute-user &optional user
 Determine a suitable value to send as the first argument of the
 opening @samp{USER} IRC command by consulting the following sources:
@@ -877,6 +887,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
@@ -889,7 +900,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
@@ -951,6 +963,143 @@ Connecting
 make the most sense, but any reasonably printable object is
 acceptable.
 
+@node SASL
+@section Authenticating via SASL
+@cindex SASL
+
+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 probably won't be needing SASL for the client-to-bouncer
+connection.  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.  To make this work, customize
+@code{erc-sasl-user} and @code{erc-sasl-password} or specify the
+@code{:user} and @code{:password} keyword arguments when invoking
+@code{erc-tls}.  Note that @code{:user} cannot be given interactively.
+@end indentedblock
+
+@var{external} (via Client TLS Certificate):
+
+@indentedblock
+This works in conjunction with the @code{:client-certificate} keyword
+offered by @code{erc-tls}.  Just ensure you've registered your
+fingerprint with the network beforehand.  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}).  These days, there's
+usually a @samp{CERT ADD} command offered by NickServ that can
+register you automatically if you issue it while connected with a
+client cert.  (@pxref{ERC client-certificate}).
+
+Additional considerations:
+@enumerate
+@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 a remote chance your
+server has something else in mind.
+@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 username, typically the same one
+registered with nickname services.  Specify this when your NickServ
+login differs from the @code{:user} you're connecting with.
+(@pxref{ERC username})
+@end defopt
+
+@defopt erc-sasl-password
+As noted elsewhere, the @code{:password} parameter for @code{erc-tls}
+was orignally intended for traditional ``server passwords,'' but these
+aren't really used any more.  As such, this option defaults to
+borrowing that parameter for its own uses, thus allowing you to call
+@code{erc-tls} with @code{:password} set to your NickServ password.
+
+You can also set this to a nonemtpy string, and ERC will send that
+when needed, no questions asked.  If you instead give a non-@code{nil}
+symbol (other than @code{:password}), 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 resolved 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, and it's
+@code{nil} by default.  As a last resort, ERC will prompt you for
+input.
+
+Lastly, 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
+
+@subheading Troubleshooting
+
+@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're struggling, remember that your SASL password is almost
+always your NickServ password.  When in doubt, try restoring all SASL
+options to their defaults and calling @code{erc-tls} with @code{:user}
+set to your NickServ account name and @code{:password} to your
+NickServ password.  If you're still having trouble, please contact us
+(@pxref{Getting Help and Reporting Bugs}).
+
+As you try out different settings, keep in mind that it's best to
+create a fresh session for every change, for example, by calling
+@code{erc-tls} from scratch.  More experienced users may be able to
+get away with cycling @code{erc-sasl-mode} and issuing a
+@samp{/reconnect}, but that's generally not recommended.  Whatever the
+case, you'll probably want to temporarily disable
+@code{erc-server-auto-reconnect} while experimenting.
 
 @node Sample Configuration
 @section Sample Configuration
diff --git a/etc/ERC-NEWS b/etc/ERC-NEWS
index 832a9566d7..3e1b7bca95 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 d23703394b..8b95f8ac81 100644
--- a/lisp/erc/erc-compat.el
+++ b/lisp/erc/erc-compat.el
@@ -273,6 +273,110 @@ erc-compat--auth-source-backend-parser-functions
     auth-source-backend-parser-functions))
 
 
+;;;; 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..c4573b3b56
--- /dev/null
+++ b/lisp/erc/erc-sasl.el
@@ -0,0 +1,428 @@
+;;; 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.
+;;
+;; - 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 :user
+  "Account username to send when authenticating.
+This is also referred to as the authentication identity or
+\"authcid\".  A value of `:user' or `:nick' indicates that the
+corresponding connection parameter on file should be used.  These
+are most often derived from arguments provided to the `erc' and
+`erc-tls' entry points.  In the case of `:nick', a downcased
+version is used."
+  :type '(choice string (const :user) (const :nick)))
+
+(defcustom erc-sasl-password :password
+  "Optional account password to send when authenticating.
+When the value is a string, ERC will use it unconditionally for
+most mechanisms.  Likewise with `:password', except ERC will
+instead use the \"session password\" on file, which often
+originates from the entry-point commands `erc' or `erc-tls'.
+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 prompt for input.
+
+Note that, with `:password', ERC will forgo sending a traditional
+server password via the IRC \"PASS\" command.  Also, when
+`erc-sasl-mechanism' is set to `ecdsa-nist256p-challenge', this
+option should hold the file name of the key."
+  :type '(choice (const nil) (const :password) 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
+includes `erc-sasl-user' for the `:user' field and
+`erc-sasl-password' for the `:host' field, when the latter option
+is a non-nil, non-keyword symbol.  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)
+
+;; 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--get-user ()
+  (pcase (alist-get 'user erc-sasl--options)
+    (:user erc-session-username)
+    (:nick (erc-downcase (erc-current-nick)))
+    (v v)))
+
+(defun erc-sasl--read-password (prompt)
+  "Return configured option or server password.
+PROMPT is passed to `read-passwd' if necessary."
+  (if-let
+      ((found (pcase (alist-get 'password erc-sasl--options)
+                (:password erc-session-password)
+                ((and (pred stringp) v) (unless (string-empty-p v) v))
+                ((and (guard erc-sasl-auth-source-function)
+                      v (let host
+                          (or v (erc-networks--id-given erc-networks--id))))
+                 (apply erc-sasl-auth-source-function
+                        :user (erc-sasl--get-user)
+                        (and host (list :host (symbol-name host))))))))
+      (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 (list name))
+                                     (erc-sasl--get-user)
+                                     "N/A" "N/A"))
+      (sasl-client-set-property client 'authenticator-name
+                                (alist-get 'authzid erc-sasl--options))
+      client)))
+
+(cl-defmethod erc-sasl--create-client ((_ (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 (erc-sasl--get-user))
+         (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 ((_ (eql scram-sha-256)))
+  "Create and return a new SCRAM-SHA-256 client."
+  (when (featurep 'sasl-scram-sha256)
+    (cl-call-next-method)))
+
+(cl-defmethod erc-sasl--create-client ((_ (eql scram-sha-512)))
+  "Create and return a new SCRAM-SHA-512 client."
+  (when (featurep 'sasl-scram-sha256)
+    (cl-call-next-method)))
+
+(cl-defmethod erc-sasl--create-client ((_ (eql ecdsa-nist256p-challenge)))
+  "Create and return a new ECDSA-NIST256P-CHALLENGE client."
+  (let ((keyfile (cdr (assq 'password erc-sasl--options))))
+    ;; Better to signal usage errors now than inside a process filter.
+    (cond ((or (not (stringp keyfile)) (not (file-readable-p keyfile)))
+           (erc-display-error-notice
+            nil "`erc-sasl-password' not accessible as a file")
+           nil)
+          ((not (executable-find "openssl"))
+           (erc-display-error-notice nil "Could not find openssl program")
+           nil)
+          (t
+           (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 ()
+  (setq erc-sasl--state (make-erc-sasl--state))
+  ;; If the previous attempt failed during registration, this may be
+  ;; non-nil and contain erroneous values, but how can we detect that?
+  ;; What if the server dropped the connection for some other reason?
+  (setq erc-sasl--options
+        (or (and erc--server-reconnecting
+                 (alist-get 'erc-sasl--options erc--server-reconnecting))
+            `((user . ,erc-sasl-user)
+              (password . ,erc-sasl-password)
+              (mechanism . ,erc-sasl-mechanism)
+              (authzid . ,erc-sasl-authzid)))))
+
+(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 CAP 3.2-aware WIP implementation.
+  ((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 or unsupported SASL mechanism: %s" mech))
+         (erc-error "Unknown or unsupported SASL mechanism: %s" mech))
+       (setf (erc-sasl--state-client erc-sasl--state) client))))
+  ((remove-hook 'erc-server-AUTHENTICATE-functions
+                #'erc-sasl--authenticate-handler t)
+   (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")))
+  (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 (string-join (cdr (erc-response.command-args 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")
+        (if (and erc-session-password
+                 (eq :password (alist-get 'password erc-sasl--options)))
+            (let (erc-session-password)
+              (erc-login))
+          (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 384d92e624..63093d509b 100644
--- a/lisp/erc/erc.el
+++ b/lisp/erc/erc.el
@@ -1860,6 +1860,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..20a6760083
--- /dev/null
+++ b/test/lisp/erc/erc-sasl-tests.el
@@ -0,0 +1,344 @@
+;;; 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--basic ()
+  (ert-info ("Explicit erc-sasl-password")
+    (let ((erc-sasl--options '((password . "foo"))))
+      (should (string= (erc-sasl--read-password nil) "foo"))))
+
+  (ert-info ("Explicit session password")
+    (let ((erc-session-password "foo")
+          (erc-sasl--options '((password . :password))))
+      (should (string= (erc-sasl--read-password nil) "foo"))))
+
+  (ert-info ("Fallback to prompt skip auth-source")
+    (should-not erc-sasl-auth-source-function)
+    (let ((erc-session-password "bar")
+          (erc-networks--id (erc-networks--id-create nil)))
+      (should (string= (ert-simulate-keys "bar\r"
+                         (erc-sasl--read-password "?"))
+                       "bar"))))
+
+  (ert-info ("Prompt when auth-source fails and `erc-sasl-password' null")
+    (let ((erc-sasl--options '((password)))
+          (erc-sasl-auth-source-function #'ignore))
+      (should (string= (ert-simulate-keys "baz\r"
+                         (erc-sasl--read-password "pwd:"))
+                       "baz")))))
+
+(ert-deftest erc-sasl--read-password--auth-source ()
+  (ert-with-temp-file netrc-file
+    :text (string-join
+           (list
+            ;; If you swap these first 2 lines, *1 below fails
+            "machine FSF.chat port 6697 user bob password sesame"
+            "machine GNU/chat port 6697 user bob password spam"
+            "machine MyHost port irc password 123")
+           "\n")
+    (let* ((auth-sources (list netrc-file))
+           (erc-session-server "irc.gnu.org")
+           (erc-session-port 6697)
+           (erc-networks--id (erc-networks--id-create nil))
+           calls
+           (erc-sasl-auth-source-function
+            (lambda (&rest r)
+              (push r calls)
+              (apply #'erc--auth-source-search r)))
+           erc-server-announced-name ; too early
+           auth-source-do-cache)
+
+      (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"))
+          (should (equal (pop calls) '(:user "bob" :host "FSF.chat")))))
+
+      (ert-info ("ID for :host and `erc-session-username' for :user") ; *1
+        (let ((erc-session-username "bob")
+              (erc-sasl--options '((user . :user) (password)))
+              (erc-networks--id (erc-networks--id-create 'GNU/chat)))
+          (should (string= (erc-sasl--read-password nil) "spam"))
+          (should (equal (pop calls) '(:user "bob" :host "GNU/chat")))))
+
+      (ert-info ("ID for :host and current nick for :user") ; *1
+        (let ((erc-server-current-nick "bob")
+              (erc-sasl--options '((user . :nick) (password)))
+              (erc-networks--id (erc-networks--id-create 'GNU/chat)))
+          (should (string= (erc-sasl--read-password nil) "spam"))
+          (should (equal (pop calls) '(:user "bob" :host "GNU/chat")))))
+
+      (ert-info ("Symbol as password, entry lacks user field")
+        (let ((erc-server-current-nick "fake")
+              (erc-sasl--options '((user . :nick) (password . MyHost)))
+              (erc-networks--id (erc-networks--id-create 'GNU/chat)))
+          (should (string= (erc-sasl--read-password nil) "123"))
+          (should (equal (pop calls) '(:user "fake" :host "MyHost"))))))))
+
+(ert-deftest erc-sasl-create-client--plain ()
+  (let* ((erc-session-password "password123")
+         (erc-session-username "tester")
+         (erc-sasl--options '((user . :user) (password . :password)))
+         (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")
+         (erc-sasl--options '((user . :nick) (password . :password)))
+         (client (erc-sasl--create-client 'external)) ; unused ^
+         (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-sasl--options '((user . "jilles") (password . "sesame")
+                              (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 '((user . :nick) (password . :password)
+                              (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")
+         (erc-sasl--options '((user . :nick) (password . :password) (authzid)))
+         (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")
+         (erc-sasl--options '((user . :nick) (password . :password) (authzid)))
+         (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..6c5e78d0c8
--- /dev/null
+++ b/test/lisp/erc/erc-scenarios-sasl.el
@@ -0,0 +1,144 @@
+;;; 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)))
+
+(require 'erc-sasl)
+
+(ert-deftest erc-scenarios-sasl--plain ()
+  :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))
+       (port (process-contact dumb-server :service))
+       (erc-modules (cons 'sasl erc-modules))
+       (erc-sasl-password "password123")
+       (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)))))
+
+    (ert-info ("Notices received")
+      (with-current-buffer (erc-d-t-wait-for 10 (get-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"))))))
+
+(ert-deftest erc-scenarios-sasl--external ()
+  :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 'external))
+       (port (process-contact dumb-server :service))
+       (erc-modules (cons 'sasl erc-modules))
+       (erc-sasl-mechanism 'external)
+       (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)))))
+
+    (ert-info ("Notices received")
+      (with-current-buffer (erc-d-t-wait-for 10 (get-buffer "ExampleOrg"))
+        (funcall expect 10 "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-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)
+       (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-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-user :nick)
+       (erc-sasl-mechanism mech)
+       (mock-rvs (list "c5RqLCZy0L4fGkKAZ0hujFBs" ""))
+       (sasl-unique-id-function (lambda () (pop mock-rvs)))
+       (expect (erc-d-t-make-expecter)))
+
+    (ert-info ("Connect")
+      (with-current-buffer (erc :server "127.0.0.1"
+                                :port port
+                                :nick "jilles"
+                                :password "sesame"
+                                :full-name "jilles")
+        (should (string= (buffer-name) (format "127.0.0.1:%d" port)))))
+
+    (ert-info ("Notices received")
+      (with-current-buffer (erc-d-t-wait-for 10 (get-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/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


[-- Warning: decoded text below may be mangled, UTF-8 assumed --]
[-- Attachment #9: 0007-Accept-functions-in-place-of-passwords-in-ERC.patch --]
[-- Type: text/x-patch, Size: 11519 bytes --]

From b4de4d2e113690540af9c67045450644bd5b949b 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 7/8] Accept functions in place of passwords in ERC

* lisp/erc/erc-backend.el (erc-session-password): Add comment
explaining type is now string, nil, or function.
* lisp/erc/erc-sasl.el (erc-sasl--read-password): Use `erc--unfun'.
* lisp/erc/erc-services.el (erc-nickserv-get-password,
erc-nickserv-send-identify): Use `erc--unfun'.
* lisp/erc/erc.el (erc--unfun): New function for unwrapping a
password couched in a getter.
(erc--debug-irc-protocol-mask-secrets): Add variable to indicate
whether to mask passwords in debug logs.
(erc--mask-secrets): New function to swap masked secret with question
marks in debug logs.
(erc-log-irc-protocol): Conditionally mask secrets when
`erc--debug-irc-protocol-mask-secrets' is non-nil.
(erc--auth-source-search): Don't unwrap secret from function before
returning.
(erc-server-join-channel, erc-login): Use `erc--unfun'.

* test/lisp/erc/erc-services-tests.el
(erc-services-tests--wrap-search): Add helper for `erc--unfun'.
(erc-services-tests--auth-source-standard,
erc-services-tests--auth-source-announced,
erc-services-tests--auth-source-overrides, erc-nickserv-get-password):
Use `erc--unfun'.
* test/lisp/erc/erc-tests.el (erc--debug-irc-protocol-mask-secrets):
Add test for masking secrets with `erc--unfun' and friends.
---
 lisp/erc/erc-backend.el             |  3 ++-
 lisp/erc/erc-sasl.el                |  2 +-
 lisp/erc/erc-services.el            |  5 ++--
 lisp/erc/erc.el                     | 38 +++++++++++++++++++++++++----
 test/lisp/erc/erc-services-tests.el | 16 +++++++++---
 test/lisp/erc/erc-tests.el          | 22 +++++++++++++++++
 6 files changed, 73 insertions(+), 13 deletions(-)

diff --git a/lisp/erc/erc-backend.el b/lisp/erc/erc-backend.el
index 973616bc37..f387491d4c 100644
--- a/lisp/erc/erc-backend.el
+++ b/lisp/erc/erc-backend.el
@@ -205,7 +205,8 @@ erc-whowas-on-nosuchnick
 ;;;; Variables and options
 
 (defvar-local erc-session-password nil
-  "The password used for the current session.")
+  "The password used for the current session.
+This should be a string or a function returning a string.")
 
 (defvar erc-server-responses (make-hash-table :test #'equal)
   "Hash table mapping server responses to their handler hooks.")
diff --git a/lisp/erc/erc-sasl.el b/lisp/erc/erc-sasl.el
index c4573b3b56..0158161b84 100644
--- a/lisp/erc/erc-sasl.el
+++ b/lisp/erc/erc-sasl.el
@@ -143,7 +143,7 @@ erc-sasl--read-password
                  (apply erc-sasl-auth-source-function
                         :user (erc-sasl--get-user)
                         (and host (list :host (symbol-name host))))))))
-      (copy-sequence found)
+      (copy-sequence (erc--unfun found))
     (read-passwd prompt)))
 
 (defun erc-sasl--plain-response (client steps)
diff --git a/lisp/erc/erc-services.el b/lisp/erc/erc-services.el
index fe9cb5b5f1..48953288d1 100644
--- a/lisp/erc/erc-services.el
+++ b/lisp/erc/erc-services.el
@@ -455,7 +455,7 @@ erc-nickserv-get-password
                   (read-passwd
                    (format "NickServ password for %s on %s (RET to cancel): "
                            nick nid)))))
-       ((not (string-empty-p ret))))
+       ((not (string-empty-p (erc--unfun ret)))))
     ret))
 
 (defvar erc-auto-discard-away)
@@ -477,7 +477,8 @@ erc-nickserv-send-identify
          (msgtype (or (erc-nickserv-alist-ident-command nil nickserv-info)
                       "PRIVMSG")))
     (erc-message msgtype
-                 (concat nickserv " " identify-word " " nick password))))
+                 (concat nickserv " " identify-word " " nick
+                         (erc--unfun password)))))
 
 (defun erc-nickserv-call-identify-function (nickname)
   "Call `erc-nickserv-identify' with NICKNAME."
diff --git a/lisp/erc/erc.el b/lisp/erc/erc.el
index 63093d509b..f18e214d55 100644
--- a/lisp/erc/erc.el
+++ b/lisp/erc/erc.el
@@ -2335,6 +2335,23 @@ erc-debug-irc-protocol
 WARNING: Do not set this variable directly!  Instead, use the
 function `erc-toggle-debug-irc-protocol' to toggle its value.")
 
+(defvar erc--debug-irc-protocol-mask-secrets t
+  "Whether to hide secrets in a debug log.
+They are still visible on screen but are replaced by question
+marks when yanked.")
+
+(defun erc--mask-secrets (string)
+  (when-let* ((eot (length string))
+              (beg (text-property-any 0 eot 'erc-secret t string))
+              (end (text-property-not-all beg eot 'erc-secret t string))
+              (sec (substring string beg end)))
+    (setq string (concat (substring string 0 beg)
+                         (make-string (- end beg) ??)
+                         (substring string end eot)))
+    (put-text-property beg end 'face 'erc-inverse-face string)
+    (put-text-property beg end 'display sec string))
+  string)
+
 (defun erc-log-irc-protocol (string &optional outbound)
   "Append STRING to the buffer *erc-protocol*.
 
@@ -2360,6 +2377,8 @@ erc-log-irc-protocol
                       (format "%s:%s" erc-session-server erc-session-port))))
           (ts (when erc-debug-irc-protocol-time-format
                 (format-time-string erc-debug-irc-protocol-time-format))))
+      (when erc--debug-irc-protocol-mask-secrets
+        (setq string (erc--mask-secrets string)))
       (with-current-buffer (get-buffer-create "*erc-protocol*")
         (save-excursion
           (goto-char (point-max))
@@ -3285,9 +3304,8 @@ erc--auth-source-search
       (setq plist (plist-put plist :max 5000))) ; `auth-source-netrc-parse'
     (unless (plist-get defaults :require)
       (setq plist (plist-put plist :require '(:secret))))
-    (when-let* ((sorted (sort (apply #'auth-source-search plist) test))
-                (secret (plist-get (car sorted) :secret)))
-      (if (functionp secret) (funcall secret) secret))))
+    (when-let* ((sorted (sort (apply #'auth-source-search plist) test)))
+      (plist-get (car sorted) :secret))))
 
 (defun erc-auth-source-search (&rest plist)
   "Call `auth-source-search', possibly with keyword params in PLIST."
@@ -3308,7 +3326,8 @@ erc-server-join-channel
     (setq secret (apply erc-auth-source-join-function
                         `(,@(and server (list :host server)) :user ,channel))))
   (erc-log (format "cmd: JOIN: %s" channel))
-  (erc-server-send (concat "JOIN " channel (and secret (concat " " secret)))))
+  (erc-server-send (concat "JOIN " channel
+                           (and secret (concat " " (erc--unfun secret))))))
 
 (defun erc--valid-local-channel-p (channel)
   "Non-nil when channel is server-local on a network that allows them."
@@ -6344,6 +6363,15 @@ erc-load-irc-script-lines
 
 ;; authentication
 
+(defun erc--unfun (maybe-fn)
+  "Return MAYBE-FN or whatever it returns."
+  (let ((s (if (functionp maybe-fn) (funcall maybe-fn) maybe-fn)))
+    (when (and erc-debug-irc-protocol
+               erc--debug-irc-protocol-mask-secrets
+               (stringp s))
+      (put-text-property 0 (length s) 'erc-secret t s))
+    s))
+
 (defun erc-login ()
   "Perform user authentication at the IRC server."
   (erc-log (format "login: nick: %s, user: %s %s %s :%s"
@@ -6353,7 +6381,7 @@ erc-login
                    erc-session-server
                    erc-session-user-full-name))
   (if erc-session-password
-      (erc-server-send (concat "PASS :" erc-session-password))
+      (erc-server-send (concat "PASS :" (erc--unfun erc-session-password)))
     (message "Logging in without password"))
   (erc-server-send (format "NICK %s" (erc-current-nick)))
   (erc-server-send
diff --git a/test/lisp/erc/erc-services-tests.el b/test/lisp/erc/erc-services-tests.el
index 7ff2e36e77..2547c5e01a 100644
--- a/test/lisp/erc/erc-services-tests.el
+++ b/test/lisp/erc/erc-services-tests.el
@@ -62,9 +62,13 @@ erc--auth-source-determine-params-merge
                            :x ("x")
                            :require (:secret))))))
 
+(defun erc-services-tests--wrap-search (s)
+  (lambda (&rest r) (erc--unfun (apply s r))))
+
 ;; Some of the following may be related to bug#23438.
 
 (defun erc-services-tests--auth-source-standard (search)
+  (setq search (erc-services-tests--wrap-search search))
 
   (ert-info ("Session wins")
     (let ((erc-session-server "irc.gnu.org")
@@ -93,6 +97,7 @@ erc-services-tests--auth-source-standard
       (should (string= (funcall search :user "#chan") "baz")))))
 
 (defun erc-services-tests--auth-source-announced (search)
+  (setq search (erc-services-tests--wrap-search search))
   (let* ((erc--isupport-params (make-hash-table))
          (erc-server-parameters '(("CHANTYPES" . "&#")))
          (erc--target (erc--target-from-string "&chan")))
@@ -124,6 +129,7 @@ erc-services-tests--auth-source-announced
           (should (string= (funcall search :user "#chan") "foo")))))))
 
 (defun erc-services-tests--auth-source-overrides (search)
+  (setq search (erc-services-tests--wrap-search search))
   (let* ((erc-session-server "irc.gnu.org")
          (erc-server-announced-name "my.gnu.org")
          (erc-network 'GNU.chat)
@@ -537,18 +543,20 @@ erc-nickserv-get-password
            (erc-network 'FSF.chat)
            (erc-server-current-nick "tester")
            (erc-networks--id (erc-networks--id-create nil))
-           (erc-session-port 6697))
+           (erc-session-port 6697)
+           (search (erc-services-tests--wrap-search
+                    #'erc-nickserv-get-password)))
 
       (ert-info ("Lookup custom option")
-        (should (string= (erc-nickserv-get-password "alice") "foo")))
+        (should (string= (funcall search "alice") "foo")))
 
       (ert-info ("Auth source")
         (ert-info ("Network")
-          (should (string= (erc-nickserv-get-password "bob") "sesame")))
+          (should (string= (funcall search "bob") "sesame")))
 
         (ert-info ("Network ID")
           (let ((erc-networks--id (erc-networks--id-create 'GNU/chat)))
-            (should (string= (erc-nickserv-get-password "bob") "spam")))))
+            (should (string= (funcall search "bob") "spam")))))
 
       (ert-info ("Read input")
         (should (string=
diff --git a/test/lisp/erc/erc-tests.el b/test/lisp/erc/erc-tests.el
index b185d850a6..4d0d69cd7b 100644
--- a/test/lisp/erc/erc-tests.el
+++ b/test/lisp/erc/erc-tests.el
@@ -530,6 +530,28 @@ erc-ring-previous-command
   (when noninteractive
     (kill-buffer "*#fake*")))
 
+(ert-deftest erc--debug-irc-protocol-mask-secrets ()
+  (should-not erc-debug-irc-protocol)
+  (should erc--debug-irc-protocol-mask-secrets)
+  (with-temp-buffer
+    (setq erc-server-process (start-process "fake" (current-buffer) "true")
+          erc-server-current-nick "tester"
+          erc-session-server "myproxy.localhost"
+          erc-session-port 6667)
+    (let ((inhibit-message noninteractive))
+      (erc-toggle-debug-irc-protocol)
+      (erc-log-irc-protocol
+       (concat "PASS :" (erc--unfun (lambda () "changeme")) "\r\n")
+       'outgoing)
+      (set-process-query-on-exit-flag erc-server-process nil))
+    (with-current-buffer "*erc-protocol*"
+      (goto-char (point-min))
+      (search-forward "\r\n\r\n")
+      (search-forward "myproxy.localhost:6667 >> PASS :????????" (pos-eol)))
+    (when noninteractive
+      (kill-buffer "*erc-protocol*")
+      (should-not erc-debug-irc-protocol))))
+
 (ert-deftest erc-log-irc-protocol ()
   (should-not erc-debug-irc-protocol)
   (with-temp-buffer
-- 
2.38.1


[-- Warning: decoded text below may be mangled, UTF-8 assumed --]
[-- Attachment #10: 0008-Add-test-scenarios-for-local-ERC-modules.patch --]
[-- Type: text/x-patch, Size: 25549 bytes --]

From ab824f5b33ec3977684af25266a658fd44c93b3d Mon Sep 17 00:00:00 2001
From: "F. Jason Park" <jp@neverwas.me>
Date: Sun, 20 Nov 2022 19:01:32 -0800
Subject: [PATCH 8/8] Add test scenarios for local ERC modules

* test/lisp/erc/erc-scenarios-base-local-modules.el: New file.
* test/lisp/erc/resources/base/local-modules/first.eld: New file.
* test/lisp/erc/resources/base/local-modules/fourth.eld: New file
* test/lisp/erc/resources/base/local-modules/second.eld: New file.
* test/lisp/erc/resources/base/local-modules/third.eld: New file.
---
 .../erc/erc-scenarios-base-local-modules.el   | 243 ++++++++++++++++++
 .../resources/base/local-modules/first.eld    |  53 ++++
 .../resources/base/local-modules/fourth.eld   |  53 ++++
 .../resources/base/local-modules/second.eld   |  47 ++++
 .../resources/base/local-modules/third.eld    |  43 ++++
 5 files changed, 439 insertions(+)
 create mode 100644 test/lisp/erc/erc-scenarios-base-local-modules.el
 create mode 100644 test/lisp/erc/resources/base/local-modules/first.eld
 create mode 100644 test/lisp/erc/resources/base/local-modules/fourth.eld
 create mode 100644 test/lisp/erc/resources/base/local-modules/second.eld
 create mode 100644 test/lisp/erc/resources/base/local-modules/third.eld

diff --git a/test/lisp/erc/erc-scenarios-base-local-modules.el b/test/lisp/erc/erc-scenarios-base-local-modules.el
new file mode 100644
index 0000000000..417705de09
--- /dev/null
+++ b/test/lisp/erc/erc-scenarios-base-local-modules.el
@@ -0,0 +1,243 @@
+;;; erc-scenarios-local-modules.el --- Local modules 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:
+
+;;; Commentary:
+
+;; These tests all use `sasl' because, as of ERC 5.5, it's the one
+;; and only local module.
+
+(require 'ert-x)
+(eval-and-compile
+  (let ((load-path (cons (ert-resource-directory) load-path)))
+    (require 'erc-scenarios-common)))
+
+(require 'erc-sasl)
+
+;; This asserts that a local module's options and its inclusion in
+;; (and absence from) `erc-update-modules' can be let-bound.
+
+(ert-deftest erc-scenarios-base-local-modules--reconnect-let ()
+  :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))
+       (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 (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")))))
+
+;; After quitting a session for which `sasl' is enabled, you
+;; disconnect and toggle `erc-sasl-mode' off.  You then reconnect
+;; using an alternate nickname.  You again disconnect and reconnect,
+;; this time immediately, and the mode stays disabled.  Finally, you
+;; once again disconnect, toggle the mode back on, and reconnect.  You
+;; are authenticated successfully, just like in the initial session.
+;;
+;; This is meant to show that a user's local mode settings persist
+;; between sessions.  It also happens to show (in round four, below)
+;; that a server renicking a user on 001 after a 903 is handled just
+;; like a user-initiated renick, although this is not the main thrust.
+
+(ert-deftest erc-scenarios-base-local-modules--mode-persistence ()
+  :tags '(:expensive-test)
+  (erc-scenarios-common-with-cleanup
+      ((erc-scenarios-common-dialog "base/local-modules")
+       (erc-server-flood-penalty 0.1)
+       (dumb-server (erc-d-run "localhost" t 'first 'second 'third 'fourth))
+       (port (process-contact dumb-server :service))
+       (erc-modules (cons 'sasl erc-modules))
+       (expect (erc-d-t-make-expecter))
+       (server-buffer-name (format "127.0.0.1:%d" port)))
+
+    (ert-info ("Round one, initial authentication succeeds as expected")
+      (with-current-buffer (erc :server "127.0.0.1"
+                                :port port
+                                :nick "tester"
+                                :user "tester"
+                                :password "changeme"
+                                :full-name "tester")
+        (should (string= (buffer-name) server-buffer-name))
+        (funcall expect 10 "You are now logged in as tester"))
+
+      (with-current-buffer (erc-d-t-wait-for 10 (get-buffer "foonet"))
+        (funcall expect 10 "This server is in debug mode")
+        (erc-cmd-JOIN "#chan")
+
+        (with-current-buffer (erc-d-t-wait-for 10 (get-buffer "#chan"))
+          (funcall expect 20 "She is Lavinia, therefore must"))
+
+        (erc-cmd-QUIT "")
+        (funcall expect 10 "finished")))
+
+    (ert-info ("Round two, nick rejected, alternate granted")
+      (with-current-buffer "foonet"
+
+        (ert-info ("Toggle mode off, reconnect")
+          (erc-sasl-mode -1)
+          (erc-cmd-RECONNECT))
+
+        (funcall expect 10 "User modes for tester`")
+        (should-not (cdr (erc-scenarios-common-buflist "foonet")))
+        (should (equal (buffer-name) "foonet"))
+        (should-not (cdr (erc-scenarios-common-buflist "#chan")))
+
+        (with-current-buffer "#chan"
+          (funcall expect 10 "Some enigma, some riddle"))
+
+        (erc-cmd-QUIT "")
+        (funcall expect 10 "finished")))
+
+    (ert-info ("Round three, send alternate nick initially")
+      (with-current-buffer "foonet"
+
+        (ert-info ("Keep mode off, reconnect")
+          (should-not erc-sasl-mode)
+          (should (local-variable-p 'erc-sasl-mode))
+          (erc-cmd-RECONNECT))
+
+        (funcall expect 10 "User modes for tester`")
+        (should-not (cdr (erc-scenarios-common-buflist "foonet")))
+        (should (equal (buffer-name) "foonet"))
+        (should-not (cdr (erc-scenarios-common-buflist "#chan")))
+
+        (with-current-buffer "#chan"
+          (funcall expect 10 "Let our reciprocal vows be remembered."))
+
+        (erc-cmd-QUIT "")
+        (funcall expect 10 "finished")))
+
+    (ert-info ("Round four, authenticated successfully again")
+      (with-current-buffer "foonet"
+
+        (ert-info ("Toggle mode on, reconnect")
+          (should-not erc-sasl-mode)
+          (should (local-variable-p 'erc-sasl-mode))
+          (erc-sasl-mode +1)
+          (erc-cmd-RECONNECT))
+
+        (funcall expect 10 "User modes for tester")
+        (should-not (cdr (erc-scenarios-common-buflist "foonet")))
+        (should (equal (buffer-name) "foonet"))
+        (should-not (cdr (erc-scenarios-common-buflist "#chan")))
+
+        (with-current-buffer "#chan"
+          (funcall expect 10 "Well met; good morrow, Titus and Hortensius."))
+
+        (erc-cmd-QUIT "")))))
+
+;; For local modules, the twin toggle commands `erc-FOO-enable' and
+;; `erc-FOO-disable' affect all buffers of a connection, whereas
+;; `erc-FOO-mode' continues to operate only on the current buffer.
+
+(ert-deftest erc-scenarios-base-local-modules--toggle-helpers ()
+  :tags '(:expensive-test)
+  (erc-scenarios-common-with-cleanup
+      ((erc-scenarios-common-dialog "base/local-modules")
+       (erc-server-flood-penalty 0.1)
+       (dumb-server (erc-d-run "localhost" t 'first 'second 'fourth))
+       (port (process-contact dumb-server :service))
+       (erc-modules (cons 'sasl erc-modules))
+       (expect (erc-d-t-make-expecter))
+       (server-buffer-name (format "127.0.0.1:%d" port)))
+
+    (ert-info ("Initial authentication succeeds as expected")
+      (with-current-buffer (erc :server "127.0.0.1"
+                                :port port
+                                :nick "tester"
+                                :user "tester"
+                                :password "changeme"
+                                :full-name "tester")
+        (should (string= (buffer-name) server-buffer-name))
+        (funcall expect 10 "You are now logged in as tester"))
+
+      (with-current-buffer (erc-d-t-wait-for 10 (get-buffer "foonet"))
+        (funcall expect 10 "This server is in debug mode")
+        (erc-cmd-JOIN "#chan")
+
+        (with-current-buffer (erc-d-t-wait-for 10 (get-buffer "#chan"))
+          (funcall expect 20 "She is Lavinia, therefore must"))
+
+        (erc-cmd-QUIT "")
+        (funcall expect 10 "finished")))
+
+    (ert-info ("Disabling works from a target buffer.")
+      (with-current-buffer "#chan"
+        (should erc-sasl-mode)
+        (call-interactively #'erc-sasl-disable)
+        (should-not erc-sasl-mode)
+        (should (local-variable-p 'erc-sasl-mode))
+        (should-not (buffer-local-value 'erc-sasl-mode (get-buffer "foonet")))
+        (erc-cmd-RECONNECT)
+        (with-current-buffer "#chan"
+          (funcall expect 10 "Some enigma, some riddle")
+          (should-not erc-sasl-mode) ; regression
+          (should (local-variable-p 'erc-sasl-mode))))
+
+      (with-current-buffer "foonet"
+        (should (local-variable-p 'erc-sasl-mode))
+        (funcall expect 10 "User modes for tester`")
+        (erc-cmd-QUIT "")
+        (funcall expect 10 "finished")))
+
+    (ert-info ("Enabling works from a target buffer")
+      (with-current-buffer "#chan"
+        (call-interactively #'erc-sasl-enable)
+        (should (local-variable-p 'erc-sasl-mode))
+        (should erc-sasl-mode)
+        (erc-cmd-RECONNECT)
+        (funcall expect 10 "Well met; good morrow, Titus and Hortensius.")
+        (erc-cmd-QUIT ""))
+
+      (with-current-buffer "foonet"
+        (should (local-variable-p 'erc-sasl-mode))
+        (should erc-sasl-mode)
+        (funcall expect 10 "User modes for tester")))))
+
+;;; erc-scenarios-local-modules.el ends here
diff --git a/test/lisp/erc/resources/base/local-modules/first.eld b/test/lisp/erc/resources/base/local-modules/first.eld
new file mode 100644
index 0000000000..f9181a80fb
--- /dev/null
+++ b/test/lisp/erc/resources/base/local-modules/first.eld
@@ -0,0 +1,53 @@
+;; -*- mode: lisp-data; -*-
+((cap 10 "CAP REQ :sasl"))
+((nick 1 "NICK tester"))
+((user 1 "USER tester 0 * :tester"))
+
+((authenticate 5 "AUTHENTICATE PLAIN")
+ (0.0 ":irc.foonet.org CAP * ACK sasl")
+ (0.0 "AUTHENTICATE +"))
+
+((authenticate 5 "AUTHENTICATE AHRlc3RlcgBjaGFuZ2VtZQ==")
+ (0.0 ":irc.foonet.org 900 * * tester :You are now logged in as tester")
+ (0.01 ":irc.foonet.org 903 * :Authentication successful"))
+
+((cap 3.2 "CAP END")
+ (0.0 ":irc.foonet.org 001 tester :Welcome to the foonet IRC Network tester")
+ (0.0 ":irc.foonet.org 002 tester :Your host is irc.foonet.org, running version ergo-v2.8.0")
+ (0.2 ":irc.foonet.org 003 tester :This server was created Sun, 20 Nov 2022 23:10:36 UTC")
+ (0.0 ":irc.foonet.org 004 tester irc.foonet.org ergo-v2.8.0 BERTZios CEIMRUabefhiklmnoqstuv Iabefhkloqv")
+ (0.0 ":irc.foonet.org 005 tester AWAYLEN=390 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.0 ":irc.foonet.org 005 tester MAXLIST=beI:60 MAXTARGETS=4 MODES MONITOR=100 NETWORK=foonet NICKLEN=32 PREFIX=(qaohv)~&@%+ STATUSMSG=~&@%+ TARGMAX=NAMES:1,LIST:1,KICK:,WHOIS:1,USERHOST:10,PRIVMSG:4,TAGMSG:4,NOTICE:4,MONITOR:100 TOPICLEN=390 UTF8MAPPING=rfc8265 UTF8ONLY WHOX :are supported by this server")
+ (0.0 ":irc.foonet.org 005 tester draft/CHATHISTORY=100 :are supported by this server")
+ (0.0 ":irc.foonet.org 251 tester :There are 0 users and 3 invisible on 1 server(s)")
+ (0.0 ":irc.foonet.org 252 tester 0 :IRC Operators online")
+ (0.0 ":irc.foonet.org 253 tester 0 :unregistered connections")
+ (0.0 ":irc.foonet.org 254 tester 1 :channels formed")
+ (0.0 ":irc.foonet.org 255 tester :I have 3 clients and 0 servers")
+ (0.0 ":irc.foonet.org 265 tester 3 3 :Current local users 3, max 3")
+ (0.0 ":irc.foonet.org 266 tester 3 3 :Current global users 3, max 3")
+ (0.0 ":irc.foonet.org 422 tester :MOTD File is missing")
+ (0.0 ":irc.foonet.org 221 tester +i")
+ (0.0 ":irc.foonet.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."))
+
+((mode 10 "MODE tester +i")
+ (0.02 ":irc.foonet.org 221 tester +i"))
+
+((join 10 "JOIN #chan")
+ (0.00 ":tester!~u@u9iqi96sfwk9s.irc JOIN #chan")
+ (0.06 ":irc.foonet.org 353 tester = #chan :@bob alice tester")
+ (0.01 ":irc.foonet.org 366 tester #chan :End of NAMES list")
+ (0.02 ":bob!~u@2fzfcku68ehqa.irc PRIVMSG #chan :tester, welcome!")
+ (0.01 ":alice!~u@2fzfcku68ehqa.irc PRIVMSG #chan :tester, welcome!")
+ (0.04 ":alice!~u@2fzfcku68ehqa.irc PRIVMSG #chan :bob: Either your unparagoned mistress is dead, or she's outprized by a trifle."))
+
+((mode 12 "MODE #chan")
+ (0.00 ":irc.foonet.org 324 tester #chan +nt")
+ (0.02 ":irc.foonet.org 329 tester #chan 1668985854")
+ (0.98 ":bob!~u@2fzfcku68ehqa.irc PRIVMSG #chan :alice: Come, you are a tedious fool: to the purpose. What was done to Elbow's wife, that he hath cause to complain of ? Come me to what was done to her.")
+ (0.01 ":alice!~u@2fzfcku68ehqa.irc PRIVMSG #chan :bob: She is Lavinia, therefore must be lov'd."))
+
+((quit 10 "QUIT :\2ERC\2")
+ (0.02 ":tester!~u@u9iqi96sfwk9s.irc QUIT :Quit"))
+
+((drop 0 DROP))
diff --git a/test/lisp/erc/resources/base/local-modules/fourth.eld b/test/lisp/erc/resources/base/local-modules/fourth.eld
new file mode 100644
index 0000000000..fd6d62b6cc
--- /dev/null
+++ b/test/lisp/erc/resources/base/local-modules/fourth.eld
@@ -0,0 +1,53 @@
+;; -*- mode: lisp-data; -*-
+((cap 10 "CAP REQ :sasl"))
+((nick 10 "NICK tester`"))
+((user 10 "USER tester 0 * :tester"))
+
+((authenticate 10 "AUTHENTICATE PLAIN")
+ (0.0 ":irc.foonet.org CAP * ACK sasl")
+ (0.0 "AUTHENTICATE +"))
+
+((authenticate 10 "AUTHENTICATE AHRlc3RlcgBjaGFuZ2VtZQ==")
+ (0.00 ":irc.foonet.org 900 * * tester :You are now logged in as tester")
+ (0.01 ":irc.foonet.org 903 * :Authentication successful"))
+
+((cap 10 "CAP END")
+ (0.00 ":irc.foonet.org 001 tester :Welcome to the foonet IRC Network tester")
+ (0.01 ":irc.foonet.org 002 tester :Your host is irc.foonet.org, running version ergo-v2.8.0")
+ (0.01 ":irc.foonet.org 003 tester :This server was created Sun, 20 Nov 2022 23:10:36 UTC")
+ (0.01 ":irc.foonet.org 004 tester irc.foonet.org ergo-v2.8.0 BERTZios CEIMRUabefhiklmnoqstuv Iabefhkloqv")
+ (0.13 ":irc.foonet.org 005 tester AWAYLEN=390 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.foonet.org 005 tester MAXLIST=beI:60 MAXTARGETS=4 MODES MONITOR=100 NETWORK=foonet NICKLEN=32 PREFIX=(qaohv)~&@%+ STATUSMSG=~&@%+ TARGMAX=NAMES:1,LIST:1,KICK:,WHOIS:1,USERHOST:10,PRIVMSG:4,TAGMSG:4,NOTICE:4,MONITOR:100 TOPICLEN=390 UTF8MAPPING=rfc8265 UTF8ONLY WHOX :are supported by this server")
+ (0.03 ":irc.foonet.org 005 tester draft/CHATHISTORY=100 :are supported by this server")
+ (0.00 ":irc.foonet.org 251 tester :There are 0 users and 3 invisible on 1 server(s)")
+ (0.00 ":irc.foonet.org 252 tester 0 :IRC Operators online")
+ (0.00 ":irc.foonet.org 253 tester 0 :unregistered connections")
+ (0.00 ":irc.foonet.org 254 tester 1 :channels formed")
+ (0.00 ":irc.foonet.org 255 tester :I have 3 clients and 0 servers")
+ (0.00 ":irc.foonet.org 265 tester 3 3 :Current local users 3, max 3")
+ (0.00 ":irc.foonet.org 266 tester 3 3 :Current global users 3, max 3")
+ (0.03 ":irc.foonet.org 422 tester :MOTD File is missing")
+ (0.02 ":irc.foonet.org 221 tester +i")
+ (0.00 ":irc.foonet.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."))
+
+((mode 10 "MODE tester +i")
+ (0.0 ":irc.foonet.org 221 tester +i"))
+
+((join 10 "JOIN #chan")
+ (0.00 ":tester!~u@u9iqi96sfwk9s.irc JOIN #chan")
+ (0.09 ":irc.foonet.org 353 tester = #chan :alice tester @bob")
+ (0.01 ":irc.foonet.org 366 tester #chan :End of NAMES list")
+ (0.00 ":alice!~u@2fzfcku68ehqa.irc PRIVMSG #chan :tester, welcome!")
+ (0.00 ":bob!~u@2fzfcku68ehqa.irc PRIVMSG #chan :tester, welcome!")
+ (0.03 ":alice!~u@2fzfcku68ehqa.irc PRIVMSG #chan :bob: And both shall cease, without your remedy.")
+ (0.02 ":bob!~u@2fzfcku68ehqa.irc PRIVMSG #chan :alice: Nay, tarry; I'll go along with thee: I can tell thee pretty tales of the duke."))
+
+((mode 10 "MODE #chan")
+ (0.00 ":irc.foonet.org 324 tester #chan +nt")
+ (0.01 ":irc.foonet.org 329 tester #chan 1668985854")
+ (0.03 ":alice!~u@2fzfcku68ehqa.irc PRIVMSG #chan :bob: Do: I'll take the sacrament on't, how and which way you will.")
+ (0.00 ":bob!~u@2fzfcku68ehqa.irc PRIVMSG #chan :alice: Worthy Macbeth, we stay upon your leisure.")
+ (0.00 ":alice!~u@2fzfcku68ehqa.irc PRIVMSG #chan :bob: Well met; good morrow, Titus and Hortensius."))
+
+((quit 10 "QUIT :\2ERC\2")
+ (0.03 ":tester!~u@u9iqi96sfwk9s.irc QUIT :Quit"))
diff --git a/test/lisp/erc/resources/base/local-modules/second.eld b/test/lisp/erc/resources/base/local-modules/second.eld
new file mode 100644
index 0000000000..a96103b2aa
--- /dev/null
+++ b/test/lisp/erc/resources/base/local-modules/second.eld
@@ -0,0 +1,47 @@
+;; -*- mode: lisp-data; -*-
+((pass 10 "PASS :changeme"))
+((nick 1 "NICK tester"))
+((user 1 "USER tester 0 * :tester")
+ (0.0 ":irc.foonet.org 433 * tester :Nickname is reserved by a different account"))
+
+((nick 10 "NICK tester`")
+ (0.01 ":irc.foonet.org FAIL NICK NICKNAME_RESERVED tester :Nickname is reserved by a different account")
+ (0.06 ":irc.foonet.org 001 tester` :Welcome to the foonet IRC Network tester`")
+ (0.01 ":irc.foonet.org 002 tester` :Your host is irc.foonet.org, running version ergo-v2.8.0")
+ (0.01 ":irc.foonet.org 003 tester` :This server was created Sun, 20 Nov 2022 23:10:36 UTC")
+ (0.01 ":irc.foonet.org 004 tester` irc.foonet.org ergo-v2.8.0 BERTZios CEIMRUabefhiklmnoqstuv Iabefhkloqv")
+ (0.01 ":irc.foonet.org 005 tester` AWAYLEN=390 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.foonet.org 005 tester` MAXLIST=beI:60 MAXTARGETS=4 MODES MONITOR=100 NETWORK=foonet NICKLEN=32 PREFIX=(qaohv)~&@%+ STATUSMSG=~&@%+ TARGMAX=NAMES:1,LIST:1,KICK:,WHOIS:1,USERHOST:10,PRIVMSG:4,TAGMSG:4,NOTICE:4,MONITOR:100 TOPICLEN=390 UTF8MAPPING=rfc8265 UTF8ONLY WHOX :are supported by this server")
+ (0.01 ":irc.foonet.org 005 tester` draft/CHATHISTORY=100 :are supported by this server")
+ (0.00 ":irc.foonet.org 251 tester` :There are 0 users and 3 invisible on 1 server(s)")
+ (0.00 ":irc.foonet.org 252 tester` 0 :IRC Operators online")
+ (0.02 ":irc.foonet.org 253 tester` 0 :unregistered connections")
+ (0.00 ":irc.foonet.org 254 tester` 1 :channels formed")
+ (0.00 ":irc.foonet.org 255 tester` :I have 3 clients and 0 servers")
+ (0.00 ":irc.foonet.org 265 tester` 3 3 :Current local users 3, max 3")
+ (0.00 ":irc.foonet.org 266 tester` 3 3 :Current global users 3, max 3")
+ (0.00 ":irc.foonet.org 422 tester` :MOTD File is missing")
+ (0.02 ":irc.foonet.org 221 tester` +i")
+ (0.00 ":irc.foonet.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."))
+
+((mode 12 "MODE tester` +i")
+ (0.0 ":irc.foonet.org 221 tester` +i"))
+
+((join 10 "JOIN #chan")
+ (0.00 ":tester`!~u@u9iqi96sfwk9s.irc JOIN #chan")
+ (0.08 ":irc.foonet.org 353 tester` = #chan :@bob alice tester`")
+ (0.01 ":irc.foonet.org 366 tester` #chan :End of NAMES list")
+ (0.00 ":alice!~u@2fzfcku68ehqa.irc PRIVMSG #chan :tester`, welcome!")
+ (0.01 ":bob!~u@2fzfcku68ehqa.irc PRIVMSG #chan :tester`, welcome!")
+ (0.05 ":bob!~u@2fzfcku68ehqa.irc PRIVMSG #chan :alice: And Jove, for your love, would infringe an oath."))
+
+((mode 10 "MODE #chan")
+ (0.00 ":irc.foonet.org 324 tester` #chan +nt")
+ (0.02 ":irc.foonet.org 329 tester` #chan 1668985854")
+ (0.07 ":alice!~u@2fzfcku68ehqa.irc PRIVMSG #chan :bob: To you that know them not. This to my mother.")
+ (0.00 ":bob!~u@2fzfcku68ehqa.irc PRIVMSG #chan :alice: Some enigma, some riddle: come, thy l'envoy; begin."))
+
+((quit 1 "QUIT :\2ERC\2")
+ (0.03 ":tester`!~u@u9iqi96sfwk9s.irc QUIT"))
+
+((drop 0 DROP))
diff --git a/test/lisp/erc/resources/base/local-modules/third.eld b/test/lisp/erc/resources/base/local-modules/third.eld
new file mode 100644
index 0000000000..060083656a
--- /dev/null
+++ b/test/lisp/erc/resources/base/local-modules/third.eld
@@ -0,0 +1,43 @@
+;; -*- mode: lisp-data; -*-
+((pass 10 "PASS :changeme"))
+((nick 1 "NICK tester`"))
+((user 1 "USER tester 0 * :tester")
+ (0.06 ":irc.foonet.org 001 tester` :Welcome to the foonet IRC Network tester`")
+ (0.01 ":irc.foonet.org 002 tester` :Your host is irc.foonet.org, running version ergo-v2.8.0")
+ (0.01 ":irc.foonet.org 003 tester` :This server was created Sun, 20 Nov 2022 23:10:36 UTC")
+ (0.01 ":irc.foonet.org 004 tester` irc.foonet.org ergo-v2.8.0 BERTZios CEIMRUabefhiklmnoqstuv Iabefhkloqv")
+ (0.01 ":irc.foonet.org 005 tester` AWAYLEN=390 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.foonet.org 005 tester` MAXLIST=beI:60 MAXTARGETS=4 MODES MONITOR=100 NETWORK=foonet NICKLEN=32 PREFIX=(qaohv)~&@%+ STATUSMSG=~&@%+ TARGMAX=NAMES:1,LIST:1,KICK:,WHOIS:1,USERHOST:10,PRIVMSG:4,TAGMSG:4,NOTICE:4,MONITOR:100 TOPICLEN=390 UTF8MAPPING=rfc8265 UTF8ONLY WHOX :are supported by this server")
+ (0.01 ":irc.foonet.org 005 tester` draft/CHATHISTORY=100 :are supported by this server")
+ (0.00 ":irc.foonet.org 251 tester` :There are 0 users and 3 invisible on 1 server(s)")
+ (0.00 ":irc.foonet.org 252 tester` 0 :IRC Operators online")
+ (0.02 ":irc.foonet.org 253 tester` 0 :unregistered connections")
+ (0.00 ":irc.foonet.org 254 tester` 1 :channels formed")
+ (0.00 ":irc.foonet.org 255 tester` :I have 3 clients and 0 servers")
+ (0.00 ":irc.foonet.org 265 tester` 3 3 :Current local users 3, max 3")
+ (0.00 ":irc.foonet.org 266 tester` 3 3 :Current global users 3, max 3")
+ (0.00 ":irc.foonet.org 422 tester` :MOTD File is missing")
+ (0.02 ":irc.foonet.org 221 tester` +i")
+ (0.00 ":irc.foonet.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."))
+
+((mode 12 "MODE tester` +i")
+ (0.0 ":irc.foonet.org 221 tester` +i"))
+
+((join 10 "JOIN #chan")
+ (0.00 ":tester`!~u@u9iqi96sfwk9s.irc JOIN #chan")
+ (0.08 ":irc.foonet.org 353 tester` = #chan :@bob alice tester`")
+ (0.01 ":irc.foonet.org 366 tester` #chan :End of NAMES list")
+ (0.00 ":alice!~u@2fzfcku68ehqa.irc PRIVMSG #chan :tester`, welcome!")
+ (0.01 ":bob!~u@2fzfcku68ehqa.irc PRIVMSG #chan :tester`, welcome!")
+ (0.05 ":bob!~u@2fzfcku68ehqa.irc PRIVMSG #chan :alice: With pomp, with triumph, and with revelling."))
+
+((mode 10 "MODE #chan")
+ (0.00 ":irc.foonet.org 324 tester` #chan +nt")
+ (0.02 ":irc.foonet.org 329 tester` #chan 1668985854")
+ (0.00 ":alice!~u@2fzfcku68ehqa.irc PRIVMSG #chan :bob: No remedy, my lord, when walls are so wilful to hear without warning.")
+ (0.01 ":bob!~u@2fzfcku68ehqa.irc PRIVMSG #chan :alice: Let our reciprocal vows be remembered. You have many opportunities to cut him off; if your will want not, time and place will be fruitfully offered. There is nothing done if he return the conqueror; then am I the prisoner, and his bed my gaol; from the loathed warmth whereof deliver me, and supply the place for your labour."))
+
+((quit 1 "QUIT :\2ERC\2")
+ (0.03 ":tester`!~u@u9iqi96sfwk9s.irc QUIT :Quit"))
+
+((drop 0 DROP))
-- 
2.38.1


^ permalink raw reply related	[flat|nested] 54+ messages in thread

* bug#29108: 25.3; ERC SASL support
       [not found]                             ` <87y1s4mjj6.fsf@neverwas.me>
@ 2022-11-22 14:01                               ` J.P.
       [not found]                               ` <87r0xvks03.fsf@neverwas.me>
  1 sibling, 0 replies; 54+ messages in thread
From: J.P. @ 2022-11-22 14:01 UTC (permalink / raw)
  To: 29108; +Cc: emacs-erc, bandali

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

v14. Revised docs a bit and renamed some compat functions. Added a 900
handler to erc-backend. Added secrets-wrapping for auth-source-pass
results in erc-compat. Ditched hook indirection for AUTHENTICATE
handler.


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

From bef06cab7e2fa695e69e30eb097f44172dbc00e3 Mon Sep 17 00:00:00 2001
From: "F. Jason Park" <jp@neverwas.me>
Date: Tue, 22 Nov 2022 00:34:27 -0800
Subject: [PATCH 0/8] *** NOT A PATCH ***

*** BLURB HERE ***

F. Jason Park (8):
  Add GS2 authorization to sasl-scram-rfc
  Don't set erc-networks--id until network is known
  Make erc--server-reconnecting non-buffer-local
  Support local ERC modules in erc-mode buffers
  Call erc-login indirectly via new generic wrapper
  Add non-IRCv3 SASL module to ERC
  Accept functions in place of passwords in ERC
  Add test scenarios for local ERC modules

 doc/misc/erc.texi                             | 190 +++++++-
 etc/ERC-NEWS                                  |  16 +-
 lisp/erc/erc-backend.el                       |  43 +-
 lisp/erc/erc-common.el                        |  82 +++-
 lisp/erc/erc-compat.el                        |  97 +++-
 lisp/erc/erc-goodies.el                       |   1 +
 lisp/erc/erc-networks.el                      |  53 +--
 lisp/erc/erc-sasl.el                          | 417 ++++++++++++++++++
 lisp/erc/erc-services.el                      |   5 +-
 lisp/erc/erc.el                               | 166 ++++---
 lisp/net/sasl-scram-rfc.el                    |  21 +-
 test/lisp/erc/erc-sasl-tests.el               | 344 +++++++++++++++
 .../erc-scenarios-base-association-nick.el    |  84 ++--
 .../erc/erc-scenarios-base-local-modules.el   | 243 ++++++++++
 test/lisp/erc/erc-scenarios-sasl.el           | 144 ++++++
 test/lisp/erc/erc-services-tests.el           |  16 +-
 test/lisp/erc/erc-tests.el                    | 178 ++++++++
 .../resources/base/local-modules/first.eld    |  53 +++
 .../resources/base/local-modules/fourth.eld   |  53 +++
 .../resources/base/local-modules/second.eld   |  47 ++
 .../resources/base/local-modules/third.eld    |  43 ++
 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 ++
 26 files changed, 2314 insertions(+), 164 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-base-local-modules.el
 create mode 100644 test/lisp/erc/erc-scenarios-sasl.el
 create mode 100644 test/lisp/erc/resources/base/local-modules/first.eld
 create mode 100644 test/lisp/erc/resources/base/local-modules/fourth.eld
 create mode 100644 test/lisp/erc/resources/base/local-modules/second.eld
 create mode 100644 test/lisp/erc/resources/base/local-modules/third.eld
 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 0c3137999a..f86465fed7 100644
--- a/doc/misc/erc.texi
+++ b/doc/misc/erc.texi
@@ -391,11 +391,11 @@ Modules
 
 There is a spiffy customize interface, which may be reached by typing
 @kbd{M-x customize-option @key{RET} erc-modules @key{RET}}.
-When removing a module outside of the Customize ecosystem, you may
-wish to ensure it's disabled by invoking its associated minor-mode
-toggle, such as @kbd{M-x erc-spelling-mode @key{RET}}.  It may also be
-worth noting that, these days, calling @code{erc-update-modules} in an
-init file is typically unnecessary.
+When removing a module outside of the Custom ecosystem, you may wish
+to ensure it's disabled by invoking its associated minor-mode toggle,
+such as @kbd{M-x erc-spelling-mode @key{RET}}.  Note that, these days,
+calling @code{erc-update-modules} in an init file is typically
+unnecessary.
 
 The following is a list of available modules.
 
@@ -526,24 +526,26 @@ Modules
 @end table
 
 @subheading Local Modules
+@cindex local modules
 
 All modules operate as minor modes under the hood, and some newer ones
-may be defined as buffer-local.  For everyday use, the only practical
-differences are
+may be defined as buffer-local.  These so-called ``local modules'' are
+a work in progress and their behavior and interface are subject to
+change.  As of ERC 5.5, the only practical differences are
 
 @enumerate
 @item
-``control variables,'' like @code{erc-sasl-mode}, are stateful across
+``Control variables,'' like @code{erc-sasl-mode}, are stateful across
 IRC sessions and override @code{erc-module} membership when influencing
-module activation in new sessions
+module activation in new sessions.
 @item
-removing a local module from @code{erc-modules} via Customize not only
+Removing a local module from @code{erc-modules} via Customize not only
 disables its mode but also kills its control variable in all ERC
-buffers
+buffers.
 @item
-``toggle commands,'' like @code{erc-sasl-mode} and
-@code{erc-sasl-enable}, behave differently, both from each other and
-from their global counterparts
+``Mode toggles,'' like @code{erc-sasl-mode} and
+@code{erc-sasl-enable}, behave differently relative to each other and
+to their global counterparts.  (More on this just below.)
 @end enumerate
 
 By default, all local-mode toggles, like @code{erc-sasl-mode}, only
diff --git a/etc/ERC-NEWS b/etc/ERC-NEWS
index 3e1b7bca95..d0d84d0a98 100644
--- a/etc/ERC-NEWS
+++ b/etc/ERC-NEWS
@@ -130,6 +130,9 @@ connection-related ones in erc-backend).  'erc-open' also no longer
 calls 'erc-update-modules', although modules are still activated
 in an identical fashion.
 
+Some groundwork has been laid for what may become a new breed of ERC
+module, namely, "connection-local" (or simply "local") modules.
+
 A few internal variables have been introduced that could just as well
 have been made public, possibly as user options.  Likewise for some
 internal functions.  As always, users needing such functionality
diff --git a/lisp/erc/erc-backend.el b/lisp/erc/erc-backend.el
index f387491d4c..43c5faad63 100644
--- a/lisp/erc/erc-backend.el
+++ b/lisp/erc/erc-backend.el
@@ -2335,6 +2335,15 @@ erc-server-322-message
     (erc-display-message parsed 'notice 'active 's671
                          ?n nick ?a securemsg)))
 
+(define-erc-response-handler (900)
+  "Handle a \"RPL_LOGGEDIN\" server command.
+Some servers don't consider this SASL-specific but rather just an
+indication of a server-side state change from logged-out to
+logged-in." nil
+  ;; Whenever ERC starts caring about user accounts, it should record
+  ;; the session as being logged here.
+  (erc-display-message parsed 'notice proc (erc-response.contents parsed)))
+
 (define-erc-response-handler (431 445 446 451 462 463 464 481 483 484 485
                                   491 501 502)
   ;; 431 - No nickname given
diff --git a/lisp/erc/erc-compat.el b/lisp/erc/erc-compat.el
index 8b95f8ac81..66a9a615e3 100644
--- a/lisp/erc/erc-compat.el
+++ b/lisp/erc/erc-compat.el
@@ -252,8 +252,18 @@ erc-compat--29-auth-source-pass-search
   ;; From `auth-source-pass-search'
   (cl-assert (and host (not (eq host t)))
              t "Invalid password-store search: %s %s")
-  (erc-compat--29-auth-source-pass--build-result-many
-   host user port require max))
+  (let ((rv (erc-compat--29-auth-source-pass--build-result-many
+             host user port require max)))
+    (if (and (fboundp 'auth-source--obfuscate)
+             (fboundp 'auth-source--deobfuscate))
+        (let (out)
+          (dolist (e rv out)
+            (when-let* ((s (plist-get e :secret))
+                        (v (auth-source--obfuscate s)))
+              (setf (plist-get e :secret)
+                    (byte-compile (lambda () (auth-source--deobfuscate v)))))
+            (push e out)))
+      rv)))
 
 (defun erc-compat--29-auth-source-pass-backend-parse (entry)
   (when (eq entry 'password-store)
@@ -291,38 +301,17 @@ erc-compat--auth-source-backend-parser-functions
                   (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.
+(defun erc-compat--29-sasl-scram-construct-gs2-header (client)
   (let ((authzid (sasl-client-property client 'authenticator-name)))
     (concat "n," (and authzid "a=") authzid ",")))
 
-(defun erc-compat--sasl-scram-client-first-message (client _step)
+(defun erc-compat--29-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)
+  (concat (erc-compat--29-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
+(defun erc-compat--29-sasl-scram--client-final-message
     (hash-fun block-length hash-length client step)
   (unless (string-match
            "^r=\\([^,]+\\),s=\\([^,]+\\),i=\\([0-9]+\\)\\(?:$\\|,\\)"
@@ -339,7 +328,7 @@ erc-compat--sasl-scram--client-final-message
          (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
+              (erc-compat--29-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
diff --git a/lisp/erc/erc-sasl.el b/lisp/erc/erc-sasl.el
index 0158161b84..9084d873ce 100644
--- a/lisp/erc/erc-sasl.el
+++ b/lisp/erc/erc-sasl.el
@@ -150,7 +150,7 @@ erc-sasl--plain-response
   (let ((sasl-read-passphrase #'erc-sasl--read-password))
     (sasl-plain-response client steps)))
 
-(declare-function erc-compat--sasl-scram--client-final-message "erc-compat"
+(declare-function erc-compat--29-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)
@@ -158,7 +158,7 @@ erc-sasl--scram-sha-hack-client-final-message
   ;; `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)))
+    (apply #'erc-compat--29-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))
@@ -202,15 +202,15 @@ erc-sasl--ecdsa-sign
        ("EXTERNAL"
         ignore)
        ("SCRAM-SHA-1"
-        erc-compat--sasl-scram-client-first-message
+        erc-compat--29-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-compat--29-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-compat--29-sasl-scram-client-first-message
         erc-sasl--scram-sha-512-client-final-message
         erc-sasl--scram-sha-512-authenticate-server)
        ("ECDSA-NIST256P-CHALLENGE"
@@ -303,36 +303,6 @@ erc-sasl--mechanism-offered-p
                        (| 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")
@@ -347,8 +317,6 @@ sasl
 This doesn't solicit or validate a suite of supported mechanisms."
   ;; See bug#49860 for a CAP 3.2-aware WIP implementation.
   ((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)))
@@ -357,15 +325,36 @@ sasl
           nil (format "Unknown or unsupported SASL mechanism: %s" mech))
          (erc-error "Unknown or unsupported SASL mechanism: %s" mech))
        (setf (erc-sasl--state-client erc-sasl--state) client))))
-  ((remove-hook 'erc-server-AUTHENTICATE-functions
-                #'erc-sasl--authenticate-handler t)
-   (kill-local-variable 'erc-sasl--state)
+  ((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)
+  "Begin or resume an SASL session." nil
+  (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)
+    (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 (erc--unfun (base64-encode-string data t))))
+      (erc-server-send (concat "AUTHENTICATE " (or data "+"))))))
 
 (defun erc-sasl--destroy (proc)
   (run-hook-with-args 'erc-quit-hook proc)
@@ -373,7 +362,7 @@ erc-sasl--destroy
   (erc-error "Disconnected from %s; please review SASL settings" proc))
 
 (define-erc-response-handler (902)
-  "Handle a ERR_NICKLOCKED response." nil
+  "Handle an ERR_NICKLOCKED response." nil
   (erc-display-message parsed '(notice error) 'active 's902
                        ?n (car (erc-response.command-args parsed))
                        ?s (erc-response.contents parsed))
@@ -384,7 +373,7 @@ erc-sasl--destroy
   (when erc-sasl-mode
     (unless erc-server-connected
       (erc-server-send "CAP END")))
-  (erc-handle-unknown-server-response proc parsed))
+  (erc-display-message parsed 'notice proc (erc-response.contents parsed)))
 
 (define-erc-response-handler (907)
   "Handle a RPL_SASLALREADY response." nil
diff --git a/lisp/erc/erc.el b/lisp/erc/erc.el
index f18e214d55..268d83dc44 100644
--- a/lisp/erc/erc.el
+++ b/lisp/erc/erc.el
@@ -2346,10 +2346,10 @@ erc--mask-secrets
               (end (text-property-not-all beg eot 'erc-secret t string))
               (sec (substring string beg end)))
     (setq string (concat (substring string 0 beg)
-                         (make-string (- end beg) ??)
+                         (make-string 10 ??)
                          (substring string end eot)))
-    (put-text-property beg end 'face 'erc-inverse-face string)
-    (put-text-property beg end 'display sec string))
+    (put-text-property beg (+ 10 beg) 'face 'erc-inverse-face string)
+    (put-text-property beg (+ 10 beg) 'display sec string))
   string)
 
 (defun erc-log-irc-protocol (string &optional outbound)
diff --git a/test/lisp/erc/erc-sasl-tests.el b/test/lisp/erc/erc-sasl-tests.el
index 20a6760083..64593ca270 100644
--- a/test/lisp/erc/erc-sasl-tests.el
+++ b/test/lisp/erc/erc-sasl-tests.el
@@ -141,7 +141,7 @@ erc-sasl-create-client--scram-sha-1
     (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
+                               `[erc-compat--29-sasl-scram-client-first-message
                                  ,req])
                        (format "%S" step)))
         (should (string= (sasl-step-data step) req))))
@@ -180,7 +180,7 @@ erc-sasl-create-client--scram-sha-256
     (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
+                               `[erc-compat--29-sasl-scram-client-first-message
                                  ,req])
                        (format "%S" step)))
         (should (string= (sasl-step-data step) req))))
@@ -220,7 +220,7 @@ erc-sasl-create-client--scram-sha-256--no-authzid
     (ert-info ("Client's initial request")
       (let ((req "n,,n=jilles,r=c5RqLCZy0L4fGkKAZ0hujFBs"))
         (should (equal (format "%S"
-                               `[erc-compat--sasl-scram-client-first-message
+                               `[erc-compat--29-sasl-scram-client-first-message
                                  ,req])
                        (format "%S" step)))
         (should (string= (sasl-step-data step) req))))
@@ -260,7 +260,7 @@ erc-sasl-create-client--scram-sha-512--no-authzid
     (ert-info ("Client's initial request")
       (let ((req "n,,n=jilles,r=c5RqLCZy0L4fGkKAZ0hujFBs"))
         (should (equal (format "%S"
-                               `[erc-compat--sasl-scram-client-first-message
+                               `[erc-compat--29-sasl-scram-client-first-message
                                  ,req])
                        (format "%S" step)))
         (should (string= (sasl-step-data step) req))))
-- 
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 ae7384cdfe4da8cc9352b18e99ebf1c79e4f21ca 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/8] 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: 15693 bytes --]

From 92a006069823e7d13625f886ab5081f9d6e15f59 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/8] 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.  Add branch condition to reload ID on non-nil case.
(erc-networks-on-MOTD-end): Let init-ID function handle renaming of
server buffer.

* 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.
(erc-set-current-nick): When connected, reload network ID.  Leave
comment warning that it may be unneeded.

* lisp/erc/erc-backend.el (erc-server-NICK, erc-server-433): Unless
already connected, schedule ID reload when server rejects or mandates
a nick change.

* test/lisp/erc/erc-scenarios-base-association-nick.el
(erc-scenarios-base-association-nick-bumped,
erc-scenarios-base-association-nick-bumped-mandated-renick): Update to
reflect more liberal association behavior when renamed by IRCd.
---
 lisp/erc/erc-backend.el                       |  6 +-
 lisp/erc/erc-networks.el                      | 53 +++++-------
 lisp/erc/erc.el                               | 21 +++--
 .../erc-scenarios-base-association-nick.el    | 84 +++++++++++--------
 4 files changed, 90 insertions(+), 74 deletions(-)

diff --git a/lisp/erc/erc-backend.el b/lisp/erc/erc-backend.el
index 15fd6ac50f..f899b866f0 100644
--- a/lisp/erc/erc-backend.el
+++ b/lisp/erc/erc-backend.el
@@ -1619,7 +1619,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)
@@ -1629,6 +1629,8 @@ define-erc-response-handler
          'NICK-you ?n nick ?N nn)
         (run-hook-with-args 'erc-nick-changed-functions nn nick))
        (t
+        (when erc-server-connected
+          (erc-networks--id-reload erc-networks--id proc parsed))
         (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))))))
@@ -2255,6 +2257,8 @@ erc-server-322-message
 
 (define-erc-response-handler (433)
   "Login-time \"nick in use\"." nil
+  (when erc-server-connected
+    (erc-networks--id-reload erc-networks--id proc parsed))
   (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 b3e5fcf1a3..19a7ab8643 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))
@@ -1382,7 +1374,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)
@@ -1397,16 +1390,17 @@ erc-networks--update-server-identity
 ;; server buffer, whereas `erc-networks--rename-server-buffer' can run
 ;; mid-session, after an identity's core components have changed.
 
-(defun erc-networks--init-identity (_proc _parsed)
+(defun erc-networks--init-identity (proc parsed)
   "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))
-  ;; Find duplicate identities or other conflicting ones and act
-  ;; accordingly.
-  (erc-networks--update-server-identity)
-  ;;
+  (if erc-networks--id
+      (erc-networks--id-reload erc-networks--id proc parsed)
+    (setq erc-networks--id (erc-networks--id-create nil))
+    ;; Find duplicate identities or other conflicting ones and act
+    ;; accordingly.
+    (erc-networks--update-server-identity)
+    (erc-networks--rename-server-buffer proc parsed))
   nil)
 
 (defun erc-networks--rename-server-buffer (new-proc &optional _parsed)
@@ -1474,8 +1468,7 @@ erc-networks-on-MOTD-end
   ;; For now, retain compatibility with erc-server-NNN-functions.
   (or (erc-networks--ensure-announced proc parsed)
       (erc-networks--set-name proc parsed)
-      (erc-networks--init-identity proc parsed)
-      (erc-networks--rename-server-buffer proc parsed)))
+      (erc-networks--init-identity proc parsed)))
 
 (define-erc-module networks nil
   "Provide data about IRC networks."
diff --git a/lisp/erc/erc.el b/lisp/erc/erc.el
index 2312246e3e..1052c8c4c0 100644
--- a/lisp/erc/erc.el
+++ b/lisp/erc/erc.el
@@ -2017,10 +2017,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
@@ -3179,7 +3181,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
@@ -5904,7 +5907,13 @@ erc-set-current-nick
   (with-current-buffer (if (buffer-live-p (erc-server-buffer))
                            (erc-server-buffer)
                          (current-buffer))
-    (setq erc-server-current-nick nick)))
+    (unless (equal erc-server-current-nick nick)
+      (setq erc-server-current-nick nick)
+      ;; This seems sensible but may well be superfluous.  Should
+      ;; really prove that it's actually needed via test scenario.
+      (when erc-server-connected
+        (erc-networks--id-reload erc-networks--id)))
+    nick))
 
 (defun erc-current-nick ()
   "Return the current nickname."
diff --git a/test/lisp/erc/erc-scenarios-base-association-nick.el b/test/lisp/erc/erc-scenarios-base-association-nick.el
index 3e848be4df..b46c996bc0 100644
--- a/test/lisp/erc/erc-scenarios-base-association-nick.el
+++ b/test/lisp/erc/erc-scenarios-base-association-nick.el
@@ -25,13 +25,24 @@
 
 (eval-when-compile (require 'erc-join))
 
-;; You register a new nick, disconnect, and log back in, but your nick
-;; is not granted, so ERC obtains a backtick'd version.  You open a
-;; query buffer for NickServ, and ERC names it using the net-ID (which
-;; includes the backtick'd nick) as a suffix.  The original
-;; (disconnected) NickServ buffer gets renamed with *its* net-ID as
-;; well.  You then identify to NickServ, and the dead session is no
-;; longer considered distinct.
+;; You register a new nick in a dedicated query buffer, disconnect,
+;; and log back in, but your nick is not granted (maybe you just
+;; turned off SASL).  In any case, ERC obtains a backtick'd version.
+;; You open a query buffer for NickServ, and ERC gives you the
+;; existing one.  And after you identify, all buffers retain their
+;; names, although your net ID has changed internally.
+;;
+;; If ERC would've instead failed (or intentionally refused) to make
+;; the association, you would've ended up with a new NickServ buffer
+;; named after the new net ID as a suffix (based on the backtick'd
+;; nick), for example, NickServ@foonet/tester`.  And the original
+;; (disconnected) NickServ buffer would've gotten suffixed with *its*
+;; net-ID as well, e.g., NickServ@foonet/tester.  And after
+;; identifying, you would've seen ERC merge the two as well as their
+;; server buffers.  While this alternate behavior may arguably be a
+;; more honest reflection of reality, it's also quite inconvenient.
+;; For a clearer example, see the original version of this file
+;; introduced by "Add user-oriented test scenarios for ERC".
 
 (ert-deftest erc-scenarios-base-association-nick-bumped ()
   :tags '(:expensive-test)
@@ -67,30 +78,29 @@ erc-scenarios-base-association-nick-bumped
           (funcall expect 5 "ERC finished"))))
 
     (with-current-buffer "foonet"
-      (erc-cmd-RECONNECT))
+      (erc-cmd-RECONNECT)
+      (funcall expect 10 "User modes for tester`"))
 
-    (erc-d-t-wait-for 10 "Nick request rejection prevents reassociation (good)"
-      (get-buffer "foonet/tester`"))
+    (ert-info ("Server buffer reassociated with new nick")
+      (should-not (get-buffer "foonet/tester`")))
 
     (ert-info ("Ask NickServ to change nick")
-      (with-current-buffer "foonet/tester`"
-        (funcall expect 3 "already in use")
+      (with-current-buffer "foonet"
         (funcall expect 3 "debug mode")
         (erc-cmd-QUERY "NickServ"))
 
-      (erc-d-t-wait-for 1 "Dead NickServ query buffer renamed, now qualified"
-        (get-buffer "NickServ@foonet/tester"))
+      (ert-info ( "NickServ buffer reassociated")
+        (should-not (get-buffer "NickServ@foonet/tester`"))
+        (should-not (get-buffer "NickServ@foonet/tester")))
 
-      (with-current-buffer "NickServ@foonet/tester`" ; new one
+      (with-current-buffer "NickServ" ; new one
         (erc-scenarios-common-say "IDENTIFY tester changeme")
-        (funcall expect 5 "You're now logged in as tester")
-        (ert-info ("Original buffer found, reused")
-          (erc-d-t-wait-for 2 (equal (buffer-name) "NickServ")))))
+        (funcall expect 5 "You're now logged in as tester")))
 
-    (ert-info ("Ours is the only NickServ buffer that remains")
+    (ert-info ("Still just one NickServ buffer")
       (should-not (cdr (erc-scenarios-common-buflist "NickServ"))))
 
-    (ert-info ("Visible network ID truncated to one component")
+    (ert-info ("As well as one server buffer")
       (should (not (get-buffer "foonet/tester`")))
       (should (not (get-buffer "foonet/tester")))
       (should (get-buffer "foonet")))))
@@ -135,29 +145,29 @@ erc-scenarios-base-association-nick-bumped-mandated-renick
     ;; Since we use reconnect, a new buffer won't be created
     ;; TODO add variant with clean `erc' invocation
     (with-current-buffer "foonet"
-      (erc-cmd-RECONNECT))
+      (erc-cmd-RECONNECT)
+      (funcall expect 10 "User modes for dummy"))
 
-    (ert-info ("Server-initiated renick")
-      (with-current-buffer (erc-d-t-wait-for 10 (get-buffer "foonet/dummy"))
-        (should-not (get-buffer "foonet/tester"))
-        (funcall expect 15 "debug mode"))
+    (ert-info ("Server-initiated renick associated correctly")
+      (with-current-buffer "foonet"
+        (funcall expect 15 "debug mode")
+        (should-not (get-buffer "foonet/dummy"))
+        (should-not (get-buffer "foonet/tester")))
 
-      (erc-d-t-wait-for 1 "Old query renamed, now qualified"
-        (get-buffer "bob@foonet/tester"))
+      (ert-info ("Old query reassociated")
+        (should (get-buffer "bob"))
+        (should-not (get-buffer "bob@foonet/tester"))
+        (should-not (get-buffer "bob@foonet/dummy")))
 
-      (with-current-buffer (erc-d-t-wait-for 5 (get-buffer "bob@foonet/dummy"))
+      (with-current-buffer "foonet"
         (erc-cmd-NICK "tester")
-        (ert-info ("Buffers combined")
-          (erc-d-t-wait-for 2 (equal (buffer-name) "bob")))))
+        (funcall expect 5 "You're now logged in as tester")))
 
-    (with-current-buffer "foonet"
-      (funcall expect 5 "You're now logged in as tester"))
-
-    (ert-info ("Ours is the only bob buffer that remains")
+    (ert-info ("Ours is still the only bob buffer that remains")
       (should-not (cdr (erc-scenarios-common-buflist "bob"))))
 
-    (ert-info ("Visible network ID truncated to one component")
-      (should (not (get-buffer "foonet/dummy")))
-      (should (get-buffer "foonet")))))
+    (ert-info ("Visible network ID still truncated to one component")
+      (should (not (get-buffer "foonet/tester")))
+      (should (not (get-buffer "foonet/dummy"))))))
 
 ;;; erc-scenarios-base-association-nick.el ends here
-- 
2.38.1


[-- Warning: decoded text below may be mangled, UTF-8 assumed --]
[-- Attachment #5: 0003-Make-erc-server-reconnecting-non-buffer-local.patch --]
[-- Type: text/x-patch, Size: 4014 bytes --]

From 1fdfe6aedc5a6ae72583783667985c0ef2e5682b Mon Sep 17 00:00:00 2001
From: "F. Jason Park" <jp@neverwas.me>
Date: Fri, 18 Nov 2022 22:42:15 -0800
Subject: [PATCH 3/8] Make erc--server-reconnecting non-buffer-local

* lisp/erc/erc-backend.el (erc--server-reconnecting): Mention expected
non-nil value type in doc string.
(erc-server-connect): Don't set `erc--server-reconnecting'.
(erc-server--reconnect): Let-bind `erc--server-reconnecting' instead
of setting it locally in the server buffer.  Set it to an alist
containing the current buffer's local variables.
(erc-process-sentinel-2): Don't set `erc--server-reconnect'.
* lisp/erc/erc.el (erc--cmd-reconnect): Clean up some assertions.
(Bug#57955.)
---
 lisp/erc/erc-backend.el | 17 ++++++++++-------
 lisp/erc/erc.el         |  6 ++----
 2 files changed, 12 insertions(+), 11 deletions(-)

diff --git a/lisp/erc/erc-backend.el b/lisp/erc/erc-backend.el
index f899b866f0..30b53dfd8e 100644
--- a/lisp/erc/erc-backend.el
+++ b/lisp/erc/erc-backend.el
@@ -311,8 +311,13 @@ erc-server-reconnecting
 (make-obsolete-variable 'erc-server-reconnecting
                         "see `erc--server-reconnecting'" "29.1")
 
-(defvar-local erc--server-reconnecting nil
-  "Non-nil when reconnecting.")
+(defvar erc--server-reconnecting nil
+  "An alist of buffer-local vars and their values when reconnecting.
+This is for the benefit of local modules and `erc-mode-hook'
+members so they can access buffer-local data from the previous
+session when reconnecting.  Once `erc-reuse-buffers' is retired
+and fully removed, modules can switch to leveraging the
+`permanent-local' property instead.")
 
 (defvar-local erc-server-timed-out nil
   "Non-nil if the IRC server failed to respond to a ping.")
@@ -664,7 +669,6 @@ erc-server-connect
       (setq erc-server-process process)
       (setq erc-server-quitting nil)
       (setq erc-server-reconnecting nil
-            erc--server-reconnecting nil
             erc--server-reconnect-timer nil)
       (setq erc-server-timed-out nil)
       (setq erc-server-banned nil)
@@ -706,11 +710,11 @@ erc-server-reconnect
     (with-current-buffer buffer
       (erc-update-mode-line)
       (erc-set-active-buffer (current-buffer))
-      (setq erc--server-reconnecting t)
       (setq erc-server-last-sent-time 0)
       (setq erc-server-lines-sent 0)
       (let ((erc-server-connect-function (or erc-session-connector
-                                             #'erc-open-network-stream)))
+                                             #'erc-open-network-stream))
+            (erc--server-reconnecting (buffer-local-variables)))
         (erc-open erc-session-server erc-session-port erc-server-current-nick
                   erc-session-user-full-name t erc-session-password
                   nil nil nil erc-session-client-certificate
@@ -824,8 +828,7 @@ erc-process-sentinel-2
         (if (not reconnect-p)
             ;; terminate, do not reconnect
             (progn
-              (setq erc--server-reconnecting nil
-                    erc--server-reconnect-timer nil)
+              (setq erc--server-reconnect-timer nil)
               (erc-display-message nil 'error (current-buffer)
                                    'terminated ?e event)
               (set-buffer-modified-p nil))
diff --git a/lisp/erc/erc.el b/lisp/erc/erc.el
index 1052c8c4c0..352f72e617 100644
--- a/lisp/erc/erc.el
+++ b/lisp/erc/erc.el
@@ -3834,10 +3834,8 @@ erc--cmd-reconnect
       (with-suppressed-warnings ((obsolete erc-server-reconnecting)
                                  (obsolete erc-reuse-buffers))
         (if erc-reuse-buffers
-            (progn (cl-assert (not erc--server-reconnecting))
-                   (cl-assert (not erc-server-reconnecting)))
-          (setq erc--server-reconnecting nil
-                erc-server-reconnecting nil)))))
+            (cl-assert (not erc-server-reconnecting))
+          (setq erc-server-reconnecting nil)))))
   t)
 
 (defun erc-cmd-RECONNECT (&rest args)
-- 
2.38.1


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

From 2ca3775e7dda024a43f1e45de38a92b216cec04b 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 4/8] Support local ERC modules in erc-mode buffers

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

* etc/ERC-NEWS: Mention changes to `erc-update-modules'.

* lisp/erc/erc.el (erc-migrate-modules): Add some missing mappings.
(erc-modules): When a user removes a module, disable it and kill its
local variable in all ERC buffers.
(erc-update-modules): Move body of `erc-update-modules' to new
internal function.
(erc--update-modules): Add new function, a renamed and slightly
modified version of `erc-update-modules'.  Specifically, change return
value from nil to a list of minor-mode commands for local modules.
Use `custom-variable-p' to detect flavor.
(erc--merge-local-modes): Add helper for finding local modules
already active as minor modes in an ERC buffer.
(erc-open): Replace `erc-update-modules' with `erc--update-modules'.
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'.

* 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.
(erc--assemble-toggle): Add new helper for constructing mode toggles,
like `erc-sasl-enable'.
(define-erc-modules): Defer to `erc--assemble-toggle' to create toggle
commands.
(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 asserting correct
module-name mappings.
(erc--merge-local-modes): Add test for helper.
(define-erc-module--global, define-erc-module--local): Add tests
asserting module-creation macro.  (Bug#57955.)
---
 doc/misc/erc.texi          |  39 +++++++++-
 etc/ERC-NEWS               |   9 +++
 lisp/erc/erc-common.el     |  82 +++++++++++++++----
 lisp/erc/erc-goodies.el    |   1 +
 lisp/erc/erc.el            | 104 ++++++++++++++++---------
 test/lisp/erc/erc-tests.el | 156 +++++++++++++++++++++++++++++++++++++
 6 files changed, 338 insertions(+), 53 deletions(-)

diff --git a/doc/misc/erc.texi b/doc/misc/erc.texi
index 0d807e323e..b9c6e33d36 100644
--- a/doc/misc/erc.texi
+++ b/doc/misc/erc.texi
@@ -390,8 +390,11 @@ 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}.
+When removing a module outside of the Custom ecosystem, you may wish
+to ensure it's disabled by invoking its associated minor-mode toggle,
+such as @kbd{M-x erc-spelling-mode @key{RET}}.  Note that, these days,
+calling @code{erc-update-modules} in an init file is typically
+unnecessary.
 
 The following is a list of available modules.
 
@@ -517,6 +520,38 @@ Modules
 
 @end table
 
+@subheading Local Modules
+@cindex local modules
+
+All modules operate as minor modes under the hood, and some newer ones
+may be defined as buffer-local.  These so-called ``local modules'' are
+a work in progress and their behavior and interface are subject to
+change.  As of ERC 5.5, the only practical differences are
+
+@enumerate
+@item
+``Control variables,'' like @code{erc-sasl-mode}, are stateful across
+IRC sessions and override @code{erc-module} membership when influencing
+module activation in new sessions.
+@item
+Removing a local module from @code{erc-modules} via Customize not only
+disables its mode but also kills its control variable in all ERC
+buffers.
+@item
+``Mode toggles,'' like @code{erc-sasl-mode} and
+@code{erc-sasl-enable}, behave differently relative to each other and
+to their global counterparts.  (More on this just below.)
+@end enumerate
+
+By default, all local-mode toggles, like @code{erc-sasl-mode}, only
+affect the current buffer, but their ``non-mode'' variants, such as
+@code{erc-sasl-enable}, operate on all buffers belonging to a
+connection when called interactively.  Keep in mind that whether
+enabled or not, a module may effectively be ``inert'' in certain types
+of buffers, such as queries and channels.  Whatever the case, a local
+toggle never mutates @code{erc-modules}.
+
+
 @c PRE5_4: Document every option of every module in its own subnode
 
 
diff --git a/etc/ERC-NEWS b/etc/ERC-NEWS
index f638d4717a..15f7fe84dd 100644
--- a/etc/ERC-NEWS
+++ b/etc/ERC-NEWS
@@ -125,6 +125,15 @@ The function 'erc-auto-query' was deemed too difficult to reason
 through and has thus been deprecated with no public replacement; it
 has also been removed from the client code path.
 
+The function 'erc-open' now delays running 'erc-mode-hook' members
+until most local session variables have been initialized (minus those
+connection-related ones in erc-backend).  'erc-open' also no longer
+calls 'erc-update-modules', although modules are still activated
+in an identical fashion.
+
+Some groundwork has been laid for what may become a new breed of ERC
+module, namely, "connection-local" (or simply "local") modules.
+
 A few internal variables have been introduced that could just as well
 have been made public, possibly as user options.  Likewise for some
 internal functions.  As always, users needing such functionality
diff --git a/lisp/erc/erc-common.el b/lisp/erc/erc-common.el
index 23a1933798..a4046ba9b3 100644
--- a/lisp/erc/erc-common.el
+++ b/lisp/erc/erc-common.el
@@ -88,6 +88,65 @@ 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))
+
+(defun erc--assemble-toggle (localp name ablsym mode val body)
+  (let ((arg (make-symbol "arg")))
+    `(defun ,ablsym ,(if localp `(&optional ,arg) '())
+       ,(concat
+         (if val "Enable" "Disable")
+         " ERC " (symbol-name name) " mode."
+         (when localp
+           "\nWith ARG, do so in all buffers for the current connection."))
+       (interactive ,@(when localp '("p")))
+       ,@(if localp
+             `((when (derived-mode-p 'erc-mode)
+                 (if ,arg
+                     (erc-with-all-buffers-of-server erc-server-process nil
+                       (,ablsym))
+                   (setq ,mode ,val)
+                   ,@body)))
+           `(,(if val
+                  `(cl-pushnew ',(erc--normalize-module-symbol name)
+                               erc-modules)
+                `(setq erc-modules (delq ',(erc--normalize-module-symbol name)
+                                         erc-modules)))
+             (setq ,mode ,val)
+             ,@body)))))
+
 (defmacro define-erc-module (name alias doc enable-body disable-body
                                   &optional local-p)
   "Define a new minor mode using ERC conventions.
@@ -103,6 +162,13 @@ define-erc-module
 an alias erc-ALIAS-mode, as well as the helper functions
 erc-NAME-enable, and erc-NAME-disable.
 
+With LOCAL-P, these helpers take on an optional argument that,
+when non-nil, causes them to act on all buffers of a connection.
+This feature is mainly intended for interactive use and does not
+carry over to their respective minor-mode toggles.  Beware that
+for global modules, these helpers and toggles all mutate
+`erc-modules'.
+
 Example:
 
   ;;;###autoload(autoload \\='erc-replace-mode \"erc-replace\")
@@ -133,20 +199,8 @@ define-erc-module
          (if ,mode
              (,enable)
            (,disable)))
-       (defun ,enable ()
-         ,(format "Enable ERC %S mode."
-                  name)
-         (interactive)
-         (add-to-list 'erc-modules (quote ,name))
-         (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)
+       ,(erc--assemble-toggle local-p name enable mode t enable-body)
+       ,(erc--assemble-toggle local-p name disable mode nil disable-body)
        ,(when (and alias (not (eq name alias)))
           `(defalias
              ',(intern
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 352f72e617..384d92e624 100644
--- a/lisp/erc/erc.el
+++ b/lisp/erc/erc.el
@@ -1791,10 +1791,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
@@ -1813,9 +1810,16 @@ erc-modules
            (dolist (module erc-modules)
              (unless (member module val)
                (let ((f (intern-soft (format "erc-%s-mode" module))))
-                 (when (and (fboundp f) (boundp f) (symbol-value f))
-                   (message "Disabling `erc-%s'" module)
-                   (funcall f 0))))))
+                 (when (and (fboundp f) (boundp f))
+                   (when (symbol-value f)
+                     (message "Disabling `erc-%s'" module)
+                     (funcall f 0))
+                   (unless (or (custom-variable-p f)
+                               (not (fboundp 'erc-buffer-filter)))
+                     (erc-buffer-filter (lambda ()
+                                          (when (symbol-value f)
+                                            (funcall f 0))
+                                          (kill-local-variable f)))))))))
          (set sym val)
          ;; this test is for the case where erc hasn't been loaded yet
          (when (fboundp 'erc-update-modules)
@@ -1873,27 +1877,23 @@ erc-modules
   :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))))))
+  "Enable minor mode for every module in `erc-modules'.
+Except ignore all local modules, which were introduced in ERC 5.5."
+  (erc--update-modules)
+  nil)
+
+(defun erc--update-modules ()
+  (let (local-modes)
+    (dolist (module erc-modules 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 (custom-variable-p mode)
+            (funcall mode 1)
+          (push mode local-modes))))))
 
 (defun erc-setup-buffer (buffer)
   "Consults `erc-join-buffer' to find out how to display `BUFFER'."
@@ -1924,6 +1924,24 @@ erc-setup-buffer
          (display-buffer buffer)
        (switch-to-buffer buffer)))))
 
+(defun erc--merge-local-modes (new-modes old-vars)
+  "Return a cons of two lists, each containing local-module modes.
+In the first, put modes to be enabled in a new ERC buffer by
+calling their associated functions.  In the second, put modes to
+be marked as disabled by setting their associated variables to
+nil."
+  (if old-vars
+      (let ((out (list (reverse new-modes))))
+        (pcase-dolist (`(,k . ,v) old-vars)
+          (when (and (string-prefix-p "erc-" (symbol-name k))
+                     (string-suffix-p "-mode" (symbol-name k)))
+            (if v
+                (cl-pushnew k (car out))
+              (setf (car out) (delq k (car out)))
+              (cl-pushnew k (cdr out)))))
+        (cons (nreverse (car out)) (nreverse (cdr out))))
+    (list new-modes)))
+
 (defun erc-open (&optional server port nick full-name
                            connect passwd tgt-list channel process
                            client-certificate user id)
@@ -1951,18 +1969,25 @@ 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-vars (and (not connect) (buffer-local-variables)))
+         (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))))
     (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--merge-local-modes (erc--update-modules)
+                                  (or erc--server-reconnecting old-vars)))
+
+    (delay-mode-hooks (erc-mode))
+
+    (setq erc-server-reconnect-count old-recon-count)
+
     (when (setq erc-server-connected (not connect))
       (setq erc-server-announced-name
             (buffer-local-value 'erc-server-announced-name old-buffer)))
@@ -2019,14 +2044,21 @@ 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 (and erc--server-reconnecting
+                       (alist-get 'erc-networks--id 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 (car delayed-modules)) (funcall mod +1))
+    (dolist (var (cdr delayed-modules)) (set var nil))
+
     ;; set up prompt
     (unless continued-session
       (goto-char (point-max))
@@ -2038,8 +2070,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 ff5d802697..b185d850a6 100644
--- a/test/lisp/erc/erc-tests.el
+++ b/test/lisp/erc/erc-tests.el
@@ -1178,4 +1178,160 @@ erc-handle-irc-url
     (kill-buffer "baznet")
     (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) '(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)) ; no locals
+        (should (equal (nreverse calls) '( erc-pcomplete (completion . 1)
+                                           erc-join (autojoin . 1)
+                                           erc-networks (networks . 1))))
+        (setq calls nil)))))
+
+(ert-deftest erc--merge-local-modes ()
+
+  (ert-info ("No existing modes")
+    (let ((old '((a) (b . t)))
+          (new '(erc-c-mode erc-d-mode)))
+      (should (equal (erc--merge-local-modes new old)
+                     '((erc-c-mode erc-d-mode))))))
+
+  (ert-info ("Active existing added, inactive existing removed, deduped")
+    (let ((old '((a) (erc-b-mode) (c . t) (erc-d-mode . t) (erc-e-mode . t)))
+          (new '(erc-b-mode erc-d-mode)))
+      (should (equal (erc--merge-local-modes new old)
+                     '((erc-d-mode erc-e-mode) . (erc-b-mode)))))))
+
+(ert-deftest define-erc-module--global ()
+  (let ((global-module '(define-erc-module mname malias
+                          "Some docstring"
+                          ((ignore a) (ignore b))
+                          ((ignore c) (ignore d)))))
+
+    (should (equal (macroexpand global-module)
+                   `(progn
+
+                      (define-minor-mode erc-mname-mode
+                        "Toggle ERC mname mode.
+With a prefix argument ARG, enable mname if ARG is positive,
+and disable it otherwise.  If called from Lisp, enable the mode
+if ARG is omitted or nil.
+Some docstring"
+                        :global t
+                        :group 'erc-mname
+                        (if erc-mname-mode
+                            (erc-mname-enable)
+                          (erc-mname-disable)))
+
+                      (defun erc-mname-enable ()
+                        "Enable ERC mname mode."
+                        (interactive)
+                        (cl-pushnew 'mname erc-modules)
+                        (setq erc-mname-mode t)
+                        (ignore a) (ignore b))
+
+                      (defun erc-mname-disable ()
+                        "Disable ERC mname mode."
+                        (interactive)
+                        (setq erc-modules (delq 'mname erc-modules))
+                        (setq erc-mname-mode nil)
+                        (ignore c) (ignore d))
+
+                      (defalias 'erc-malias-mode #'erc-mname-mode)
+
+                      (put 'erc-mname-mode 'definition-name 'mname)
+                      (put 'erc-mname-enable 'definition-name 'mname)
+                      (put 'erc-mname-disable 'definition-name 'mname))))))
+
+(ert-deftest define-erc-module--local ()
+  (let* ((global-module '(define-erc-module mname malias
+                           "Some docstring"
+                           ((ignore a) (ignore b))
+                           ((ignore c) (ignore d))
+                           'local))
+         (got (macroexpand global-module))
+         (arg-en (cadr (nth 2 (nth 2 got))))
+         (arg-dis (cadr (nth 2 (nth 3 got)))))
+
+    (should (equal got
+                   `(progn
+                      (define-minor-mode erc-mname-mode
+                        "Toggle ERC mname mode.
+With a prefix argument ARG, enable mname if ARG is positive,
+and disable it otherwise.  If called from Lisp, enable the mode
+if ARG is omitted or nil.
+Some docstring"
+                        :global nil
+                        :group 'erc-mname
+                        (if erc-mname-mode
+                            (erc-mname-enable)
+                          (erc-mname-disable)))
+
+                      (defun erc-mname-enable (&optional ,arg-en)
+                        "Enable ERC mname mode.
+With ARG, do so in all buffers for the current connection."
+                        (interactive "p")
+                        (when (derived-mode-p 'erc-mode)
+                          (if ,arg-en
+                              (erc-with-all-buffers-of-server
+                                  erc-server-process nil
+                                (erc-mname-enable))
+                            (setq erc-mname-mode t)
+                            (ignore a) (ignore b))))
+
+                      (defun erc-mname-disable (&optional ,arg-dis)
+                        "Disable ERC mname mode.
+With ARG, do so in all buffers for the current connection."
+                        (interactive "p")
+                        (when (derived-mode-p 'erc-mode)
+                          (if ,arg-dis
+                              (erc-with-all-buffers-of-server
+                                  erc-server-process nil
+                                (erc-mname-disable))
+                            (setq erc-mname-mode nil)
+                            (ignore c) (ignore d))))
+
+                      (defalias 'erc-malias-mode #'erc-mname-mode)
+
+                      (put 'erc-mname-mode 'definition-name 'mname)
+                      (put 'erc-mname-enable 'definition-name 'mname)
+                      (put 'erc-mname-disable 'definition-name 'mname))))))
+
 ;;; erc-tests.el ends here
-- 
2.38.1


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

From 8318537de9aeaec1e2217b621781aa6b24c07d1d 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 5/8] Call erc-login indirectly via new generic wrapper

* lisp/erc/erc-backend (erc--register-connection): Add new internal
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 30b53dfd8e..973616bc37 100644
--- a/lisp/erc/erc-backend.el
+++ b/lisp/erc/erc-backend.el
@@ -643,6 +643,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))
+
 (defvar erc--server-connect-dumb-ipv6-regexp
   ;; Not for validation (gives false positives).
   (rx bot "[" (group (+ (any xdigit digit ":.")) (? "%" (+ alnum))) "]" eot))
@@ -697,7 +701,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.
@@ -897,7 +901,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 #8: 0006-Add-non-IRCv3-SASL-module-to-ERC.patch --]
[-- Type: text/x-patch, Size: 71489 bytes --]

From f96bf824d0ac12f7f79f4f3ce9e4a80211bb91b4 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 6/8] Add non-IRCv3 SASL module to ERC

* lisp/erc/erc-compat.el
(erc-compat--29-sasl-scram-construct-gs2-header,
erc-compat--29-sasl-scram-client-first-message,
erc-compat--29-sasl-scram--client-final-message): Fix encoding bug and
add minimal authorization support with copies of SASL 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                             | 151 ++++++-
 etc/ERC-NEWS                                  |   7 +-
 lisp/erc/erc-backend.el                       |   9 +
 lisp/erc/erc-compat.el                        |  83 ++++
 lisp/erc/erc-sasl.el                          | 417 ++++++++++++++++++
 lisp/erc/erc.el                               |   1 +
 test/lisp/erc/erc-sasl-tests.el               | 344 +++++++++++++++
 test/lisp/erc/erc-scenarios-sasl.el           | 144 ++++++
 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, 1333 insertions(+), 5 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 b9c6e33d36..f86465fed7 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.
 * Integrations::                Integrations available for ERC.
 * Options::                     Options that are available for ERC.
@@ -482,6 +483,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
@@ -561,6 +566,7 @@ Advanced Usage
 
 @menu
 * Connecting::                  Ways of connecting to an IRC server.
+* SASL::                        Authenticating via SASL
 * Sample Configuration::        An example configuration file.
 * Integrations::                Integrations available for ERC.
 * Options::                     Options that are available for ERC.
@@ -633,6 +639,7 @@ Connecting
 parameters, and some, like @code{client-certificate}, will just be
 @code{nil}.
 
+@anchor{ERC client-certificate}
 To use a certificate with @code{erc-tls}, specify the optional
 @var{client-certificate} keyword argument, whose value should be as
 described in the documentation of @code{open-network-stream}: if
@@ -767,7 +774,10 @@ Connecting
 You can manually set another nickname with the /NICK command.
 @end defopt
 
+@anchor{ERC username}
 @subheading User
+@cindex user
+
 @defun erc-compute-user &optional user
 Determine a suitable value to send as the first argument of the
 opening @samp{USER} IRC command by consulting the following sources:
@@ -879,6 +889,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
@@ -891,7 +902,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
@@ -953,6 +965,143 @@ Connecting
 make the most sense, but any reasonably printable object is
 acceptable.
 
+@node SASL
+@section Authenticating via SASL
+@cindex SASL
+
+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 probably won't be needing SASL for the client-to-bouncer
+connection.  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.  To make this work, customize
+@code{erc-sasl-user} and @code{erc-sasl-password} or specify the
+@code{:user} and @code{:password} keyword arguments when invoking
+@code{erc-tls}.  Note that @code{:user} cannot be given interactively.
+@end indentedblock
+
+@var{external} (via Client TLS Certificate):
+
+@indentedblock
+This works in conjunction with the @code{:client-certificate} keyword
+offered by @code{erc-tls}.  Just ensure you've registered your
+fingerprint with the network beforehand.  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}).  These days, there's
+usually a @samp{CERT ADD} command offered by NickServ that can
+register you automatically if you issue it while connected with a
+client cert.  (@pxref{ERC client-certificate}).
+
+Additional considerations:
+@enumerate
+@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 a remote chance your
+server has something else in mind.
+@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 username, typically the same one
+registered with nickname services.  Specify this when your NickServ
+login differs from the @code{:user} you're connecting with.
+(@pxref{ERC username})
+@end defopt
+
+@defopt erc-sasl-password
+As noted elsewhere, the @code{:password} parameter for @code{erc-tls}
+was orignally intended for traditional ``server passwords,'' but these
+aren't really used any more.  As such, this option defaults to
+borrowing that parameter for its own uses, thus allowing you to call
+@code{erc-tls} with @code{:password} set to your NickServ password.
+
+You can also set this to a nonemtpy string, and ERC will send that
+when needed, no questions asked.  If you instead give a non-@code{nil}
+symbol (other than @code{:password}), 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 resolved 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, and it's
+@code{nil} by default.  As a last resort, ERC will prompt you for
+input.
+
+Lastly, 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
+
+@subheading Troubleshooting
+
+@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're struggling, remember that your SASL password is almost
+always your NickServ password.  When in doubt, try restoring all SASL
+options to their defaults and calling @code{erc-tls} with @code{:user}
+set to your NickServ account name and @code{:password} to your
+NickServ password.  If you're still having trouble, please contact us
+(@pxref{Getting Help and Reporting Bugs}).
+
+As you try out different settings, keep in mind that it's best to
+create a fresh session for every change, for example, by calling
+@code{erc-tls} from scratch.  More experienced users may be able to
+get away with cycling @code{erc-sasl-mode} and issuing a
+@samp{/reconnect}, but that's generally not recommended.  Whatever the
+case, you'll probably want to temporarily disable
+@code{erc-server-auto-reconnect} while experimenting.
 
 @node Sample Configuration
 @section Sample Configuration
diff --git a/etc/ERC-NEWS b/etc/ERC-NEWS
index 15f7fe84dd..d0d84d0a98 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-backend.el b/lisp/erc/erc-backend.el
index 973616bc37..6e91353808 100644
--- a/lisp/erc/erc-backend.el
+++ b/lisp/erc/erc-backend.el
@@ -2334,6 +2334,15 @@ erc-server-322-message
     (erc-display-message parsed 'notice 'active 's671
                          ?n nick ?a securemsg)))
 
+(define-erc-response-handler (900)
+  "Handle a \"RPL_LOGGEDIN\" server command.
+Some servers don't consider this SASL-specific but rather just an
+indication of a server-side state change from logged-out to
+logged-in." nil
+  ;; Whenever ERC starts caring about user accounts, it should record
+  ;; the session as being logged here.
+  (erc-display-message parsed 'notice proc (erc-response.contents parsed)))
+
 (define-erc-response-handler (431 445 446 451 462 463 464 481 483 484 485
                                   491 501 502)
   ;; 431 - No nickname given
diff --git a/lisp/erc/erc-compat.el b/lisp/erc/erc-compat.el
index d23703394b..4893f6ce59 100644
--- a/lisp/erc/erc-compat.el
+++ b/lisp/erc/erc-compat.el
@@ -273,6 +273,89 @@ erc-compat--auth-source-backend-parser-functions
     auth-source-backend-parser-functions))
 
 
+;;;; 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--29-sasl-scram-construct-gs2-header (client)
+  (let ((authzid (sasl-client-property client 'authenticator-name)))
+    (concat "n," (and authzid "a=") authzid ",")))
+
+(defun erc-compat--29-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--29-sasl-scram-construct-gs2-header client)
+          (sasl-scram--client-first-message-bare client)))
+
+(defun erc-compat--29-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--29-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..ab171ea4d3
--- /dev/null
+++ b/lisp/erc/erc-sasl.el
@@ -0,0 +1,417 @@
+;;; 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.
+;;
+;; - 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 :user
+  "Account username to send when authenticating.
+This is also referred to as the authentication identity or
+\"authcid\".  A value of `:user' or `:nick' indicates that the
+corresponding connection parameter on file should be used.  These
+are most often derived from arguments provided to the `erc' and
+`erc-tls' entry points.  In the case of `:nick', a downcased
+version is used."
+  :type '(choice string (const :user) (const :nick)))
+
+(defcustom erc-sasl-password :password
+  "Optional account password to send when authenticating.
+When the value is a string, ERC will use it unconditionally for
+most mechanisms.  Likewise with `:password', except ERC will
+instead use the \"session password\" on file, which often
+originates from the entry-point commands `erc' or `erc-tls'.
+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 prompt for input.
+
+Note that, with `:password', ERC will forgo sending a traditional
+server password via the IRC \"PASS\" command.  Also, when
+`erc-sasl-mechanism' is set to `ecdsa-nist256p-challenge', this
+option should hold the file name of the key."
+  :type '(choice (const nil) (const :password) 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
+includes `erc-sasl-user' for the `:user' field and
+`erc-sasl-password' for the `:host' field, when the latter option
+is a non-nil, non-keyword symbol.  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)
+
+;; 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--get-user ()
+  (pcase (alist-get 'user erc-sasl--options)
+    (:user erc-session-username)
+    (:nick (erc-downcase (erc-current-nick)))
+    (v v)))
+
+(defun erc-sasl--read-password (prompt)
+  "Return configured option or server password.
+PROMPT is passed to `read-passwd' if necessary."
+  (if-let
+      ((found (pcase (alist-get 'password erc-sasl--options)
+                (:password erc-session-password)
+                ((and (pred stringp) v) (unless (string-empty-p v) v))
+                ((and (guard erc-sasl-auth-source-function)
+                      v (let host
+                          (or v (erc-networks--id-given erc-networks--id))))
+                 (apply erc-sasl-auth-source-function
+                        :user (erc-sasl--get-user)
+                        (and host (list :host (symbol-name host))))))))
+      (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--29-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--29-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--29-sasl-scram-client-first-message
+        erc-sasl--scram-sha-1-client-final-message
+        sasl-scram-sha-1-authenticate-server)
+       ("SCRAM-SHA-256"
+        erc-compat--29-sasl-scram-client-first-message
+        erc-sasl--scram-sha-256-client-final-message
+        sasl-scram-sha-256-authenticate-server)
+       ("SCRAM-SHA-512"
+        erc-compat--29-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 (list name))
+                                     (erc-sasl--get-user)
+                                     "N/A" "N/A"))
+      (sasl-client-set-property client 'authenticator-name
+                                (alist-get 'authzid erc-sasl--options))
+      client)))
+
+(cl-defmethod erc-sasl--create-client ((_ (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 (erc-sasl--get-user))
+         (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 ((_ (eql scram-sha-256)))
+  "Create and return a new SCRAM-SHA-256 client."
+  (when (featurep 'sasl-scram-sha256)
+    (cl-call-next-method)))
+
+(cl-defmethod erc-sasl--create-client ((_ (eql scram-sha-512)))
+  "Create and return a new SCRAM-SHA-512 client."
+  (when (featurep 'sasl-scram-sha256)
+    (cl-call-next-method)))
+
+(cl-defmethod erc-sasl--create-client ((_ (eql ecdsa-nist256p-challenge)))
+  "Create and return a new ECDSA-NIST256P-CHALLENGE client."
+  (let ((keyfile (cdr (assq 'password erc-sasl--options))))
+    ;; Better to signal usage errors now than inside a process filter.
+    (cond ((or (not (stringp keyfile)) (not (file-readable-p keyfile)))
+           (erc-display-error-notice
+            nil "`erc-sasl-password' not accessible as a file")
+           nil)
+          ((not (executable-find "openssl"))
+           (erc-display-error-notice nil "Could not find openssl program")
+           nil)
+          (t
+           (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 ()
+  (setq erc-sasl--state (make-erc-sasl--state))
+  ;; If the previous attempt failed during registration, this may be
+  ;; non-nil and contain erroneous values, but how can we detect that?
+  ;; What if the server dropped the connection for some other reason?
+  (setq erc-sasl--options
+        (or (and erc--server-reconnecting
+                 (alist-get 'erc-sasl--options erc--server-reconnecting))
+            `((user . ,erc-sasl-user)
+              (password . ,erc-sasl-password)
+              (mechanism . ,erc-sasl-mechanism)
+              (authzid . ,erc-sasl-authzid)))))
+
+(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)))
+
+(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 CAP 3.2-aware WIP implementation.
+  ((unless erc--target
+     (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 or unsupported SASL mechanism: %s" mech))
+         (erc-error "Unknown or unsupported SASL mechanism: %s" mech))
+       (setf (erc-sasl--state-client erc-sasl--state) client))))
+  ((kill-local-variable 'erc-sasl--state)
+   (kill-local-variable 'erc-sasl--options))
+  'local)
+
+(define-erc-response-handler (AUTHENTICATE)
+  "Begin or resume an SASL session." nil
+  (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)
+    (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)))
+      (erc-server-send (concat "AUTHENTICATE " (or data "+"))))))
+
+(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 an 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")))
+  (erc-display-message parsed 'notice proc (erc-response.contents 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 (string-join (cdr (erc-response.command-args 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")
+        (if (and erc-session-password
+                 (eq :password (alist-get 'password erc-sasl--options)))
+            (let (erc-session-password)
+              (erc-login))
+          (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 384d92e624..63093d509b 100644
--- a/lisp/erc/erc.el
+++ b/lisp/erc/erc.el
@@ -1860,6 +1860,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..64593ca270
--- /dev/null
+++ b/test/lisp/erc/erc-sasl-tests.el
@@ -0,0 +1,344 @@
+;;; 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--basic ()
+  (ert-info ("Explicit erc-sasl-password")
+    (let ((erc-sasl--options '((password . "foo"))))
+      (should (string= (erc-sasl--read-password nil) "foo"))))
+
+  (ert-info ("Explicit session password")
+    (let ((erc-session-password "foo")
+          (erc-sasl--options '((password . :password))))
+      (should (string= (erc-sasl--read-password nil) "foo"))))
+
+  (ert-info ("Fallback to prompt skip auth-source")
+    (should-not erc-sasl-auth-source-function)
+    (let ((erc-session-password "bar")
+          (erc-networks--id (erc-networks--id-create nil)))
+      (should (string= (ert-simulate-keys "bar\r"
+                         (erc-sasl--read-password "?"))
+                       "bar"))))
+
+  (ert-info ("Prompt when auth-source fails and `erc-sasl-password' null")
+    (let ((erc-sasl--options '((password)))
+          (erc-sasl-auth-source-function #'ignore))
+      (should (string= (ert-simulate-keys "baz\r"
+                         (erc-sasl--read-password "pwd:"))
+                       "baz")))))
+
+(ert-deftest erc-sasl--read-password--auth-source ()
+  (ert-with-temp-file netrc-file
+    :text (string-join
+           (list
+            ;; If you swap these first 2 lines, *1 below fails
+            "machine FSF.chat port 6697 user bob password sesame"
+            "machine GNU/chat port 6697 user bob password spam"
+            "machine MyHost port irc password 123")
+           "\n")
+    (let* ((auth-sources (list netrc-file))
+           (erc-session-server "irc.gnu.org")
+           (erc-session-port 6697)
+           (erc-networks--id (erc-networks--id-create nil))
+           calls
+           (erc-sasl-auth-source-function
+            (lambda (&rest r)
+              (push r calls)
+              (apply #'erc--auth-source-search r)))
+           erc-server-announced-name ; too early
+           auth-source-do-cache)
+
+      (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"))
+          (should (equal (pop calls) '(:user "bob" :host "FSF.chat")))))
+
+      (ert-info ("ID for :host and `erc-session-username' for :user") ; *1
+        (let ((erc-session-username "bob")
+              (erc-sasl--options '((user . :user) (password)))
+              (erc-networks--id (erc-networks--id-create 'GNU/chat)))
+          (should (string= (erc-sasl--read-password nil) "spam"))
+          (should (equal (pop calls) '(:user "bob" :host "GNU/chat")))))
+
+      (ert-info ("ID for :host and current nick for :user") ; *1
+        (let ((erc-server-current-nick "bob")
+              (erc-sasl--options '((user . :nick) (password)))
+              (erc-networks--id (erc-networks--id-create 'GNU/chat)))
+          (should (string= (erc-sasl--read-password nil) "spam"))
+          (should (equal (pop calls) '(:user "bob" :host "GNU/chat")))))
+
+      (ert-info ("Symbol as password, entry lacks user field")
+        (let ((erc-server-current-nick "fake")
+              (erc-sasl--options '((user . :nick) (password . MyHost)))
+              (erc-networks--id (erc-networks--id-create 'GNU/chat)))
+          (should (string= (erc-sasl--read-password nil) "123"))
+          (should (equal (pop calls) '(:user "fake" :host "MyHost"))))))))
+
+(ert-deftest erc-sasl-create-client--plain ()
+  (let* ((erc-session-password "password123")
+         (erc-session-username "tester")
+         (erc-sasl--options '((user . :user) (password . :password)))
+         (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")
+         (erc-sasl--options '((user . :nick) (password . :password)))
+         (client (erc-sasl--create-client 'external)) ; unused ^
+         (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-sasl--options '((user . "jilles") (password . "sesame")
+                              (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--29-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 '((user . :nick) (password . :password)
+                              (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--29-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")
+         (erc-sasl--options '((user . :nick) (password . :password) (authzid)))
+         (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--29-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")
+         (erc-sasl--options '((user . :nick) (password . :password) (authzid)))
+         (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--29-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..6c5e78d0c8
--- /dev/null
+++ b/test/lisp/erc/erc-scenarios-sasl.el
@@ -0,0 +1,144 @@
+;;; 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)))
+
+(require 'erc-sasl)
+
+(ert-deftest erc-scenarios-sasl--plain ()
+  :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))
+       (port (process-contact dumb-server :service))
+       (erc-modules (cons 'sasl erc-modules))
+       (erc-sasl-password "password123")
+       (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)))))
+
+    (ert-info ("Notices received")
+      (with-current-buffer (erc-d-t-wait-for 10 (get-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"))))))
+
+(ert-deftest erc-scenarios-sasl--external ()
+  :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 'external))
+       (port (process-contact dumb-server :service))
+       (erc-modules (cons 'sasl erc-modules))
+       (erc-sasl-mechanism 'external)
+       (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)))))
+
+    (ert-info ("Notices received")
+      (with-current-buffer (erc-d-t-wait-for 10 (get-buffer "ExampleOrg"))
+        (funcall expect 10 "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-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)
+       (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-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-user :nick)
+       (erc-sasl-mechanism mech)
+       (mock-rvs (list "c5RqLCZy0L4fGkKAZ0hujFBs" ""))
+       (sasl-unique-id-function (lambda () (pop mock-rvs)))
+       (expect (erc-d-t-make-expecter)))
+
+    (ert-info ("Connect")
+      (with-current-buffer (erc :server "127.0.0.1"
+                                :port port
+                                :nick "jilles"
+                                :password "sesame"
+                                :full-name "jilles")
+        (should (string= (buffer-name) (format "127.0.0.1:%d" port)))))
+
+    (ert-info ("Notices received")
+      (with-current-buffer (erc-d-t-wait-for 10 (get-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/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


[-- Warning: decoded text below may be mangled, UTF-8 assumed --]
[-- Attachment #9: 0007-Accept-functions-in-place-of-passwords-in-ERC.patch --]
[-- Type: text/x-patch, Size: 13116 bytes --]

From c4800c9e1f641bf09e1bc906353461272f4e921f 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 7/8] Accept functions in place of passwords in ERC

* lisp/erc/erc-backend.el (erc-session-password): Add comment
explaining type is now string, nil, or function.
* lisp/erc/erc-compat.el (erc-compat--29-auth-source-pass-search):
Use obfuscation from auth-source function when available.
* lisp/erc/erc-sasl.el (erc-sasl--read-password,
erc-server-AUTHENTICATE): Use `erc--unfun'.
* lisp/erc/erc-services.el (erc-nickserv-get-password,
erc-nickserv-send-identify): Use `erc--unfun'.
* lisp/erc/erc.el (erc--unfun): New function for unwrapping a
password couched in a getter.
(erc--debug-irc-protocol-mask-secrets): Add variable to indicate
whether to mask passwords in debug logs.
(erc--mask-secrets): New function to swap masked secret with question
marks in debug logs.
(erc-log-irc-protocol): Conditionally mask secrets when
`erc--debug-irc-protocol-mask-secrets' is non-nil.
(erc--auth-source-search): Don't unwrap secret from function before
returning.
(erc-server-join-channel, erc-login): Use `erc--unfun'.

* test/lisp/erc/erc-services-tests.el
(erc-services-tests--wrap-search): Add helper for `erc--unfun'.
(erc-services-tests--auth-source-standard,
erc-services-tests--auth-source-announced,
erc-services-tests--auth-source-overrides, erc-nickserv-get-password):
Use `erc--unfun'.
* test/lisp/erc/erc-tests.el (erc--debug-irc-protocol-mask-secrets):
Add test for masking secrets with `erc--unfun' and friends.
---
 lisp/erc/erc-backend.el             |  3 ++-
 lisp/erc/erc-compat.el              | 14 +++++++++--
 lisp/erc/erc-sasl.el                |  4 +--
 lisp/erc/erc-services.el            |  5 ++--
 lisp/erc/erc.el                     | 38 +++++++++++++++++++++++++----
 test/lisp/erc/erc-services-tests.el | 16 +++++++++---
 test/lisp/erc/erc-tests.el          | 22 +++++++++++++++++
 7 files changed, 86 insertions(+), 16 deletions(-)

diff --git a/lisp/erc/erc-backend.el b/lisp/erc/erc-backend.el
index 6e91353808..43c5faad63 100644
--- a/lisp/erc/erc-backend.el
+++ b/lisp/erc/erc-backend.el
@@ -205,7 +205,8 @@ erc-whowas-on-nosuchnick
 ;;;; Variables and options
 
 (defvar-local erc-session-password nil
-  "The password used for the current session.")
+  "The password used for the current session.
+This should be a string or a function returning a string.")
 
 (defvar erc-server-responses (make-hash-table :test #'equal)
   "Hash table mapping server responses to their handler hooks.")
diff --git a/lisp/erc/erc-compat.el b/lisp/erc/erc-compat.el
index 4893f6ce59..66a9a615e3 100644
--- a/lisp/erc/erc-compat.el
+++ b/lisp/erc/erc-compat.el
@@ -252,8 +252,18 @@ erc-compat--29-auth-source-pass-search
   ;; From `auth-source-pass-search'
   (cl-assert (and host (not (eq host t)))
              t "Invalid password-store search: %s %s")
-  (erc-compat--29-auth-source-pass--build-result-many
-   host user port require max))
+  (let ((rv (erc-compat--29-auth-source-pass--build-result-many
+             host user port require max)))
+    (if (and (fboundp 'auth-source--obfuscate)
+             (fboundp 'auth-source--deobfuscate))
+        (let (out)
+          (dolist (e rv out)
+            (when-let* ((s (plist-get e :secret))
+                        (v (auth-source--obfuscate s)))
+              (setf (plist-get e :secret)
+                    (byte-compile (lambda () (auth-source--deobfuscate v)))))
+            (push e out)))
+      rv)))
 
 (defun erc-compat--29-auth-source-pass-backend-parse (entry)
   (when (eq entry 'password-store)
diff --git a/lisp/erc/erc-sasl.el b/lisp/erc/erc-sasl.el
index ab171ea4d3..9084d873ce 100644
--- a/lisp/erc/erc-sasl.el
+++ b/lisp/erc/erc-sasl.el
@@ -143,7 +143,7 @@ erc-sasl--read-password
                  (apply erc-sasl-auth-source-function
                         :user (erc-sasl--get-user)
                         (and host (list :host (symbol-name host))))))))
-      (copy-sequence found)
+      (copy-sequence (erc--unfun found))
     (read-passwd prompt)))
 
 (defun erc-sasl--plain-response (client steps)
@@ -353,7 +353,7 @@ sasl
       (when (string= data "")
         (setq data nil))
       (when data
-        (setq data (base64-encode-string data t)))
+        (setq data (erc--unfun (base64-encode-string data t))))
       (erc-server-send (concat "AUTHENTICATE " (or data "+"))))))
 
 (defun erc-sasl--destroy (proc)
diff --git a/lisp/erc/erc-services.el b/lisp/erc/erc-services.el
index fe9cb5b5f1..48953288d1 100644
--- a/lisp/erc/erc-services.el
+++ b/lisp/erc/erc-services.el
@@ -455,7 +455,7 @@ erc-nickserv-get-password
                   (read-passwd
                    (format "NickServ password for %s on %s (RET to cancel): "
                            nick nid)))))
-       ((not (string-empty-p ret))))
+       ((not (string-empty-p (erc--unfun ret)))))
     ret))
 
 (defvar erc-auto-discard-away)
@@ -477,7 +477,8 @@ erc-nickserv-send-identify
          (msgtype (or (erc-nickserv-alist-ident-command nil nickserv-info)
                       "PRIVMSG")))
     (erc-message msgtype
-                 (concat nickserv " " identify-word " " nick password))))
+                 (concat nickserv " " identify-word " " nick
+                         (erc--unfun password)))))
 
 (defun erc-nickserv-call-identify-function (nickname)
   "Call `erc-nickserv-identify' with NICKNAME."
diff --git a/lisp/erc/erc.el b/lisp/erc/erc.el
index 63093d509b..268d83dc44 100644
--- a/lisp/erc/erc.el
+++ b/lisp/erc/erc.el
@@ -2335,6 +2335,23 @@ erc-debug-irc-protocol
 WARNING: Do not set this variable directly!  Instead, use the
 function `erc-toggle-debug-irc-protocol' to toggle its value.")
 
+(defvar erc--debug-irc-protocol-mask-secrets t
+  "Whether to hide secrets in a debug log.
+They are still visible on screen but are replaced by question
+marks when yanked.")
+
+(defun erc--mask-secrets (string)
+  (when-let* ((eot (length string))
+              (beg (text-property-any 0 eot 'erc-secret t string))
+              (end (text-property-not-all beg eot 'erc-secret t string))
+              (sec (substring string beg end)))
+    (setq string (concat (substring string 0 beg)
+                         (make-string 10 ??)
+                         (substring string end eot)))
+    (put-text-property beg (+ 10 beg) 'face 'erc-inverse-face string)
+    (put-text-property beg (+ 10 beg) 'display sec string))
+  string)
+
 (defun erc-log-irc-protocol (string &optional outbound)
   "Append STRING to the buffer *erc-protocol*.
 
@@ -2360,6 +2377,8 @@ erc-log-irc-protocol
                       (format "%s:%s" erc-session-server erc-session-port))))
           (ts (when erc-debug-irc-protocol-time-format
                 (format-time-string erc-debug-irc-protocol-time-format))))
+      (when erc--debug-irc-protocol-mask-secrets
+        (setq string (erc--mask-secrets string)))
       (with-current-buffer (get-buffer-create "*erc-protocol*")
         (save-excursion
           (goto-char (point-max))
@@ -3285,9 +3304,8 @@ erc--auth-source-search
       (setq plist (plist-put plist :max 5000))) ; `auth-source-netrc-parse'
     (unless (plist-get defaults :require)
       (setq plist (plist-put plist :require '(:secret))))
-    (when-let* ((sorted (sort (apply #'auth-source-search plist) test))
-                (secret (plist-get (car sorted) :secret)))
-      (if (functionp secret) (funcall secret) secret))))
+    (when-let* ((sorted (sort (apply #'auth-source-search plist) test)))
+      (plist-get (car sorted) :secret))))
 
 (defun erc-auth-source-search (&rest plist)
   "Call `auth-source-search', possibly with keyword params in PLIST."
@@ -3308,7 +3326,8 @@ erc-server-join-channel
     (setq secret (apply erc-auth-source-join-function
                         `(,@(and server (list :host server)) :user ,channel))))
   (erc-log (format "cmd: JOIN: %s" channel))
-  (erc-server-send (concat "JOIN " channel (and secret (concat " " secret)))))
+  (erc-server-send (concat "JOIN " channel
+                           (and secret (concat " " (erc--unfun secret))))))
 
 (defun erc--valid-local-channel-p (channel)
   "Non-nil when channel is server-local on a network that allows them."
@@ -6344,6 +6363,15 @@ erc-load-irc-script-lines
 
 ;; authentication
 
+(defun erc--unfun (maybe-fn)
+  "Return MAYBE-FN or whatever it returns."
+  (let ((s (if (functionp maybe-fn) (funcall maybe-fn) maybe-fn)))
+    (when (and erc-debug-irc-protocol
+               erc--debug-irc-protocol-mask-secrets
+               (stringp s))
+      (put-text-property 0 (length s) 'erc-secret t s))
+    s))
+
 (defun erc-login ()
   "Perform user authentication at the IRC server."
   (erc-log (format "login: nick: %s, user: %s %s %s :%s"
@@ -6353,7 +6381,7 @@ erc-login
                    erc-session-server
                    erc-session-user-full-name))
   (if erc-session-password
-      (erc-server-send (concat "PASS :" erc-session-password))
+      (erc-server-send (concat "PASS :" (erc--unfun erc-session-password)))
     (message "Logging in without password"))
   (erc-server-send (format "NICK %s" (erc-current-nick)))
   (erc-server-send
diff --git a/test/lisp/erc/erc-services-tests.el b/test/lisp/erc/erc-services-tests.el
index 7ff2e36e77..2547c5e01a 100644
--- a/test/lisp/erc/erc-services-tests.el
+++ b/test/lisp/erc/erc-services-tests.el
@@ -62,9 +62,13 @@ erc--auth-source-determine-params-merge
                            :x ("x")
                            :require (:secret))))))
 
+(defun erc-services-tests--wrap-search (s)
+  (lambda (&rest r) (erc--unfun (apply s r))))
+
 ;; Some of the following may be related to bug#23438.
 
 (defun erc-services-tests--auth-source-standard (search)
+  (setq search (erc-services-tests--wrap-search search))
 
   (ert-info ("Session wins")
     (let ((erc-session-server "irc.gnu.org")
@@ -93,6 +97,7 @@ erc-services-tests--auth-source-standard
       (should (string= (funcall search :user "#chan") "baz")))))
 
 (defun erc-services-tests--auth-source-announced (search)
+  (setq search (erc-services-tests--wrap-search search))
   (let* ((erc--isupport-params (make-hash-table))
          (erc-server-parameters '(("CHANTYPES" . "&#")))
          (erc--target (erc--target-from-string "&chan")))
@@ -124,6 +129,7 @@ erc-services-tests--auth-source-announced
           (should (string= (funcall search :user "#chan") "foo")))))))
 
 (defun erc-services-tests--auth-source-overrides (search)
+  (setq search (erc-services-tests--wrap-search search))
   (let* ((erc-session-server "irc.gnu.org")
          (erc-server-announced-name "my.gnu.org")
          (erc-network 'GNU.chat)
@@ -537,18 +543,20 @@ erc-nickserv-get-password
            (erc-network 'FSF.chat)
            (erc-server-current-nick "tester")
            (erc-networks--id (erc-networks--id-create nil))
-           (erc-session-port 6697))
+           (erc-session-port 6697)
+           (search (erc-services-tests--wrap-search
+                    #'erc-nickserv-get-password)))
 
       (ert-info ("Lookup custom option")
-        (should (string= (erc-nickserv-get-password "alice") "foo")))
+        (should (string= (funcall search "alice") "foo")))
 
       (ert-info ("Auth source")
         (ert-info ("Network")
-          (should (string= (erc-nickserv-get-password "bob") "sesame")))
+          (should (string= (funcall search "bob") "sesame")))
 
         (ert-info ("Network ID")
           (let ((erc-networks--id (erc-networks--id-create 'GNU/chat)))
-            (should (string= (erc-nickserv-get-password "bob") "spam")))))
+            (should (string= (funcall search "bob") "spam")))))
 
       (ert-info ("Read input")
         (should (string=
diff --git a/test/lisp/erc/erc-tests.el b/test/lisp/erc/erc-tests.el
index b185d850a6..4d0d69cd7b 100644
--- a/test/lisp/erc/erc-tests.el
+++ b/test/lisp/erc/erc-tests.el
@@ -530,6 +530,28 @@ erc-ring-previous-command
   (when noninteractive
     (kill-buffer "*#fake*")))
 
+(ert-deftest erc--debug-irc-protocol-mask-secrets ()
+  (should-not erc-debug-irc-protocol)
+  (should erc--debug-irc-protocol-mask-secrets)
+  (with-temp-buffer
+    (setq erc-server-process (start-process "fake" (current-buffer) "true")
+          erc-server-current-nick "tester"
+          erc-session-server "myproxy.localhost"
+          erc-session-port 6667)
+    (let ((inhibit-message noninteractive))
+      (erc-toggle-debug-irc-protocol)
+      (erc-log-irc-protocol
+       (concat "PASS :" (erc--unfun (lambda () "changeme")) "\r\n")
+       'outgoing)
+      (set-process-query-on-exit-flag erc-server-process nil))
+    (with-current-buffer "*erc-protocol*"
+      (goto-char (point-min))
+      (search-forward "\r\n\r\n")
+      (search-forward "myproxy.localhost:6667 >> PASS :????????" (pos-eol)))
+    (when noninteractive
+      (kill-buffer "*erc-protocol*")
+      (should-not erc-debug-irc-protocol))))
+
 (ert-deftest erc-log-irc-protocol ()
   (should-not erc-debug-irc-protocol)
   (with-temp-buffer
-- 
2.38.1


[-- Warning: decoded text below may be mangled, UTF-8 assumed --]
[-- Attachment #10: 0008-Add-test-scenarios-for-local-ERC-modules.patch --]
[-- Type: text/x-patch, Size: 25562 bytes --]

From bef06cab7e2fa695e69e30eb097f44172dbc00e3 Mon Sep 17 00:00:00 2001
From: "F. Jason Park" <jp@neverwas.me>
Date: Sun, 20 Nov 2022 19:01:32 -0800
Subject: [PATCH 8/8] Add test scenarios for local ERC modules

* test/lisp/erc/erc-scenarios-base-local-modules.el: New file.
* test/lisp/erc/resources/base/local-modules/first.eld: New file.
* test/lisp/erc/resources/base/local-modules/fourth.eld: New file
* test/lisp/erc/resources/base/local-modules/second.eld: New file.
* test/lisp/erc/resources/base/local-modules/third.eld: New file.
(Bug#57955.)
---
 .../erc/erc-scenarios-base-local-modules.el   | 243 ++++++++++++++++++
 .../resources/base/local-modules/first.eld    |  53 ++++
 .../resources/base/local-modules/fourth.eld   |  53 ++++
 .../resources/base/local-modules/second.eld   |  47 ++++
 .../resources/base/local-modules/third.eld    |  43 ++++
 5 files changed, 439 insertions(+)
 create mode 100644 test/lisp/erc/erc-scenarios-base-local-modules.el
 create mode 100644 test/lisp/erc/resources/base/local-modules/first.eld
 create mode 100644 test/lisp/erc/resources/base/local-modules/fourth.eld
 create mode 100644 test/lisp/erc/resources/base/local-modules/second.eld
 create mode 100644 test/lisp/erc/resources/base/local-modules/third.eld

diff --git a/test/lisp/erc/erc-scenarios-base-local-modules.el b/test/lisp/erc/erc-scenarios-base-local-modules.el
new file mode 100644
index 0000000000..417705de09
--- /dev/null
+++ b/test/lisp/erc/erc-scenarios-base-local-modules.el
@@ -0,0 +1,243 @@
+;;; erc-scenarios-local-modules.el --- Local modules 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:
+
+;;; Commentary:
+
+;; These tests all use `sasl' because, as of ERC 5.5, it's the one
+;; and only local module.
+
+(require 'ert-x)
+(eval-and-compile
+  (let ((load-path (cons (ert-resource-directory) load-path)))
+    (require 'erc-scenarios-common)))
+
+(require 'erc-sasl)
+
+;; This asserts that a local module's options and its inclusion in
+;; (and absence from) `erc-update-modules' can be let-bound.
+
+(ert-deftest erc-scenarios-base-local-modules--reconnect-let ()
+  :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))
+       (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 (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")))))
+
+;; After quitting a session for which `sasl' is enabled, you
+;; disconnect and toggle `erc-sasl-mode' off.  You then reconnect
+;; using an alternate nickname.  You again disconnect and reconnect,
+;; this time immediately, and the mode stays disabled.  Finally, you
+;; once again disconnect, toggle the mode back on, and reconnect.  You
+;; are authenticated successfully, just like in the initial session.
+;;
+;; This is meant to show that a user's local mode settings persist
+;; between sessions.  It also happens to show (in round four, below)
+;; that a server renicking a user on 001 after a 903 is handled just
+;; like a user-initiated renick, although this is not the main thrust.
+
+(ert-deftest erc-scenarios-base-local-modules--mode-persistence ()
+  :tags '(:expensive-test)
+  (erc-scenarios-common-with-cleanup
+      ((erc-scenarios-common-dialog "base/local-modules")
+       (erc-server-flood-penalty 0.1)
+       (dumb-server (erc-d-run "localhost" t 'first 'second 'third 'fourth))
+       (port (process-contact dumb-server :service))
+       (erc-modules (cons 'sasl erc-modules))
+       (expect (erc-d-t-make-expecter))
+       (server-buffer-name (format "127.0.0.1:%d" port)))
+
+    (ert-info ("Round one, initial authentication succeeds as expected")
+      (with-current-buffer (erc :server "127.0.0.1"
+                                :port port
+                                :nick "tester"
+                                :user "tester"
+                                :password "changeme"
+                                :full-name "tester")
+        (should (string= (buffer-name) server-buffer-name))
+        (funcall expect 10 "You are now logged in as tester"))
+
+      (with-current-buffer (erc-d-t-wait-for 10 (get-buffer "foonet"))
+        (funcall expect 10 "This server is in debug mode")
+        (erc-cmd-JOIN "#chan")
+
+        (with-current-buffer (erc-d-t-wait-for 10 (get-buffer "#chan"))
+          (funcall expect 20 "She is Lavinia, therefore must"))
+
+        (erc-cmd-QUIT "")
+        (funcall expect 10 "finished")))
+
+    (ert-info ("Round two, nick rejected, alternate granted")
+      (with-current-buffer "foonet"
+
+        (ert-info ("Toggle mode off, reconnect")
+          (erc-sasl-mode -1)
+          (erc-cmd-RECONNECT))
+
+        (funcall expect 10 "User modes for tester`")
+        (should-not (cdr (erc-scenarios-common-buflist "foonet")))
+        (should (equal (buffer-name) "foonet"))
+        (should-not (cdr (erc-scenarios-common-buflist "#chan")))
+
+        (with-current-buffer "#chan"
+          (funcall expect 10 "Some enigma, some riddle"))
+
+        (erc-cmd-QUIT "")
+        (funcall expect 10 "finished")))
+
+    (ert-info ("Round three, send alternate nick initially")
+      (with-current-buffer "foonet"
+
+        (ert-info ("Keep mode off, reconnect")
+          (should-not erc-sasl-mode)
+          (should (local-variable-p 'erc-sasl-mode))
+          (erc-cmd-RECONNECT))
+
+        (funcall expect 10 "User modes for tester`")
+        (should-not (cdr (erc-scenarios-common-buflist "foonet")))
+        (should (equal (buffer-name) "foonet"))
+        (should-not (cdr (erc-scenarios-common-buflist "#chan")))
+
+        (with-current-buffer "#chan"
+          (funcall expect 10 "Let our reciprocal vows be remembered."))
+
+        (erc-cmd-QUIT "")
+        (funcall expect 10 "finished")))
+
+    (ert-info ("Round four, authenticated successfully again")
+      (with-current-buffer "foonet"
+
+        (ert-info ("Toggle mode on, reconnect")
+          (should-not erc-sasl-mode)
+          (should (local-variable-p 'erc-sasl-mode))
+          (erc-sasl-mode +1)
+          (erc-cmd-RECONNECT))
+
+        (funcall expect 10 "User modes for tester")
+        (should-not (cdr (erc-scenarios-common-buflist "foonet")))
+        (should (equal (buffer-name) "foonet"))
+        (should-not (cdr (erc-scenarios-common-buflist "#chan")))
+
+        (with-current-buffer "#chan"
+          (funcall expect 10 "Well met; good morrow, Titus and Hortensius."))
+
+        (erc-cmd-QUIT "")))))
+
+;; For local modules, the twin toggle commands `erc-FOO-enable' and
+;; `erc-FOO-disable' affect all buffers of a connection, whereas
+;; `erc-FOO-mode' continues to operate only on the current buffer.
+
+(ert-deftest erc-scenarios-base-local-modules--toggle-helpers ()
+  :tags '(:expensive-test)
+  (erc-scenarios-common-with-cleanup
+      ((erc-scenarios-common-dialog "base/local-modules")
+       (erc-server-flood-penalty 0.1)
+       (dumb-server (erc-d-run "localhost" t 'first 'second 'fourth))
+       (port (process-contact dumb-server :service))
+       (erc-modules (cons 'sasl erc-modules))
+       (expect (erc-d-t-make-expecter))
+       (server-buffer-name (format "127.0.0.1:%d" port)))
+
+    (ert-info ("Initial authentication succeeds as expected")
+      (with-current-buffer (erc :server "127.0.0.1"
+                                :port port
+                                :nick "tester"
+                                :user "tester"
+                                :password "changeme"
+                                :full-name "tester")
+        (should (string= (buffer-name) server-buffer-name))
+        (funcall expect 10 "You are now logged in as tester"))
+
+      (with-current-buffer (erc-d-t-wait-for 10 (get-buffer "foonet"))
+        (funcall expect 10 "This server is in debug mode")
+        (erc-cmd-JOIN "#chan")
+
+        (with-current-buffer (erc-d-t-wait-for 10 (get-buffer "#chan"))
+          (funcall expect 20 "She is Lavinia, therefore must"))
+
+        (erc-cmd-QUIT "")
+        (funcall expect 10 "finished")))
+
+    (ert-info ("Disabling works from a target buffer.")
+      (with-current-buffer "#chan"
+        (should erc-sasl-mode)
+        (call-interactively #'erc-sasl-disable)
+        (should-not erc-sasl-mode)
+        (should (local-variable-p 'erc-sasl-mode))
+        (should-not (buffer-local-value 'erc-sasl-mode (get-buffer "foonet")))
+        (erc-cmd-RECONNECT)
+        (with-current-buffer "#chan"
+          (funcall expect 10 "Some enigma, some riddle")
+          (should-not erc-sasl-mode) ; regression
+          (should (local-variable-p 'erc-sasl-mode))))
+
+      (with-current-buffer "foonet"
+        (should (local-variable-p 'erc-sasl-mode))
+        (funcall expect 10 "User modes for tester`")
+        (erc-cmd-QUIT "")
+        (funcall expect 10 "finished")))
+
+    (ert-info ("Enabling works from a target buffer")
+      (with-current-buffer "#chan"
+        (call-interactively #'erc-sasl-enable)
+        (should (local-variable-p 'erc-sasl-mode))
+        (should erc-sasl-mode)
+        (erc-cmd-RECONNECT)
+        (funcall expect 10 "Well met; good morrow, Titus and Hortensius.")
+        (erc-cmd-QUIT ""))
+
+      (with-current-buffer "foonet"
+        (should (local-variable-p 'erc-sasl-mode))
+        (should erc-sasl-mode)
+        (funcall expect 10 "User modes for tester")))))
+
+;;; erc-scenarios-local-modules.el ends here
diff --git a/test/lisp/erc/resources/base/local-modules/first.eld b/test/lisp/erc/resources/base/local-modules/first.eld
new file mode 100644
index 0000000000..f9181a80fb
--- /dev/null
+++ b/test/lisp/erc/resources/base/local-modules/first.eld
@@ -0,0 +1,53 @@
+;; -*- mode: lisp-data; -*-
+((cap 10 "CAP REQ :sasl"))
+((nick 1 "NICK tester"))
+((user 1 "USER tester 0 * :tester"))
+
+((authenticate 5 "AUTHENTICATE PLAIN")
+ (0.0 ":irc.foonet.org CAP * ACK sasl")
+ (0.0 "AUTHENTICATE +"))
+
+((authenticate 5 "AUTHENTICATE AHRlc3RlcgBjaGFuZ2VtZQ==")
+ (0.0 ":irc.foonet.org 900 * * tester :You are now logged in as tester")
+ (0.01 ":irc.foonet.org 903 * :Authentication successful"))
+
+((cap 3.2 "CAP END")
+ (0.0 ":irc.foonet.org 001 tester :Welcome to the foonet IRC Network tester")
+ (0.0 ":irc.foonet.org 002 tester :Your host is irc.foonet.org, running version ergo-v2.8.0")
+ (0.2 ":irc.foonet.org 003 tester :This server was created Sun, 20 Nov 2022 23:10:36 UTC")
+ (0.0 ":irc.foonet.org 004 tester irc.foonet.org ergo-v2.8.0 BERTZios CEIMRUabefhiklmnoqstuv Iabefhkloqv")
+ (0.0 ":irc.foonet.org 005 tester AWAYLEN=390 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.0 ":irc.foonet.org 005 tester MAXLIST=beI:60 MAXTARGETS=4 MODES MONITOR=100 NETWORK=foonet NICKLEN=32 PREFIX=(qaohv)~&@%+ STATUSMSG=~&@%+ TARGMAX=NAMES:1,LIST:1,KICK:,WHOIS:1,USERHOST:10,PRIVMSG:4,TAGMSG:4,NOTICE:4,MONITOR:100 TOPICLEN=390 UTF8MAPPING=rfc8265 UTF8ONLY WHOX :are supported by this server")
+ (0.0 ":irc.foonet.org 005 tester draft/CHATHISTORY=100 :are supported by this server")
+ (0.0 ":irc.foonet.org 251 tester :There are 0 users and 3 invisible on 1 server(s)")
+ (0.0 ":irc.foonet.org 252 tester 0 :IRC Operators online")
+ (0.0 ":irc.foonet.org 253 tester 0 :unregistered connections")
+ (0.0 ":irc.foonet.org 254 tester 1 :channels formed")
+ (0.0 ":irc.foonet.org 255 tester :I have 3 clients and 0 servers")
+ (0.0 ":irc.foonet.org 265 tester 3 3 :Current local users 3, max 3")
+ (0.0 ":irc.foonet.org 266 tester 3 3 :Current global users 3, max 3")
+ (0.0 ":irc.foonet.org 422 tester :MOTD File is missing")
+ (0.0 ":irc.foonet.org 221 tester +i")
+ (0.0 ":irc.foonet.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."))
+
+((mode 10 "MODE tester +i")
+ (0.02 ":irc.foonet.org 221 tester +i"))
+
+((join 10 "JOIN #chan")
+ (0.00 ":tester!~u@u9iqi96sfwk9s.irc JOIN #chan")
+ (0.06 ":irc.foonet.org 353 tester = #chan :@bob alice tester")
+ (0.01 ":irc.foonet.org 366 tester #chan :End of NAMES list")
+ (0.02 ":bob!~u@2fzfcku68ehqa.irc PRIVMSG #chan :tester, welcome!")
+ (0.01 ":alice!~u@2fzfcku68ehqa.irc PRIVMSG #chan :tester, welcome!")
+ (0.04 ":alice!~u@2fzfcku68ehqa.irc PRIVMSG #chan :bob: Either your unparagoned mistress is dead, or she's outprized by a trifle."))
+
+((mode 12 "MODE #chan")
+ (0.00 ":irc.foonet.org 324 tester #chan +nt")
+ (0.02 ":irc.foonet.org 329 tester #chan 1668985854")
+ (0.98 ":bob!~u@2fzfcku68ehqa.irc PRIVMSG #chan :alice: Come, you are a tedious fool: to the purpose. What was done to Elbow's wife, that he hath cause to complain of ? Come me to what was done to her.")
+ (0.01 ":alice!~u@2fzfcku68ehqa.irc PRIVMSG #chan :bob: She is Lavinia, therefore must be lov'd."))
+
+((quit 10 "QUIT :\2ERC\2")
+ (0.02 ":tester!~u@u9iqi96sfwk9s.irc QUIT :Quit"))
+
+((drop 0 DROP))
diff --git a/test/lisp/erc/resources/base/local-modules/fourth.eld b/test/lisp/erc/resources/base/local-modules/fourth.eld
new file mode 100644
index 0000000000..fd6d62b6cc
--- /dev/null
+++ b/test/lisp/erc/resources/base/local-modules/fourth.eld
@@ -0,0 +1,53 @@
+;; -*- mode: lisp-data; -*-
+((cap 10 "CAP REQ :sasl"))
+((nick 10 "NICK tester`"))
+((user 10 "USER tester 0 * :tester"))
+
+((authenticate 10 "AUTHENTICATE PLAIN")
+ (0.0 ":irc.foonet.org CAP * ACK sasl")
+ (0.0 "AUTHENTICATE +"))
+
+((authenticate 10 "AUTHENTICATE AHRlc3RlcgBjaGFuZ2VtZQ==")
+ (0.00 ":irc.foonet.org 900 * * tester :You are now logged in as tester")
+ (0.01 ":irc.foonet.org 903 * :Authentication successful"))
+
+((cap 10 "CAP END")
+ (0.00 ":irc.foonet.org 001 tester :Welcome to the foonet IRC Network tester")
+ (0.01 ":irc.foonet.org 002 tester :Your host is irc.foonet.org, running version ergo-v2.8.0")
+ (0.01 ":irc.foonet.org 003 tester :This server was created Sun, 20 Nov 2022 23:10:36 UTC")
+ (0.01 ":irc.foonet.org 004 tester irc.foonet.org ergo-v2.8.0 BERTZios CEIMRUabefhiklmnoqstuv Iabefhkloqv")
+ (0.13 ":irc.foonet.org 005 tester AWAYLEN=390 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.foonet.org 005 tester MAXLIST=beI:60 MAXTARGETS=4 MODES MONITOR=100 NETWORK=foonet NICKLEN=32 PREFIX=(qaohv)~&@%+ STATUSMSG=~&@%+ TARGMAX=NAMES:1,LIST:1,KICK:,WHOIS:1,USERHOST:10,PRIVMSG:4,TAGMSG:4,NOTICE:4,MONITOR:100 TOPICLEN=390 UTF8MAPPING=rfc8265 UTF8ONLY WHOX :are supported by this server")
+ (0.03 ":irc.foonet.org 005 tester draft/CHATHISTORY=100 :are supported by this server")
+ (0.00 ":irc.foonet.org 251 tester :There are 0 users and 3 invisible on 1 server(s)")
+ (0.00 ":irc.foonet.org 252 tester 0 :IRC Operators online")
+ (0.00 ":irc.foonet.org 253 tester 0 :unregistered connections")
+ (0.00 ":irc.foonet.org 254 tester 1 :channels formed")
+ (0.00 ":irc.foonet.org 255 tester :I have 3 clients and 0 servers")
+ (0.00 ":irc.foonet.org 265 tester 3 3 :Current local users 3, max 3")
+ (0.00 ":irc.foonet.org 266 tester 3 3 :Current global users 3, max 3")
+ (0.03 ":irc.foonet.org 422 tester :MOTD File is missing")
+ (0.02 ":irc.foonet.org 221 tester +i")
+ (0.00 ":irc.foonet.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."))
+
+((mode 10 "MODE tester +i")
+ (0.0 ":irc.foonet.org 221 tester +i"))
+
+((join 10 "JOIN #chan")
+ (0.00 ":tester!~u@u9iqi96sfwk9s.irc JOIN #chan")
+ (0.09 ":irc.foonet.org 353 tester = #chan :alice tester @bob")
+ (0.01 ":irc.foonet.org 366 tester #chan :End of NAMES list")
+ (0.00 ":alice!~u@2fzfcku68ehqa.irc PRIVMSG #chan :tester, welcome!")
+ (0.00 ":bob!~u@2fzfcku68ehqa.irc PRIVMSG #chan :tester, welcome!")
+ (0.03 ":alice!~u@2fzfcku68ehqa.irc PRIVMSG #chan :bob: And both shall cease, without your remedy.")
+ (0.02 ":bob!~u@2fzfcku68ehqa.irc PRIVMSG #chan :alice: Nay, tarry; I'll go along with thee: I can tell thee pretty tales of the duke."))
+
+((mode 10 "MODE #chan")
+ (0.00 ":irc.foonet.org 324 tester #chan +nt")
+ (0.01 ":irc.foonet.org 329 tester #chan 1668985854")
+ (0.03 ":alice!~u@2fzfcku68ehqa.irc PRIVMSG #chan :bob: Do: I'll take the sacrament on't, how and which way you will.")
+ (0.00 ":bob!~u@2fzfcku68ehqa.irc PRIVMSG #chan :alice: Worthy Macbeth, we stay upon your leisure.")
+ (0.00 ":alice!~u@2fzfcku68ehqa.irc PRIVMSG #chan :bob: Well met; good morrow, Titus and Hortensius."))
+
+((quit 10 "QUIT :\2ERC\2")
+ (0.03 ":tester!~u@u9iqi96sfwk9s.irc QUIT :Quit"))
diff --git a/test/lisp/erc/resources/base/local-modules/second.eld b/test/lisp/erc/resources/base/local-modules/second.eld
new file mode 100644
index 0000000000..a96103b2aa
--- /dev/null
+++ b/test/lisp/erc/resources/base/local-modules/second.eld
@@ -0,0 +1,47 @@
+;; -*- mode: lisp-data; -*-
+((pass 10 "PASS :changeme"))
+((nick 1 "NICK tester"))
+((user 1 "USER tester 0 * :tester")
+ (0.0 ":irc.foonet.org 433 * tester :Nickname is reserved by a different account"))
+
+((nick 10 "NICK tester`")
+ (0.01 ":irc.foonet.org FAIL NICK NICKNAME_RESERVED tester :Nickname is reserved by a different account")
+ (0.06 ":irc.foonet.org 001 tester` :Welcome to the foonet IRC Network tester`")
+ (0.01 ":irc.foonet.org 002 tester` :Your host is irc.foonet.org, running version ergo-v2.8.0")
+ (0.01 ":irc.foonet.org 003 tester` :This server was created Sun, 20 Nov 2022 23:10:36 UTC")
+ (0.01 ":irc.foonet.org 004 tester` irc.foonet.org ergo-v2.8.0 BERTZios CEIMRUabefhiklmnoqstuv Iabefhkloqv")
+ (0.01 ":irc.foonet.org 005 tester` AWAYLEN=390 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.foonet.org 005 tester` MAXLIST=beI:60 MAXTARGETS=4 MODES MONITOR=100 NETWORK=foonet NICKLEN=32 PREFIX=(qaohv)~&@%+ STATUSMSG=~&@%+ TARGMAX=NAMES:1,LIST:1,KICK:,WHOIS:1,USERHOST:10,PRIVMSG:4,TAGMSG:4,NOTICE:4,MONITOR:100 TOPICLEN=390 UTF8MAPPING=rfc8265 UTF8ONLY WHOX :are supported by this server")
+ (0.01 ":irc.foonet.org 005 tester` draft/CHATHISTORY=100 :are supported by this server")
+ (0.00 ":irc.foonet.org 251 tester` :There are 0 users and 3 invisible on 1 server(s)")
+ (0.00 ":irc.foonet.org 252 tester` 0 :IRC Operators online")
+ (0.02 ":irc.foonet.org 253 tester` 0 :unregistered connections")
+ (0.00 ":irc.foonet.org 254 tester` 1 :channels formed")
+ (0.00 ":irc.foonet.org 255 tester` :I have 3 clients and 0 servers")
+ (0.00 ":irc.foonet.org 265 tester` 3 3 :Current local users 3, max 3")
+ (0.00 ":irc.foonet.org 266 tester` 3 3 :Current global users 3, max 3")
+ (0.00 ":irc.foonet.org 422 tester` :MOTD File is missing")
+ (0.02 ":irc.foonet.org 221 tester` +i")
+ (0.00 ":irc.foonet.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."))
+
+((mode 12 "MODE tester` +i")
+ (0.0 ":irc.foonet.org 221 tester` +i"))
+
+((join 10 "JOIN #chan")
+ (0.00 ":tester`!~u@u9iqi96sfwk9s.irc JOIN #chan")
+ (0.08 ":irc.foonet.org 353 tester` = #chan :@bob alice tester`")
+ (0.01 ":irc.foonet.org 366 tester` #chan :End of NAMES list")
+ (0.00 ":alice!~u@2fzfcku68ehqa.irc PRIVMSG #chan :tester`, welcome!")
+ (0.01 ":bob!~u@2fzfcku68ehqa.irc PRIVMSG #chan :tester`, welcome!")
+ (0.05 ":bob!~u@2fzfcku68ehqa.irc PRIVMSG #chan :alice: And Jove, for your love, would infringe an oath."))
+
+((mode 10 "MODE #chan")
+ (0.00 ":irc.foonet.org 324 tester` #chan +nt")
+ (0.02 ":irc.foonet.org 329 tester` #chan 1668985854")
+ (0.07 ":alice!~u@2fzfcku68ehqa.irc PRIVMSG #chan :bob: To you that know them not. This to my mother.")
+ (0.00 ":bob!~u@2fzfcku68ehqa.irc PRIVMSG #chan :alice: Some enigma, some riddle: come, thy l'envoy; begin."))
+
+((quit 1 "QUIT :\2ERC\2")
+ (0.03 ":tester`!~u@u9iqi96sfwk9s.irc QUIT"))
+
+((drop 0 DROP))
diff --git a/test/lisp/erc/resources/base/local-modules/third.eld b/test/lisp/erc/resources/base/local-modules/third.eld
new file mode 100644
index 0000000000..060083656a
--- /dev/null
+++ b/test/lisp/erc/resources/base/local-modules/third.eld
@@ -0,0 +1,43 @@
+;; -*- mode: lisp-data; -*-
+((pass 10 "PASS :changeme"))
+((nick 1 "NICK tester`"))
+((user 1 "USER tester 0 * :tester")
+ (0.06 ":irc.foonet.org 001 tester` :Welcome to the foonet IRC Network tester`")
+ (0.01 ":irc.foonet.org 002 tester` :Your host is irc.foonet.org, running version ergo-v2.8.0")
+ (0.01 ":irc.foonet.org 003 tester` :This server was created Sun, 20 Nov 2022 23:10:36 UTC")
+ (0.01 ":irc.foonet.org 004 tester` irc.foonet.org ergo-v2.8.0 BERTZios CEIMRUabefhiklmnoqstuv Iabefhkloqv")
+ (0.01 ":irc.foonet.org 005 tester` AWAYLEN=390 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.foonet.org 005 tester` MAXLIST=beI:60 MAXTARGETS=4 MODES MONITOR=100 NETWORK=foonet NICKLEN=32 PREFIX=(qaohv)~&@%+ STATUSMSG=~&@%+ TARGMAX=NAMES:1,LIST:1,KICK:,WHOIS:1,USERHOST:10,PRIVMSG:4,TAGMSG:4,NOTICE:4,MONITOR:100 TOPICLEN=390 UTF8MAPPING=rfc8265 UTF8ONLY WHOX :are supported by this server")
+ (0.01 ":irc.foonet.org 005 tester` draft/CHATHISTORY=100 :are supported by this server")
+ (0.00 ":irc.foonet.org 251 tester` :There are 0 users and 3 invisible on 1 server(s)")
+ (0.00 ":irc.foonet.org 252 tester` 0 :IRC Operators online")
+ (0.02 ":irc.foonet.org 253 tester` 0 :unregistered connections")
+ (0.00 ":irc.foonet.org 254 tester` 1 :channels formed")
+ (0.00 ":irc.foonet.org 255 tester` :I have 3 clients and 0 servers")
+ (0.00 ":irc.foonet.org 265 tester` 3 3 :Current local users 3, max 3")
+ (0.00 ":irc.foonet.org 266 tester` 3 3 :Current global users 3, max 3")
+ (0.00 ":irc.foonet.org 422 tester` :MOTD File is missing")
+ (0.02 ":irc.foonet.org 221 tester` +i")
+ (0.00 ":irc.foonet.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."))
+
+((mode 12 "MODE tester` +i")
+ (0.0 ":irc.foonet.org 221 tester` +i"))
+
+((join 10 "JOIN #chan")
+ (0.00 ":tester`!~u@u9iqi96sfwk9s.irc JOIN #chan")
+ (0.08 ":irc.foonet.org 353 tester` = #chan :@bob alice tester`")
+ (0.01 ":irc.foonet.org 366 tester` #chan :End of NAMES list")
+ (0.00 ":alice!~u@2fzfcku68ehqa.irc PRIVMSG #chan :tester`, welcome!")
+ (0.01 ":bob!~u@2fzfcku68ehqa.irc PRIVMSG #chan :tester`, welcome!")
+ (0.05 ":bob!~u@2fzfcku68ehqa.irc PRIVMSG #chan :alice: With pomp, with triumph, and with revelling."))
+
+((mode 10 "MODE #chan")
+ (0.00 ":irc.foonet.org 324 tester` #chan +nt")
+ (0.02 ":irc.foonet.org 329 tester` #chan 1668985854")
+ (0.00 ":alice!~u@2fzfcku68ehqa.irc PRIVMSG #chan :bob: No remedy, my lord, when walls are so wilful to hear without warning.")
+ (0.01 ":bob!~u@2fzfcku68ehqa.irc PRIVMSG #chan :alice: Let our reciprocal vows be remembered. You have many opportunities to cut him off; if your will want not, time and place will be fruitfully offered. There is nothing done if he return the conqueror; then am I the prisoner, and his bed my gaol; from the loathed warmth whereof deliver me, and supply the place for your labour."))
+
+((quit 1 "QUIT :\2ERC\2")
+ (0.03 ":tester`!~u@u9iqi96sfwk9s.irc QUIT :Quit"))
+
+((drop 0 DROP))
-- 
2.38.1


^ permalink raw reply related	[flat|nested] 54+ messages in thread

* bug#29108: 25.3; ERC SASL support
       [not found]                               ` <87r0xvks03.fsf@neverwas.me>
@ 2022-11-24  2:49                                 ` Amin Bandali
       [not found]                                 ` <87r0xtnk24.fsf@gnu.org>
  1 sibling, 0 replies; 54+ messages in thread
From: Amin Bandali @ 2022-11-24  2:49 UTC (permalink / raw)
  To: J.P.; +Cc: emacs-erc, 29108-done

Hey J.P., (all,)

J.P. writes:

> v14. Revised docs a bit and renamed some compat functions. Added a 900
> handler to erc-backend. Added secrets-wrapping for auth-source-pass
> results in erc-compat. Ditched hook indirection for AUTHENTICATE
> handler.
>

Thanks!  I've pushed all of these patches.  All but one of them
were committed without any additional changes; and that one was
0006-Add-non-IRCv3-SASL-module-to-ERC.patch, where I just added
the missing entries for doc/misc/erc.texi and etc/ERC-NEWS to the
commit message.

> Subject: [PATCH 4/8] Support local ERC modules in erc-mode buffers
[...]
> diff --git a/doc/misc/erc.texi b/doc/misc/erc.texi
> index 0d807e323e..b9c6e33d36 100644
> --- a/doc/misc/erc.texi
> +++ b/doc/misc/erc.texi
> @@ -390,8 +390,11 @@ 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}.
> +When removing a module outside of the Custom ecosystem, you may wish
> +to ensure it's disabled by invoking its associated minor-mode toggle,
> +such as @kbd{M-x erc-spelling-mode @key{RET}}.  Note that, these days,
> +calling @code{erc-update-modules} in an init file is typically
> +unnecessary.

What do you think about tweaking the last sentence to be more specific
about when calling `erc-update-modules' may still be needed, maybe
with a short example, please?

> Subject: [PATCH 6/8] Add non-IRCv3 SASL module to ERC
[...]
> diff --git a/doc/misc/erc.texi b/doc/misc/erc.texi
[...]

What do you think about adding a few short examples to the manual
showing the usage of each of the variants/mechanisms with a call to
`erc-tls'?  Kind of like the ones in "Connecting to an IRC Server"
that show how to use `:client-certificate' for instance.

Many thanks for all of your work on implementing and landing SASL
support for ERC, a feature that many ERC users (myself included)
have wished for and looked forward to seeing in ERC for years!

-amin





^ permalink raw reply	[flat|nested] 54+ messages in thread

* bug#29108: 25.3; ERC SASL support
       [not found]                                 ` <87r0xtnk24.fsf@gnu.org>
@ 2022-11-25 14:43                                   ` J.P.
       [not found]                                   ` <87wn7jgkne.fsf@neverwas.me>
  1 sibling, 0 replies; 54+ messages in thread
From: J.P. @ 2022-11-25 14:43 UTC (permalink / raw)
  To: Amin Bandali; +Cc: 29108, emacs-erc

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

Amin Bandali <bandali@gnu.org> writes:

> Hey J.P., (all,)

Hey :)

> J.P. writes:
>
>> v14. Revised docs a bit and renamed some compat functions. Added a 900
>> handler to erc-backend. Added secrets-wrapping for auth-source-pass
>> results in erc-compat. Ditched hook indirection for AUTHENTICATE
>> handler.
>>
>
> Thanks!  I've pushed all of these patches.

Thanks for bailing me out. (This was fast becoming the "Swingers
answering machine" of bug threads.)

> All but one of them were committed without any additional changes; and
> that one was 0006-Add-non-IRCv3-SASL-module-to-ERC.patch, where I just
> added the missing entries for doc/misc/erc.texi and etc/ERC-NEWS to
> the commit message.

Oof. Thanks. I also forgot the bug number on

  0007-Accept-functions-in-place-of-passwords-in-ERC.patch

despite being kindly warned of that eventuality.

>> Subject: [PATCH 4/8] Support local ERC modules in erc-mode buffers
> [...]
>> diff --git a/doc/misc/erc.texi b/doc/misc/erc.texi
>> index 0d807e323e..b9c6e33d36 100644
>> --- a/doc/misc/erc.texi
>> +++ b/doc/misc/erc.texi
>> @@ -390,8 +390,11 @@ 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}.
>> +When removing a module outside of the Custom ecosystem, you may wish
>> +to ensure it's disabled by invoking its associated minor-mode toggle,
>> +such as @kbd{M-x erc-spelling-mode @key{RET}}.  Note that, these days,
>> +calling @code{erc-update-modules} in an init file is typically
>> +unnecessary.
>
> What do you think about tweaking the last sentence to be more specific
> about when calling `erc-update-modules' may still be needed, maybe
> with a short example, please?

Right. Too cryptic. I've adjusted things in the second patch but am
happy to redo/revise, as always. (The first patch contains a bug fix.)

>> Subject: [PATCH 6/8] Add non-IRCv3 SASL module to ERC
> [...]
>> diff --git a/doc/misc/erc.texi b/doc/misc/erc.texi
> [...]
>
> What do you think about adding a few short examples to the manual
> showing the usage of each of the variants/mechanisms with a call to
> `erc-tls'?  Kind of like the ones in "Connecting to an IRC Server"
> that show how to use `:client-certificate' for instance.

Good call. I've attempted something like that in a separate "examples"
section (2nd patch). I'm hesitant about the last, "multi-network"
example, though. It sort of implies we're committing to supporting
let-binding as a means of specifying per-network local-module options,
going forward, which maybe also puts us on the hook for (eventually)
providing a mechanism to make options bookkeeping easier for would-be
local-module authors. OTOH, neither of those is as yet a realistic
problem.

Speaking of maintenance burdens, I think `erc-sasl-password' is too
overloaded and unwieldy, particularly WRT the "non-nil symbol" form. And
falling back on `:id' is redundant because `erc-auth-source-search'
already does that. So, as penance for my ugly API design, I've attached
a (third) patch that tries to corral some of the crazy by adding an
optional auth-source query function to house the more nuanced
functionality (for those actually wanting it) while sparing everyone
else the needless complexity. (That's the idea, anyway.)

> Many thanks for all of your work on implementing and landing SASL
> support for ERC, a feature that many ERC users (myself included)
> have wished for and looked forward to seeing in ERC for years!
>
> -amin

My pleasure! (Although I should've been quicker to admit that my older
POC efforts weren't suitable for prime time without serious reworking.)


[-- Warning: decoded text below may be mangled, UTF-8 assumed --]
[-- Attachment #2: 0001-Add-erc-sasl-auth-source-function-to-cached-options.patch --]
[-- Type: text/x-patch, Size: 6005 bytes --]

From f406d32c6ea6a5e08f660bafd4ea30767936b799 Mon Sep 17 00:00:00 2001
From: "F. Jason Park" <jp@neverwas.me>
Date: Wed, 23 Nov 2022 21:31:19 -0800
Subject: [PATCH 1/3] Add erc-sasl-auth-source-function to cached options

* lisp/erc/erc-sasl.el (erc-sasl--read-password): Consult cached
options instead of `erc-sasl-auth-source-function'.
(erc-sasl--init): Add `erc-sasl-auth-source-function' to
`erc-sasl--options'.

* test/lisp/erc/erc-sasl-tests.el (erc-sasl--read-password--basic,
erc-sasl--read-password--auth-source): Look for original value of
`erc-sasl-auth-source-function' in `erc-sasl--options' under the
`authfn' key.
---
 lisp/erc/erc-sasl.el            | 10 ++++++----
 test/lisp/erc/erc-sasl-tests.el | 31 ++++++++++++++++---------------
 2 files changed, 22 insertions(+), 19 deletions(-)

diff --git a/lisp/erc/erc-sasl.el b/lisp/erc/erc-sasl.el
index 9084d873ce..5ee7169de5 100644
--- a/lisp/erc/erc-sasl.el
+++ b/lisp/erc/erc-sasl.el
@@ -137,10 +137,11 @@ erc-sasl--read-password
       ((found (pcase (alist-get 'password erc-sasl--options)
                 (:password erc-session-password)
                 ((and (pred stringp) v) (unless (string-empty-p v) v))
-                ((and (guard erc-sasl-auth-source-function)
-                      v (let host
-                          (or v (erc-networks--id-given erc-networks--id))))
-                 (apply erc-sasl-auth-source-function
+                ((and (let fn (alist-get 'authfn erc-sasl--options))
+                      (guard fn) v
+                      (let host
+                        (or v (erc-networks--id-given erc-networks--id))))
+                 (apply fn
                         :user (erc-sasl--get-user)
                         (and host (list :host (symbol-name host))))))))
       (copy-sequence (erc--unfun found))
@@ -293,6 +294,7 @@ erc-sasl--init
             `((user . ,erc-sasl-user)
               (password . ,erc-sasl-password)
               (mechanism . ,erc-sasl-mechanism)
+              (authfn . ,erc-sasl-auth-source-function)
               (authzid . ,erc-sasl-authzid)))))
 
 (defun erc-sasl--mechanism-offered-p (offered)
diff --git a/test/lisp/erc/erc-sasl-tests.el b/test/lisp/erc/erc-sasl-tests.el
index 64593ca270..a0e871979a 100644
--- a/test/lisp/erc/erc-sasl-tests.el
+++ b/test/lisp/erc/erc-sasl-tests.el
@@ -42,17 +42,17 @@ erc-sasl--read-password--basic
           (erc-sasl--options '((password . :password))))
       (should (string= (erc-sasl--read-password nil) "foo"))))
 
-  (ert-info ("Fallback to prompt skip auth-source")
-    (should-not erc-sasl-auth-source-function)
-    (let ((erc-session-password "bar")
-          (erc-networks--id (erc-networks--id-create nil)))
+  (ert-info ("Prompt when no authfn and :password resolves to nil")
+    (let ((erc-session-password nil)
+          (erc-sasl--options
+           '((password . :password) (user . :user) (authfn))))
       (should (string= (ert-simulate-keys "bar\r"
                          (erc-sasl--read-password "?"))
                        "bar"))))
 
-  (ert-info ("Prompt when auth-source fails and `erc-sasl-password' null")
-    (let ((erc-sasl--options '((password)))
-          (erc-sasl-auth-source-function #'ignore))
+  (ert-info ("Prompt when auth-source fails and `erc-session-password' null")
+    (should-not erc-session-password)
+    (let ((erc-sasl--options '((password) (authfn . ignore))))
       (should (string= (ert-simulate-keys "baz\r"
                          (erc-sasl--read-password "pwd:"))
                        "baz")))))
@@ -71,36 +71,37 @@ erc-sasl--read-password--auth-source
            (erc-session-port 6697)
            (erc-networks--id (erc-networks--id-create nil))
            calls
-           (erc-sasl-auth-source-function
-            (lambda (&rest r)
-              (push r calls)
-              (apply #'erc--auth-source-search r)))
+           (fn (lambda (&rest r)
+                 (push r calls)
+                 (apply #'erc--auth-source-search r)))
            erc-server-announced-name ; too early
            auth-source-do-cache)
 
       (ert-info ("Symbol as password specifies machine")
-        (let ((erc-sasl--options '((user . "bob") (password . FSF.chat)))
+        (let ((erc-sasl--options
+               `((user . "bob") (password . FSF.chat) (authfn . ,fn)))
               (erc-networks--id (make-erc-networks--id)))
           (should (string= (erc-sasl--read-password nil) "sesame"))
           (should (equal (pop calls) '(:user "bob" :host "FSF.chat")))))
 
       (ert-info ("ID for :host and `erc-session-username' for :user") ; *1
         (let ((erc-session-username "bob")
-              (erc-sasl--options '((user . :user) (password)))
+              (erc-sasl--options `((user . :user) (password) (authfn . ,fn)))
               (erc-networks--id (erc-networks--id-create 'GNU/chat)))
           (should (string= (erc-sasl--read-password nil) "spam"))
           (should (equal (pop calls) '(:user "bob" :host "GNU/chat")))))
 
       (ert-info ("ID for :host and current nick for :user") ; *1
         (let ((erc-server-current-nick "bob")
-              (erc-sasl--options '((user . :nick) (password)))
+              (erc-sasl--options `((user . :nick) (password) (authfn . ,fn)))
               (erc-networks--id (erc-networks--id-create 'GNU/chat)))
           (should (string= (erc-sasl--read-password nil) "spam"))
           (should (equal (pop calls) '(:user "bob" :host "GNU/chat")))))
 
       (ert-info ("Symbol as password, entry lacks user field")
         (let ((erc-server-current-nick "fake")
-              (erc-sasl--options '((user . :nick) (password . MyHost)))
+              (erc-sasl--options
+               `((user . :nick) (password . MyHost) (authfn . ,fn)))
               (erc-networks--id (erc-networks--id-create 'GNU/chat)))
           (should (string= (erc-sasl--read-password nil) "123"))
           (should (equal (pop calls) '(:user "fake" :host "MyHost"))))))))
-- 
2.38.1


[-- Warning: decoded text below may be mangled, UTF-8 assumed --]
[-- Attachment #3: 0002-doc-misc-erc.texi-Revise-SASL-and-modules-chapters.patch --]
[-- Type: text/x-patch, Size: 3580 bytes --]

From 2ac3d4eb39b53256edfe6ddba541da2c81d64fc1 Mon Sep 17 00:00:00 2001
From: "F. Jason Park" <jp@neverwas.me>
Date: Wed, 23 Nov 2022 21:31:19 -0800
Subject: [PATCH 2/3] * doc/misc/erc.texi: Revise SASL and modules chapters.

---
 doc/misc/erc.texi | 82 ++++++++++++++++++++++++++++++++++++++++++++---
 1 file changed, 78 insertions(+), 4 deletions(-)

diff --git a/doc/misc/erc.texi b/doc/misc/erc.texi
index f86465fed7..5317a3e5aa 100644
--- a/doc/misc/erc.texi
+++ b/doc/misc/erc.texi
@@ -392,10 +392,14 @@ Modules
 There is a spiffy customize interface, which may be reached by typing
 @kbd{M-x customize-option @key{RET} erc-modules @key{RET}}.
 When removing a module outside of the Custom ecosystem, you may wish
-to ensure it's disabled by invoking its associated minor-mode toggle,
-such as @kbd{M-x erc-spelling-mode @key{RET}}.  Note that, these days,
-calling @code{erc-update-modules} in an init file is typically
-unnecessary.
+to ensure it's disabled by invoking its associated minor-mode toggle
+with a nonpositive prefix argument, for example, @kbd{C-u - M-x
+erc-spelling-mode @key{RET}}.  Additionally, if you plan on loading
+third-party modules that perform atypical setup on activation, you may
+need to arrange for calling @code{erc-update-modules} in your init
+file.  Examples of such setup might include registering an
+@code{erc-before-connect} hook, advising @code{erc-open}, and
+modifying @code{erc-modules} itself.
 
 The following is a list of available modules.
 
@@ -1082,6 +1086,76 @@ SASL
 leave this set to @code{nil}.
 @end defopt
 
+@subheading Examples
+
+@itemize @bullet
+@item
+Defaults
+
+@lisp
+(erc-tls :server "irc.libera.chat" :port 6697
+         :nick "aph"
+         :user "APHacker"
+         :password "changeme")
+@end lisp
+
+Here, after adding @code{sasl} to @code{erc-modules} via the Customize
+interface, you authenticate to Libera using the @samp{PLAIN} mechanism
+and your NickServ credentials, @samp{APHacker} and @samp{changeme}.
+
+@item
+External
+
+@lisp
+(setopt erc-sasl-mechanism 'external)
+
+(erc-tls :server "irc.libera.chat" :port 6697 :nick "aph"
+         :client-certificate
+         '("/home/aph/my.key" "/home/aph/my.crt"))
+@end lisp
+
+You decide to switch things up and try out the @samp{EXTERNAL}
+mechanism.  You follow your network's instructions for telling
+NickServ about your client-certificate's fingerprint, and you
+authenticate successfully.
+
+@item
+Multiple networks
+
+@example
+# ~/.authinfo.gpg
+
+machine irc.libera.chat key /home/aph/my.key cert /home/aph/my.crt
+machine Example.Net login alyssa password sEcReT
+machine Example.Net login aph-bot password sesame
+@end example
+
+@lisp
+;; init.el
+
+(defun my-erc-up (network)
+  (interactive "Snetwork: ")
+
+  (pcase network
+    ('libera
+     (let ((erc-sasl-mechanism 'external))
+       (erc-tls :server "irc.libera.chat" :port 6697
+                :client-certificate t)))
+    ('example
+     (let ((erc-sasl-auth-source-function #'erc-auth-source-search)
+           (erc-sasl-password 'Example.Net))
+       (erc-tls :server "irc.example.net" :port 6697
+                :user "alyssa")))))
+@end lisp
+
+You've started storing your credentials with auth-source and have
+decided to try SASL on another network as well.  But there's a catch:
+this network doesn't support @samp{EXTERNAL}.  You use
+@code{let}-binding to get around this and successfully authenticate to
+both networks.
+
+@end itemize
+
 @subheading Troubleshooting
 
 @strong{Warning:} ERC's SASL offering is currently limited by a lack
-- 
2.38.1


[-- Warning: decoded text below may be mangled, UTF-8 assumed --]
[-- Attachment #4: 0003-Simplify-erc-sasl-s-auth-source-API.patch --]
[-- Type: text/x-patch, Size: 13109 bytes --]

From 936c25dd844079edcc474a50cc82a1fca2b196f4 Mon Sep 17 00:00:00 2001
From: "F. Jason Park" <jp@neverwas.me>
Date: Wed, 23 Nov 2022 21:31:19 -0800
Subject: [PATCH 3/3] Simplify erc-sasl's auth-source API

* doc/misc/erc.texi: Revise descriptions in SASL chapter to reflect
simplified auth-source options.

* lisp/erc/erc-sasl.el (erc-sasl-password,
erc-sasl-auth-source-function): Revise doc strings.
(erc-sasl-auth-source-password-as-host): New function to serve as
more useful choice for option `erc-sasl-auth-source-function'.
(erc-sasl--read-password): Promote auth-source to poll position, above
an explicit string and `:password'.

* test/lisp/erc/erc-sasl-tests.el (erc-sasl--read-password--basic):
Massage tests to conform to simplified `erc-sasl-password' API.
---
 doc/misc/erc.texi               | 40 +++++++++++-------
 lisp/erc/erc-sasl.el            | 74 ++++++++++++++++++++-------------
 test/lisp/erc/erc-sasl-tests.el | 38 +++++++++++------
 3 files changed, 96 insertions(+), 56 deletions(-)

diff --git a/doc/misc/erc.texi b/doc/misc/erc.texi
index 5317a3e5aa..6f9656ca6b 100644
--- a/doc/misc/erc.texi
+++ b/doc/misc/erc.texi
@@ -1055,17 +1055,10 @@ SASL
 @code{erc-tls} with @code{:password} set to your NickServ password.
 
 You can also set this to a nonemtpy string, and ERC will send that
-when needed, no questions asked.  If you instead give a non-@code{nil}
-symbol (other than @code{:password}), 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 resolved 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, and it's
-@code{nil} by default.  As a last resort, ERC will prompt you for
-input.
+when needed, no questions asked.  There is one catch, though: if you
+set @code{erc-sasl-auth-source-function} to a function, ERC will
+perform an auth-source query instead.  As last resort in all cases,
+ERC will prompt you for input.
 
 Lastly, if your mechanism is @code{ecdsa-nist256p-challenge}, this
 option should instead hold the file name of your key.
@@ -1075,7 +1068,23 @@ SASL
 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.
+@code{erc-auth-source-search} for queries to be performed.  For
+convenience, this module provides the following as a possible value:
+
+@defun erc-sasl-auth-source-password-as-host &rest plist
+Setting @code{erc-sasl-auth-source-function} to this function tells
+ERC to use @code{erc-sasl-password} for the @code{:host} field when
+querying auth-source, even if its value is the default
+@code{:password}, in which case ERC knows to ``resolve'' it to
+@code{erc-session-password} and use that as long as it's
+non-@code{nil}.  Otherwise, ERC just defers to
+@code{erc-auth-source-search} to determine the @code{:host}, along
+with everything else.
+@end defun
+
+Regardless, so long as this option specifies a function, ERC will pass
+it the ``resolved'' value of @code{erc-sasl-user} for the auth-source
+@code{:user} parameter.
 @end defopt
 
 @defopt erc-sasl-authzid
@@ -1142,10 +1151,11 @@ SASL
        (erc-tls :server "irc.libera.chat" :port 6697
                 :client-certificate t)))
     ('example
-     (let ((erc-sasl-auth-source-function #'erc-auth-source-search)
-           (erc-sasl-password 'Example.Net))
+     (let ((erc-sasl-auth-source-function
+            #'erc-sasl-auth-source-password-as-host))
        (erc-tls :server "irc.example.net" :port 6697
-                :user "alyssa")))))
+                :user "alyssa"
+                :password "Example.Net")))))
 @end lisp
 
 You've started storing your credentials with auth-source and have
diff --git a/lisp/erc/erc-sasl.el b/lisp/erc/erc-sasl.el
index 5ee7169de5..e149c94085 100644
--- a/lisp/erc/erc-sasl.el
+++ b/lisp/erc/erc-sasl.el
@@ -77,15 +77,14 @@ erc-sasl-user
 
 (defcustom erc-sasl-password :password
   "Optional account password to send when authenticating.
-When the value is a string, ERC will use it unconditionally for
-most mechanisms.  Likewise with `:password', except ERC will
-instead use the \"session password\" on file, which often
-originates from the entry-point commands `erc' or `erc-tls'.
-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 prompt for input.
+When `erc-sasl-auth-source-function' is a function, ERC will
+attempt an auth-source query and prompt for input if it fails.
+Otherwise, when the value is a nonempty string, ERC will use it
+unconditionally for most mechanisms.  Likewise with `:password',
+except ERC will instead use the \"session password\" on file, if
+any, which often originates from the entry-point commands `erc'
+or `erc-tls'.  As with auth-source, ERC will prompt for input as
+a fallback.
 
 Note that, with `:password', ERC will forgo sending a traditional
 server password via the IRC \"PASS\" command.  Also, when
@@ -95,15 +94,18 @@ erc-sasl-password
 
 (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
-includes `erc-sasl-user' for the `:user' field and
-`erc-sasl-password' for the `:host' field, when the latter option
-is a non-nil, non-keyword symbol.  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)
+If provided, this function should expect to be called with any
+number of keyword params known to `auth-source-search', even
+though, as of ERC 5.5, these consists only of `:user' paired with
+a \"resolved\" `erc-sasl-user' value.  Additionally, all user
+options defined this library, such as `erc-sasl-password', are
+bound to their original values from module initialization.  In
+return, ERC expects a string to send as the SASL password, or
+nil, in which case, ERC will prompt the for input.  See info
+node `(erc) Connecting' for details on ERC's auth-source
+integration."
+  :type '(choice (function-item erc-sasl-auth-source-password-as-host)
+                 (function-item erc-auth-source-search)
                  (const nil)
                  function))
 
@@ -130,20 +132,34 @@ erc-sasl--get-user
     (:nick (erc-downcase (erc-current-nick)))
     (v v)))
 
+(defun erc-sasl-auth-source-password-as-host (&rest plist)
+  "Call `erc-auth-source-search' with `erc-sasl-password' as `:host'.
+But only do so when it's a string or a non-nil symbol, unless
+that symbol is `:password', in which case, use a non-nil
+`erc-session-password' instead.  Otherwise, just defer to
+`erc-auth-source-search' to pick a suitable `:host'."
+  (when erc-sasl-password
+    (when-let ((host (if (eq :password erc-sasl-password)
+                         (and (not (functionp erc-session-password))
+                              erc-session-password)
+                       erc-sasl-password)))
+      (setq plist `(,@plist :host ,(format "%s" host)))))
+  (apply #'erc-auth-source-search plist))
+
 (defun erc-sasl--read-password (prompt)
   "Return configured option or server password.
 PROMPT is passed to `read-passwd' if necessary."
-  (if-let
-      ((found (pcase (alist-get 'password erc-sasl--options)
-                (:password erc-session-password)
-                ((and (pred stringp) v) (unless (string-empty-p v) v))
-                ((and (let fn (alist-get 'authfn erc-sasl--options))
-                      (guard fn) v
-                      (let host
-                        (or v (erc-networks--id-given erc-networks--id))))
-                 (apply fn
-                        :user (erc-sasl--get-user)
-                        (and host (list :host (symbol-name host))))))))
+  (if-let ((found (pcase (alist-get 'password erc-sasl--options)
+                    ((guard (alist-get 'authfn erc-sasl--options))
+                     (let-alist erc-sasl--options
+                       (let ((erc-sasl-user .user)
+                             (erc-sasl-password .password)
+                             (erc-sasl-mechanism .mechanism)
+                             (erc-sasl-authzid .authzid)
+                             (erc-sasl-auth-source-function .authfn))
+                         (funcall .authfn :user (erc-sasl--get-user)))))
+                    (:password erc-session-password)
+                    ((and (pred stringp) v) (unless (string-empty-p v) v)))))
       (copy-sequence (erc--unfun found))
     (read-passwd prompt)))
 
diff --git a/test/lisp/erc/erc-sasl-tests.el b/test/lisp/erc/erc-sasl-tests.el
index a0e871979a..0e5ea60e5f 100644
--- a/test/lisp/erc/erc-sasl-tests.el
+++ b/test/lisp/erc/erc-sasl-tests.el
@@ -57,6 +57,8 @@ erc-sasl--read-password--basic
                          (erc-sasl--read-password "pwd:"))
                        "baz")))))
 
+;; This mainly tests `erc-sasl-auth-source-password-as-host'.
+
 (ert-deftest erc-sasl--read-password--auth-source ()
   (ert-with-temp-file netrc-file
     :text (string-join
@@ -70,33 +72,42 @@ erc-sasl--read-password--auth-source
            (erc-session-server "irc.gnu.org")
            (erc-session-port 6697)
            (erc-networks--id (erc-networks--id-create nil))
-           calls
-           (fn (lambda (&rest r)
-                 (push r calls)
-                 (apply #'erc--auth-source-search r)))
            erc-server-announced-name ; too early
-           auth-source-do-cache)
+           auth-source-do-cache
+           ;;
+           (fn #'erc-sasl-auth-source-password-as-host)
+           calls)
+
+      (advice-add 'erc-auth-source-search :before
+                  (lambda (&rest r) (push r calls))
+                  '((name . erc-sasl--read-password--auth-source)))
 
       (ert-info ("Symbol as password specifies machine")
         (let ((erc-sasl--options
-               `((user . "bob") (password . FSF.chat) (authfn . ,fn)))
-              (erc-networks--id (make-erc-networks--id)))
+               `((user . "bob") (password . FSF.chat) (authfn . ,fn))))
           (should (string= (erc-sasl--read-password nil) "sesame"))
           (should (equal (pop calls) '(:user "bob" :host "FSF.chat")))))
 
-      (ert-info ("ID for :host and `erc-session-username' for :user") ; *1
+      (ert-info (":password as password resolved to machine")
+        (let ((erc-session-password "FSF.chat")
+              (erc-sasl--options
+               `((user . "bob") (password . :password) (authfn . ,fn))))
+          (should (string= (erc-sasl--read-password nil) "sesame"))
+          (should (equal (pop calls) '(:user "bob" :host "FSF.chat")))))
+
+      (ert-info (":user resolved to `erc-session-username'") ; *1
         (let ((erc-session-username "bob")
               (erc-sasl--options `((user . :user) (password) (authfn . ,fn)))
               (erc-networks--id (erc-networks--id-create 'GNU/chat)))
           (should (string= (erc-sasl--read-password nil) "spam"))
-          (should (equal (pop calls) '(:user "bob" :host "GNU/chat")))))
+          (should (equal (pop calls) '(:user "bob")))))
 
-      (ert-info ("ID for :host and current nick for :user") ; *1
+      (ert-info (":user resolved to current nick") ; *1
         (let ((erc-server-current-nick "bob")
               (erc-sasl--options `((user . :nick) (password) (authfn . ,fn)))
               (erc-networks--id (erc-networks--id-create 'GNU/chat)))
           (should (string= (erc-sasl--read-password nil) "spam"))
-          (should (equal (pop calls) '(:user "bob" :host "GNU/chat")))))
+          (should (equal (pop calls) '(:user "bob")))))
 
       (ert-info ("Symbol as password, entry lacks user field")
         (let ((erc-server-current-nick "fake")
@@ -104,7 +115,10 @@ erc-sasl--read-password--auth-source
                `((user . :nick) (password . MyHost) (authfn . ,fn)))
               (erc-networks--id (erc-networks--id-create 'GNU/chat)))
           (should (string= (erc-sasl--read-password nil) "123"))
-          (should (equal (pop calls) '(:user "fake" :host "MyHost"))))))))
+          (should (equal (pop calls) '(:user "fake" :host "MyHost")))))
+
+      (advice-remove 'erc-auth-source-search
+                     'erc-sasl--read-password--auth-source))))
 
 (ert-deftest erc-sasl-create-client--plain ()
   (let* ((erc-session-password "password123")
-- 
2.38.1


^ permalink raw reply related	[flat|nested] 54+ messages in thread

* bug#29108: 25.3; ERC SASL support
       [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>
  2 siblings, 0 replies; 54+ messages in thread
From: J.P. @ 2022-11-28  0:08 UTC (permalink / raw)
  To: 29108; +Cc: emacs-erc, Amin Bandali

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

v2. Tweaked some docs.


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

From 26bd2f4ea882e9855470865abf853554920d34af Mon Sep 17 00:00:00 2001
From: "F. Jason Park" <jp@neverwas.me>
Date: Sun, 27 Nov 2022 16:02:19 -0800
Subject: [PATCH 0/3] *** NOT A PATCH ***

*** BLURB HERE ***

F. Jason Park (3):
  Add erc-sasl-auth-source-function to cached options
  * doc/misc/erc.texi: Revise SASL and modules chapters.
  Simplify erc-sasl's auth-source API

 doc/misc/erc.texi               | 116 +++++++++++++++++++++++++++-----
 lisp/erc/erc-sasl.el            |  77 +++++++++++++--------
 test/lisp/erc/erc-sasl-tests.el |  61 ++++++++++-------
 3 files changed, 186 insertions(+), 68 deletions(-)

Interdiff:
diff --git a/doc/misc/erc.texi b/doc/misc/erc.texi
index 6f9656ca6b..aa7b9cb947 100644
--- a/doc/misc/erc.texi
+++ b/doc/misc/erc.texi
@@ -1055,8 +1055,8 @@ SASL
 @code{erc-tls} with @code{:password} set to your NickServ password.
 
 You can also set this to a nonemtpy string, and ERC will send that
-when needed, no questions asked.  There is one catch, though: if you
-set @code{erc-sasl-auth-source-function} to a function, ERC will
+when needed, no questions asked.  Or, if you'd rather use auth-source,
+set @code{erc-sasl-auth-source-function} to a function, and ERC will
 perform an auth-source query instead.  As last resort in all cases,
 ERC will prompt you for input.
 
@@ -1082,9 +1082,9 @@ SASL
 with everything else.
 @end defun
 
-Regardless, so long as this option specifies a function, ERC will pass
-it the ``resolved'' value of @code{erc-sasl-user} for the auth-source
-@code{:user} parameter.
+As long as this option specifies a function, ERC will pass it the
+``resolved'' value of @code{erc-sasl-user} for the auth-source
+@code{:user} param.
 @end defopt
 
 @defopt erc-sasl-authzid
diff --git a/lisp/erc/erc-sasl.el b/lisp/erc/erc-sasl.el
index e149c94085..5b2c93988a 100644
--- a/lisp/erc/erc-sasl.el
+++ b/lisp/erc/erc-sasl.el
@@ -96,11 +96,11 @@ erc-sasl-auth-source-function
   "Function to query auth-source for an SASL password.
 If provided, this function should expect to be called with any
 number of keyword params known to `auth-source-search', even
-though, as of ERC 5.5, these consists only of `:user' paired with
-a \"resolved\" `erc-sasl-user' value.  Additionally, all user
-options defined this library, such as `erc-sasl-password', are
-bound to their original values from module initialization.  In
-return, ERC expects a string to send as the SASL password, or
+though ERC itself only specifies `:user' paired with a
+\"resolved\" `erc-sasl-user' value.  When calling this function,
+ERC binds all options defined in this library, such as
+`erc-sasl-password', to their values from entry-point invocation.
+In return, ERC expects a string to send as the SASL password, or
 nil, in which case, ERC will prompt the for input.  See info
 node `(erc) Connecting' for details on ERC's auth-source
 integration."
@@ -137,7 +137,8 @@ erc-sasl-auth-source-password-as-host
 But only do so when it's a string or a non-nil symbol, unless
 that symbol is `:password', in which case, use a non-nil
 `erc-session-password' instead.  Otherwise, just defer to
-`erc-auth-source-search' to pick a suitable `:host'."
+`erc-auth-source-search' to pick a suitable `:host'.  Expect
+PLIST to contain keyword params known to `auth-source-search'."
   (when erc-sasl-password
     (when-let ((host (if (eq :password erc-sasl-password)
                          (and (not (functionp erc-session-password))
@@ -148,7 +149,7 @@ erc-sasl-auth-source-password-as-host
 
 (defun erc-sasl--read-password (prompt)
   "Return configured option or server password.
-PROMPT is passed to `read-passwd' if necessary."
+If necessary, pass PROMPT to `read-passwd'."
   (if-let ((found (pcase (alist-get 'password erc-sasl--options)
                     ((guard (alist-get 'authfn erc-sasl--options))
                      (let-alist erc-sasl--options
-- 
2.38.1


[-- Warning: decoded text below may be mangled, UTF-8 assumed --]
[-- Attachment #3: 0001-Add-erc-sasl-auth-source-function-to-cached-options.patch --]
[-- Type: text/x-patch, Size: 6004 bytes --]

From c257f2b890e0f6a3901d29c5dc7e50fd7cc631e5 Mon Sep 17 00:00:00 2001
From: "F. Jason Park" <jp@neverwas.me>
Date: Wed, 23 Nov 2022 21:31:19 -0800
Subject: [PATCH 1/3] Add erc-sasl-auth-source-function to cached options

* lisp/erc/erc-sasl.el (erc-sasl--read-password): Consult cached
options instead of `erc-sasl-auth-source-function'.
(erc-sasl--init): Add `erc-sasl-auth-source-function' to
`erc-sasl--options'.

* test/lisp/erc/erc-sasl-tests.el (erc-sasl--read-password--basic,
erc-sasl--read-password--auth-source): Look for original value of
`erc-sasl-auth-source-function' in `erc-sasl--options' under the
`authfn' key.
---
 lisp/erc/erc-sasl.el            | 10 ++++++----
 test/lisp/erc/erc-sasl-tests.el | 31 ++++++++++++++++---------------
 2 files changed, 22 insertions(+), 19 deletions(-)

diff --git a/lisp/erc/erc-sasl.el b/lisp/erc/erc-sasl.el
index 9084d873ce..5ee7169de5 100644
--- a/lisp/erc/erc-sasl.el
+++ b/lisp/erc/erc-sasl.el
@@ -137,10 +137,11 @@ erc-sasl--read-password
       ((found (pcase (alist-get 'password erc-sasl--options)
                 (:password erc-session-password)
                 ((and (pred stringp) v) (unless (string-empty-p v) v))
-                ((and (guard erc-sasl-auth-source-function)
-                      v (let host
-                          (or v (erc-networks--id-given erc-networks--id))))
-                 (apply erc-sasl-auth-source-function
+                ((and (let fn (alist-get 'authfn erc-sasl--options))
+                      (guard fn) v
+                      (let host
+                        (or v (erc-networks--id-given erc-networks--id))))
+                 (apply fn
                         :user (erc-sasl--get-user)
                         (and host (list :host (symbol-name host))))))))
       (copy-sequence (erc--unfun found))
@@ -293,6 +294,7 @@ erc-sasl--init
             `((user . ,erc-sasl-user)
               (password . ,erc-sasl-password)
               (mechanism . ,erc-sasl-mechanism)
+              (authfn . ,erc-sasl-auth-source-function)
               (authzid . ,erc-sasl-authzid)))))
 
 (defun erc-sasl--mechanism-offered-p (offered)
diff --git a/test/lisp/erc/erc-sasl-tests.el b/test/lisp/erc/erc-sasl-tests.el
index 64593ca270..3e6828ff64 100644
--- a/test/lisp/erc/erc-sasl-tests.el
+++ b/test/lisp/erc/erc-sasl-tests.el
@@ -42,17 +42,17 @@ erc-sasl--read-password--basic
           (erc-sasl--options '((password . :password))))
       (should (string= (erc-sasl--read-password nil) "foo"))))
 
-  (ert-info ("Fallback to prompt skip auth-source")
-    (should-not erc-sasl-auth-source-function)
-    (let ((erc-session-password "bar")
-          (erc-networks--id (erc-networks--id-create nil)))
+  (ert-info ("Prompt when no authfn and :password resolves to nil")
+    (let ((erc-session-password nil)
+          (erc-sasl--options
+           '((password . :password) (user . :user) (authfn))))
       (should (string= (ert-simulate-keys "bar\r"
                          (erc-sasl--read-password "?"))
                        "bar"))))
 
-  (ert-info ("Prompt when auth-source fails and `erc-sasl-password' null")
-    (let ((erc-sasl--options '((password)))
-          (erc-sasl-auth-source-function #'ignore))
+  (ert-info ("Prompt when auth-source fails and `erc-session-password' null")
+    (should-not erc-session-password)
+    (let ((erc-sasl--options '((password) (authfn . ignore))))
       (should (string= (ert-simulate-keys "baz\r"
                          (erc-sasl--read-password "pwd:"))
                        "baz")))))
@@ -71,36 +71,37 @@ erc-sasl--read-password--auth-source
            (erc-session-port 6697)
            (erc-networks--id (erc-networks--id-create nil))
            calls
-           (erc-sasl-auth-source-function
-            (lambda (&rest r)
-              (push r calls)
-              (apply #'erc--auth-source-search r)))
+           (fn (lambda (&rest r)
+                 (push r calls)
+                 (apply #'erc-auth-source-search r)))
            erc-server-announced-name ; too early
            auth-source-do-cache)
 
       (ert-info ("Symbol as password specifies machine")
-        (let ((erc-sasl--options '((user . "bob") (password . FSF.chat)))
+        (let ((erc-sasl--options
+               `((user . "bob") (password . FSF.chat) (authfn . ,fn)))
               (erc-networks--id (make-erc-networks--id)))
           (should (string= (erc-sasl--read-password nil) "sesame"))
           (should (equal (pop calls) '(:user "bob" :host "FSF.chat")))))
 
       (ert-info ("ID for :host and `erc-session-username' for :user") ; *1
         (let ((erc-session-username "bob")
-              (erc-sasl--options '((user . :user) (password)))
+              (erc-sasl--options `((user . :user) (password) (authfn . ,fn)))
               (erc-networks--id (erc-networks--id-create 'GNU/chat)))
           (should (string= (erc-sasl--read-password nil) "spam"))
           (should (equal (pop calls) '(:user "bob" :host "GNU/chat")))))
 
       (ert-info ("ID for :host and current nick for :user") ; *1
         (let ((erc-server-current-nick "bob")
-              (erc-sasl--options '((user . :nick) (password)))
+              (erc-sasl--options `((user . :nick) (password) (authfn . ,fn)))
               (erc-networks--id (erc-networks--id-create 'GNU/chat)))
           (should (string= (erc-sasl--read-password nil) "spam"))
           (should (equal (pop calls) '(:user "bob" :host "GNU/chat")))))
 
       (ert-info ("Symbol as password, entry lacks user field")
         (let ((erc-server-current-nick "fake")
-              (erc-sasl--options '((user . :nick) (password . MyHost)))
+              (erc-sasl--options
+               `((user . :nick) (password . MyHost) (authfn . ,fn)))
               (erc-networks--id (erc-networks--id-create 'GNU/chat)))
           (should (string= (erc-sasl--read-password nil) "123"))
           (should (equal (pop calls) '(:user "fake" :host "MyHost"))))))))
-- 
2.38.1


[-- Warning: decoded text below may be mangled, UTF-8 assumed --]
[-- Attachment #4: 0002-doc-misc-erc.texi-Revise-SASL-and-modules-chapters.patch --]
[-- Type: text/x-patch, Size: 3580 bytes --]

From c80f7f6a8905c3c5c5404bcbb414a50eb7cc222a Mon Sep 17 00:00:00 2001
From: "F. Jason Park" <jp@neverwas.me>
Date: Wed, 23 Nov 2022 21:31:19 -0800
Subject: [PATCH 2/3] * doc/misc/erc.texi: Revise SASL and modules chapters.

---
 doc/misc/erc.texi | 82 ++++++++++++++++++++++++++++++++++++++++++++---
 1 file changed, 78 insertions(+), 4 deletions(-)

diff --git a/doc/misc/erc.texi b/doc/misc/erc.texi
index f86465fed7..5317a3e5aa 100644
--- a/doc/misc/erc.texi
+++ b/doc/misc/erc.texi
@@ -392,10 +392,14 @@ Modules
 There is a spiffy customize interface, which may be reached by typing
 @kbd{M-x customize-option @key{RET} erc-modules @key{RET}}.
 When removing a module outside of the Custom ecosystem, you may wish
-to ensure it's disabled by invoking its associated minor-mode toggle,
-such as @kbd{M-x erc-spelling-mode @key{RET}}.  Note that, these days,
-calling @code{erc-update-modules} in an init file is typically
-unnecessary.
+to ensure it's disabled by invoking its associated minor-mode toggle
+with a nonpositive prefix argument, for example, @kbd{C-u - M-x
+erc-spelling-mode @key{RET}}.  Additionally, if you plan on loading
+third-party modules that perform atypical setup on activation, you may
+need to arrange for calling @code{erc-update-modules} in your init
+file.  Examples of such setup might include registering an
+@code{erc-before-connect} hook, advising @code{erc-open}, and
+modifying @code{erc-modules} itself.
 
 The following is a list of available modules.
 
@@ -1082,6 +1086,76 @@ SASL
 leave this set to @code{nil}.
 @end defopt
 
+@subheading Examples
+
+@itemize @bullet
+@item
+Defaults
+
+@lisp
+(erc-tls :server "irc.libera.chat" :port 6697
+         :nick "aph"
+         :user "APHacker"
+         :password "changeme")
+@end lisp
+
+Here, after adding @code{sasl} to @code{erc-modules} via the Customize
+interface, you authenticate to Libera using the @samp{PLAIN} mechanism
+and your NickServ credentials, @samp{APHacker} and @samp{changeme}.
+
+@item
+External
+
+@lisp
+(setopt erc-sasl-mechanism 'external)
+
+(erc-tls :server "irc.libera.chat" :port 6697 :nick "aph"
+         :client-certificate
+         '("/home/aph/my.key" "/home/aph/my.crt"))
+@end lisp
+
+You decide to switch things up and try out the @samp{EXTERNAL}
+mechanism.  You follow your network's instructions for telling
+NickServ about your client-certificate's fingerprint, and you
+authenticate successfully.
+
+@item
+Multiple networks
+
+@example
+# ~/.authinfo.gpg
+
+machine irc.libera.chat key /home/aph/my.key cert /home/aph/my.crt
+machine Example.Net login alyssa password sEcReT
+machine Example.Net login aph-bot password sesame
+@end example
+
+@lisp
+;; init.el
+
+(defun my-erc-up (network)
+  (interactive "Snetwork: ")
+
+  (pcase network
+    ('libera
+     (let ((erc-sasl-mechanism 'external))
+       (erc-tls :server "irc.libera.chat" :port 6697
+                :client-certificate t)))
+    ('example
+     (let ((erc-sasl-auth-source-function #'erc-auth-source-search)
+           (erc-sasl-password 'Example.Net))
+       (erc-tls :server "irc.example.net" :port 6697
+                :user "alyssa")))))
+@end lisp
+
+You've started storing your credentials with auth-source and have
+decided to try SASL on another network as well.  But there's a catch:
+this network doesn't support @samp{EXTERNAL}.  You use
+@code{let}-binding to get around this and successfully authenticate to
+both networks.
+
+@end itemize
+
 @subheading Troubleshooting
 
 @strong{Warning:} ERC's SASL offering is currently limited by a lack
-- 
2.38.1


[-- Warning: decoded text below may be mangled, UTF-8 assumed --]
[-- Attachment #5: 0003-Simplify-erc-sasl-s-auth-source-API.patch --]
[-- Type: text/x-patch, Size: 13218 bytes --]

From 26bd2f4ea882e9855470865abf853554920d34af Mon Sep 17 00:00:00 2001
From: "F. Jason Park" <jp@neverwas.me>
Date: Wed, 23 Nov 2022 21:31:19 -0800
Subject: [PATCH 3/3] Simplify erc-sasl's auth-source API

* doc/misc/erc.texi: Revise descriptions in SASL chapter to reflect
simplified auth-source options.

* lisp/erc/erc-sasl.el (erc-sasl-password,
erc-sasl-auth-source-function): Revise doc strings.
(erc-sasl-auth-source-password-as-host): New function to serve as
more useful choice for option `erc-sasl-auth-source-function'.
(erc-sasl--read-password): Promote auth-source to pole position, above
an explicit string and `:password'.

* test/lisp/erc/erc-sasl-tests.el (erc-sasl--read-password--basic):
Massage tests to conform to simplified `erc-sasl-password'
API.  (Bug#29108.)
---
 doc/misc/erc.texi               | 40 ++++++++++-------
 lisp/erc/erc-sasl.el            | 77 ++++++++++++++++++++-------------
 test/lisp/erc/erc-sasl-tests.el | 38 +++++++++++-----
 3 files changed, 98 insertions(+), 57 deletions(-)

diff --git a/doc/misc/erc.texi b/doc/misc/erc.texi
index 5317a3e5aa..aa7b9cb947 100644
--- a/doc/misc/erc.texi
+++ b/doc/misc/erc.texi
@@ -1055,17 +1055,10 @@ SASL
 @code{erc-tls} with @code{:password} set to your NickServ password.
 
 You can also set this to a nonemtpy string, and ERC will send that
-when needed, no questions asked.  If you instead give a non-@code{nil}
-symbol (other than @code{:password}), 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 resolved 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, and it's
-@code{nil} by default.  As a last resort, ERC will prompt you for
-input.
+when needed, no questions asked.  Or, if you'd rather use auth-source,
+set @code{erc-sasl-auth-source-function} to a function, and ERC will
+perform an auth-source query instead.  As last resort in all cases,
+ERC will prompt you for input.
 
 Lastly, if your mechanism is @code{ecdsa-nist256p-challenge}, this
 option should instead hold the file name of your key.
@@ -1075,7 +1068,23 @@ SASL
 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.
+@code{erc-auth-source-search} for queries to be performed.  For
+convenience, this module provides the following as a possible value:
+
+@defun erc-sasl-auth-source-password-as-host &rest plist
+Setting @code{erc-sasl-auth-source-function} to this function tells
+ERC to use @code{erc-sasl-password} for the @code{:host} field when
+querying auth-source, even if its value is the default
+@code{:password}, in which case ERC knows to ``resolve'' it to
+@code{erc-session-password} and use that as long as it's
+non-@code{nil}.  Otherwise, ERC just defers to
+@code{erc-auth-source-search} to determine the @code{:host}, along
+with everything else.
+@end defun
+
+As long as this option specifies a function, ERC will pass it the
+``resolved'' value of @code{erc-sasl-user} for the auth-source
+@code{:user} param.
 @end defopt
 
 @defopt erc-sasl-authzid
@@ -1142,10 +1151,11 @@ SASL
        (erc-tls :server "irc.libera.chat" :port 6697
                 :client-certificate t)))
     ('example
-     (let ((erc-sasl-auth-source-function #'erc-auth-source-search)
-           (erc-sasl-password 'Example.Net))
+     (let ((erc-sasl-auth-source-function
+            #'erc-sasl-auth-source-password-as-host))
        (erc-tls :server "irc.example.net" :port 6697
-                :user "alyssa")))))
+                :user "alyssa"
+                :password "Example.Net")))))
 @end lisp
 
 You've started storing your credentials with auth-source and have
diff --git a/lisp/erc/erc-sasl.el b/lisp/erc/erc-sasl.el
index 5ee7169de5..5b2c93988a 100644
--- a/lisp/erc/erc-sasl.el
+++ b/lisp/erc/erc-sasl.el
@@ -77,15 +77,14 @@ erc-sasl-user
 
 (defcustom erc-sasl-password :password
   "Optional account password to send when authenticating.
-When the value is a string, ERC will use it unconditionally for
-most mechanisms.  Likewise with `:password', except ERC will
-instead use the \"session password\" on file, which often
-originates from the entry-point commands `erc' or `erc-tls'.
-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 prompt for input.
+When `erc-sasl-auth-source-function' is a function, ERC will
+attempt an auth-source query and prompt for input if it fails.
+Otherwise, when the value is a nonempty string, ERC will use it
+unconditionally for most mechanisms.  Likewise with `:password',
+except ERC will instead use the \"session password\" on file, if
+any, which often originates from the entry-point commands `erc'
+or `erc-tls'.  As with auth-source, ERC will prompt for input as
+a fallback.
 
 Note that, with `:password', ERC will forgo sending a traditional
 server password via the IRC \"PASS\" command.  Also, when
@@ -95,15 +94,18 @@ erc-sasl-password
 
 (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
-includes `erc-sasl-user' for the `:user' field and
-`erc-sasl-password' for the `:host' field, when the latter option
-is a non-nil, non-keyword symbol.  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)
+If provided, this function should expect to be called with any
+number of keyword params known to `auth-source-search', even
+though ERC itself only specifies `:user' paired with a
+\"resolved\" `erc-sasl-user' value.  When calling this function,
+ERC binds all options defined in this library, such as
+`erc-sasl-password', to their values from entry-point invocation.
+In return, ERC expects a string to send as the SASL password, or
+nil, in which case, ERC will prompt the for input.  See info
+node `(erc) Connecting' for details on ERC's auth-source
+integration."
+  :type '(choice (function-item erc-sasl-auth-source-password-as-host)
+                 (function-item erc-auth-source-search)
                  (const nil)
                  function))
 
@@ -130,20 +132,35 @@ erc-sasl--get-user
     (:nick (erc-downcase (erc-current-nick)))
     (v v)))
 
+(defun erc-sasl-auth-source-password-as-host (&rest plist)
+  "Call `erc-auth-source-search' with `erc-sasl-password' as `:host'.
+But only do so when it's a string or a non-nil symbol, unless
+that symbol is `:password', in which case, use a non-nil
+`erc-session-password' instead.  Otherwise, just defer to
+`erc-auth-source-search' to pick a suitable `:host'.  Expect
+PLIST to contain keyword params known to `auth-source-search'."
+  (when erc-sasl-password
+    (when-let ((host (if (eq :password erc-sasl-password)
+                         (and (not (functionp erc-session-password))
+                              erc-session-password)
+                       erc-sasl-password)))
+      (setq plist `(,@plist :host ,(format "%s" host)))))
+  (apply #'erc-auth-source-search plist))
+
 (defun erc-sasl--read-password (prompt)
   "Return configured option or server password.
-PROMPT is passed to `read-passwd' if necessary."
-  (if-let
-      ((found (pcase (alist-get 'password erc-sasl--options)
-                (:password erc-session-password)
-                ((and (pred stringp) v) (unless (string-empty-p v) v))
-                ((and (let fn (alist-get 'authfn erc-sasl--options))
-                      (guard fn) v
-                      (let host
-                        (or v (erc-networks--id-given erc-networks--id))))
-                 (apply fn
-                        :user (erc-sasl--get-user)
-                        (and host (list :host (symbol-name host))))))))
+If necessary, pass PROMPT to `read-passwd'."
+  (if-let ((found (pcase (alist-get 'password erc-sasl--options)
+                    ((guard (alist-get 'authfn erc-sasl--options))
+                     (let-alist erc-sasl--options
+                       (let ((erc-sasl-user .user)
+                             (erc-sasl-password .password)
+                             (erc-sasl-mechanism .mechanism)
+                             (erc-sasl-authzid .authzid)
+                             (erc-sasl-auth-source-function .authfn))
+                         (funcall .authfn :user (erc-sasl--get-user)))))
+                    (:password erc-session-password)
+                    ((and (pred stringp) v) (unless (string-empty-p v) v)))))
       (copy-sequence (erc--unfun found))
     (read-passwd prompt)))
 
diff --git a/test/lisp/erc/erc-sasl-tests.el b/test/lisp/erc/erc-sasl-tests.el
index 3e6828ff64..0e5ea60e5f 100644
--- a/test/lisp/erc/erc-sasl-tests.el
+++ b/test/lisp/erc/erc-sasl-tests.el
@@ -57,6 +57,8 @@ erc-sasl--read-password--basic
                          (erc-sasl--read-password "pwd:"))
                        "baz")))))
 
+;; This mainly tests `erc-sasl-auth-source-password-as-host'.
+
 (ert-deftest erc-sasl--read-password--auth-source ()
   (ert-with-temp-file netrc-file
     :text (string-join
@@ -70,33 +72,42 @@ erc-sasl--read-password--auth-source
            (erc-session-server "irc.gnu.org")
            (erc-session-port 6697)
            (erc-networks--id (erc-networks--id-create nil))
-           calls
-           (fn (lambda (&rest r)
-                 (push r calls)
-                 (apply #'erc-auth-source-search r)))
            erc-server-announced-name ; too early
-           auth-source-do-cache)
+           auth-source-do-cache
+           ;;
+           (fn #'erc-sasl-auth-source-password-as-host)
+           calls)
+
+      (advice-add 'erc-auth-source-search :before
+                  (lambda (&rest r) (push r calls))
+                  '((name . erc-sasl--read-password--auth-source)))
 
       (ert-info ("Symbol as password specifies machine")
         (let ((erc-sasl--options
-               `((user . "bob") (password . FSF.chat) (authfn . ,fn)))
-              (erc-networks--id (make-erc-networks--id)))
+               `((user . "bob") (password . FSF.chat) (authfn . ,fn))))
           (should (string= (erc-sasl--read-password nil) "sesame"))
           (should (equal (pop calls) '(:user "bob" :host "FSF.chat")))))
 
-      (ert-info ("ID for :host and `erc-session-username' for :user") ; *1
+      (ert-info (":password as password resolved to machine")
+        (let ((erc-session-password "FSF.chat")
+              (erc-sasl--options
+               `((user . "bob") (password . :password) (authfn . ,fn))))
+          (should (string= (erc-sasl--read-password nil) "sesame"))
+          (should (equal (pop calls) '(:user "bob" :host "FSF.chat")))))
+
+      (ert-info (":user resolved to `erc-session-username'") ; *1
         (let ((erc-session-username "bob")
               (erc-sasl--options `((user . :user) (password) (authfn . ,fn)))
               (erc-networks--id (erc-networks--id-create 'GNU/chat)))
           (should (string= (erc-sasl--read-password nil) "spam"))
-          (should (equal (pop calls) '(:user "bob" :host "GNU/chat")))))
+          (should (equal (pop calls) '(:user "bob")))))
 
-      (ert-info ("ID for :host and current nick for :user") ; *1
+      (ert-info (":user resolved to current nick") ; *1
         (let ((erc-server-current-nick "bob")
               (erc-sasl--options `((user . :nick) (password) (authfn . ,fn)))
               (erc-networks--id (erc-networks--id-create 'GNU/chat)))
           (should (string= (erc-sasl--read-password nil) "spam"))
-          (should (equal (pop calls) '(:user "bob" :host "GNU/chat")))))
+          (should (equal (pop calls) '(:user "bob")))))
 
       (ert-info ("Symbol as password, entry lacks user field")
         (let ((erc-server-current-nick "fake")
@@ -104,7 +115,10 @@ erc-sasl--read-password--auth-source
                `((user . :nick) (password . MyHost) (authfn . ,fn)))
               (erc-networks--id (erc-networks--id-create 'GNU/chat)))
           (should (string= (erc-sasl--read-password nil) "123"))
-          (should (equal (pop calls) '(:user "fake" :host "MyHost"))))))))
+          (should (equal (pop calls) '(:user "fake" :host "MyHost")))))
+
+      (advice-remove 'erc-auth-source-search
+                     'erc-sasl--read-password--auth-source))))
 
 (ert-deftest erc-sasl-create-client--plain ()
   (let* ((erc-session-password "password123")
-- 
2.38.1


^ permalink raw reply related	[flat|nested] 54+ messages in thread

* bug#29108: 25.3; ERC SASL support
       [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>
  2 siblings, 0 replies; 54+ messages in thread
From: Amin Bandali @ 2022-11-29  5:19 UTC (permalink / raw)
  To: J.P.; +Cc: 29108, emacs-erc

Hey!

J.P. writes:

[...]
>
> Thanks for bailing me out. (This was fast becoming the "Swingers
> answering machine" of bug threads.)

Ahaha no problem ;-)

>> All but one of them were committed without any additional changes; and
>> that one was 0006-Add-non-IRCv3-SASL-module-to-ERC.patch, where I just
>> added the missing entries for doc/misc/erc.texi and etc/ERC-NEWS to
>> the commit message.
>
> Oof. Thanks. I also forgot the bug number on
>
>   0007-Accept-functions-in-place-of-passwords-in-ERC.patch
>
> despite being kindly warned of that eventuality.

Ah, no worries; I missed that as well.

[...]
>
> Right. Too cryptic. I've adjusted things in the second patch but am
> happy to redo/revise, as always. (The first patch contains a bug fix.)
>
[...]
>
> Good call. I've attempted something like that in a separate "examples"
> section (2nd patch). I'm hesitant about the last, "multi-network"
> example, though. It sort of implies we're committing to supporting
> let-binding as a means of specifying per-network local-module options,
> going forward, which maybe also puts us on the hook for (eventually)
> providing a mechanism to make options bookkeeping easier for would-be
> local-module authors. OTOH, neither of those is as yet a realistic
> problem.
>
> Speaking of maintenance burdens, I think `erc-sasl-password' is too
> overloaded and unwieldy, particularly WRT the "non-nil symbol" form. And
> falling back on `:id' is redundant because `erc-auth-source-search'
> already does that. So, as penance for my ugly API design, I've attached
> a (third) patch that tries to corral some of the crazy by adding an
> optional auth-source query function to house the more nuanced
> functionality (for those actually wanting it) while sparing everyone
> else the needless complexity. (That's the idea, anyway.)

Thanks!  Yeah, I don't really see supporting let-binds as too big of a
potential future burden.  Worst case scenario, if/when local modules
are fully implemented and we and/or module authors find supporting
let-binding impractical, or there are clear advantages in not having
them, we could drop them in a major version bump (ERC 6.0 anyone?)
along with any other potential breaking change we might want to make.

Also your simplification sounds good.  I pushed the v2 of all three
patches from your other reply to emacs-29.  Thanks again!

>> Many thanks for all of your work on implementing and landing SASL
>> support for ERC, a feature that many ERC users (myself included)
>> have wished for and looked forward to seeing in ERC for years!
>>
>> -amin
>
> My pleasure! (Although I should've been quicker to admit that my older
> POC efforts weren't suitable for prime time without serious reworking.)
>

Cheers!

-a





^ permalink raw reply	[flat|nested] 54+ messages in thread

* bug#29108: 25.3; ERC SASL support
       [not found]                                     ` <87iliyz6at.fsf@gnu.org>
@ 2022-11-29 15:05                                       ` J.P.
  0 siblings, 0 replies; 54+ messages in thread
From: J.P. @ 2022-11-29 15:05 UTC (permalink / raw)
  To: Amin Bandali; +Cc: 29108, emacs-erc

Amin Bandali <bandali@gnu.org> writes:

>> Right. Too cryptic. I've adjusted things in the second patch but am
>> happy to redo/revise, as always. (The first patch contains a bug fix.)
>>
> [...]
>>
>> Good call. I've attempted something like that in a separate "examples"
>> section (2nd patch). I'm hesitant about the last, "multi-network"
>> example, though. It sort of implies we're committing to supporting
>> let-binding as a means of specifying per-network local-module options,
>> going forward, which maybe also puts us on the hook for (eventually)
>> providing a mechanism to make options bookkeeping easier for would-be
>> local-module authors. OTOH, neither of those is as yet a realistic
>> problem.
>>
>> Speaking of maintenance burdens, I think `erc-sasl-password' is too
>> overloaded and unwieldy, particularly WRT the "non-nil symbol" form. And
>> falling back on `:id' is redundant because `erc-auth-source-search'
>> already does that. So, as penance for my ugly API design, I've attached
>> a (third) patch that tries to corral some of the crazy by adding an
>> optional auth-source query function to house the more nuanced
>> functionality (for those actually wanting it) while sparing everyone
>> else the needless complexity. (That's the idea, anyway.)
>
> Thanks!  Yeah, I don't really see supporting let-binds as too big of a
> potential future burden.  Worst case scenario, if/when local modules
> are fully implemented and we and/or module authors find supporting
> let-binding impractical, or there are clear advantages in not having
> them, we could drop them in a major version bump (ERC 6.0 anyone?)
> along with any other potential breaking change we might want to make.

Agreed (let's break it all)!

Seriously, though, I really appreciate your taking the time to review
these.

> Also your simplification sounds good.  I pushed the v2 of all three
> patches from your other reply to emacs-29.

Sweet, thanks so much.





^ permalink raw reply	[flat|nested] 54+ messages in thread

end of thread, other threads:[~2022-11-29 15:05 UTC | newest]

Thread overview: 54+ messages (download: mbox.gz / follow: Atom feed)
-- links below jump to the message on this page --
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.
     [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-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
2022-11-13 15:36             ` J.P.
     [not found]             ` <87o7taoohd.fsf@neverwas.me>
2022-11-14  6:45               ` J.P.
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.

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).