serverless-ircd 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (192) hide show
  1. package/.github/workflows/ci.yml +28 -0
  2. package/.github/workflows/deploy-cf-tcp.yml +26 -2
  3. package/.github/workflows/deploy-cf.yml +26 -0
  4. package/CHANGELOG.md +289 -0
  5. package/README.md +153 -20
  6. package/apps/aws-stack/bin/aws.ts +36 -0
  7. package/apps/aws-stack/package.json +1 -1
  8. package/apps/aws-stack/src/aws-stack.ts +221 -15
  9. package/apps/aws-stack/tests/stack.test.ts +450 -16
  10. package/apps/cf-tcp-container/Dockerfile +37 -5
  11. package/apps/cf-tcp-container/package.json +7 -2
  12. package/apps/cf-tcp-container/src/config-loader.ts +113 -2
  13. package/apps/cf-tcp-container/src/container-server.ts +256 -79
  14. package/apps/cf-tcp-container/src/main.ts +22 -7
  15. package/apps/cf-tcp-container/src/proxy-protocol.ts +112 -0
  16. package/apps/cf-tcp-container/terraform/spectrum.tf +40 -11
  17. package/apps/cf-tcp-container/tests/config-loader.test.ts +170 -0
  18. package/apps/cf-tcp-container/tests/container-server-tls.test.ts +382 -0
  19. package/apps/cf-tcp-container/tests/container-server.test.ts +358 -31
  20. package/apps/cf-tcp-container/tests/dockerfile.test.ts +110 -0
  21. package/apps/cf-tcp-container/tests/proxy-protocol.test.ts +187 -0
  22. package/apps/cf-tcp-container/tests/spectrum-terraform.test.ts +135 -0
  23. package/apps/cf-tcp-container/tests/tls-e2e.test.ts +5 -1
  24. package/apps/cf-tcp-container/wrangler.toml +17 -4
  25. package/apps/cf-worker/package.json +2 -2
  26. package/apps/cf-worker/src/worker.ts +77 -5
  27. package/apps/cf-worker/tests/raw-modules.d.ts +11 -0
  28. package/apps/cf-worker/tests/smoke.test.ts +4 -0
  29. package/apps/cf-worker/tests/wrangler-config.test.ts +47 -0
  30. package/apps/cf-worker/tests/ws-admission.test.ts +112 -0
  31. package/apps/cf-worker/tests/ws-rate-limit.test.ts +133 -0
  32. package/apps/cf-worker/wrangler.test.toml +15 -1
  33. package/apps/cf-worker/wrangler.toml +86 -9
  34. package/apps/local-cli/package.json +1 -1
  35. package/apps/local-cli/src/config-loader.ts +14 -2
  36. package/apps/local-cli/src/line-scanner.ts +26 -0
  37. package/apps/local-cli/src/server.ts +23 -2
  38. package/apps/local-cli/tests/line-scanner.test.ts +64 -0
  39. package/apps/local-cli/tests/tcp.test.ts +29 -0
  40. package/apps/web/package.json +1 -1
  41. package/docs/AWS-Deployment.md +123 -22
  42. package/docs/AWS-TCP-Deployment.md +37 -2
  43. package/docs/Chat-History.md +55 -0
  44. package/docs/Cloudflare-Deployment-Guide.md +9 -2
  45. package/docs/Cloudflare-TCP-Deployment.md +135 -52
  46. package/docs/SASL-EXTERNAL.md +175 -0
  47. package/package.json +3 -3
  48. package/packages/aws-adapter/package.json +1 -1
  49. package/packages/aws-adapter/src/admission.ts +28 -13
  50. package/packages/aws-adapter/src/aws-runtime.ts +30 -3
  51. package/packages/aws-adapter/src/cdk-table-defs.ts +34 -6
  52. package/packages/aws-adapter/src/config-loader.ts +134 -6
  53. package/packages/aws-adapter/src/dynamo-services-store.ts +12 -0
  54. package/packages/aws-adapter/src/handlers/connect.ts +47 -1
  55. package/packages/aws-adapter/src/handlers/default.ts +95 -6
  56. package/packages/aws-adapter/src/handlers/index.ts +31 -2
  57. package/packages/aws-adapter/src/handlers/nlb-stream.ts +132 -8
  58. package/packages/aws-adapter/src/ip-admission.ts +79 -0
  59. package/packages/aws-adapter/src/serialize.ts +8 -0
  60. package/packages/aws-adapter/src/tables.ts +9 -0
  61. package/packages/aws-adapter/tests/admission.test.ts +60 -2
  62. package/packages/aws-adapter/tests/aws-harness.ts +23 -1
  63. package/packages/aws-adapter/tests/aws-runtime.test.ts +64 -0
  64. package/packages/aws-adapter/tests/config-loader.test.ts +151 -0
  65. package/packages/aws-adapter/tests/connect.test.ts +199 -2
  66. package/packages/aws-adapter/tests/default-frame-limit.test.ts +231 -0
  67. package/packages/aws-adapter/tests/default-occ.test.ts +10 -3
  68. package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +123 -1
  69. package/packages/aws-adapter/tests/handlers.test.ts +57 -1
  70. package/packages/aws-adapter/tests/nlb-secure.test.ts +362 -0
  71. package/packages/aws-adapter/tests/nlb-stream.test.ts +628 -9
  72. package/packages/cf-adapter/package.json +1 -1
  73. package/packages/cf-adapter/src/cf-runtime.ts +48 -9
  74. package/packages/cf-adapter/src/config-loader.ts +133 -8
  75. package/packages/cf-adapter/src/connection-do.ts +154 -21
  76. package/packages/cf-adapter/src/counter-do.ts +142 -0
  77. package/packages/cf-adapter/src/d1-services-store.ts +47 -5
  78. package/packages/cf-adapter/src/env.ts +88 -0
  79. package/packages/cf-adapter/src/index.ts +17 -1
  80. package/packages/cf-adapter/src/rate-limit-do.ts +87 -0
  81. package/packages/cf-adapter/tests/cf-runtime.test.ts +104 -15
  82. package/packages/cf-adapter/tests/config-loader.test.ts +159 -0
  83. package/packages/cf-adapter/tests/connection-do-counter.test.ts +165 -0
  84. package/packages/cf-adapter/tests/connection-do-frame-limit.test.ts +177 -0
  85. package/packages/cf-adapter/tests/connection-do-pure.test.ts +74 -5
  86. package/packages/cf-adapter/tests/connection-do-ws-spec-contract.test.ts +7 -4
  87. package/packages/cf-adapter/tests/counter-do.test.ts +181 -0
  88. package/packages/cf-adapter/tests/d1-services-store.test.ts +192 -1
  89. package/packages/cf-adapter/tests/rate-limit-do.test.ts +160 -0
  90. package/packages/cf-adapter/tests/worker/main.ts +4 -0
  91. package/packages/cf-adapter/wrangler.test.toml +18 -1
  92. package/packages/in-memory-runtime/package.json +1 -1
  93. package/packages/in-memory-runtime/src/in-memory-runtime.ts +25 -0
  94. package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +74 -0
  95. package/packages/irc-core/package.json +1 -1
  96. package/packages/irc-core/src/caps/capabilities.ts +20 -10
  97. package/packages/irc-core/src/certfp.ts +178 -0
  98. package/packages/irc-core/src/commands/cap.ts +10 -2
  99. package/packages/irc-core/src/commands/chanserv.ts +117 -14
  100. package/packages/irc-core/src/commands/chathistory.ts +13 -5
  101. package/packages/irc-core/src/commands/hostserv.ts +84 -8
  102. package/packages/irc-core/src/commands/index.ts +2 -1
  103. package/packages/irc-core/src/commands/invite.ts +1 -7
  104. package/packages/irc-core/src/commands/join.ts +1 -16
  105. package/packages/irc-core/src/commands/kick.ts +1 -8
  106. package/packages/irc-core/src/commands/list.ts +1 -8
  107. package/packages/irc-core/src/commands/mode.ts +1 -8
  108. package/packages/irc-core/src/commands/multiline.ts +4 -10
  109. package/packages/irc-core/src/commands/names.ts +53 -13
  110. package/packages/irc-core/src/commands/nickserv.ts +40 -1
  111. package/packages/irc-core/src/commands/oper.ts +361 -8
  112. package/packages/irc-core/src/commands/part.ts +4 -10
  113. package/packages/irc-core/src/commands/privmsg.ts +8 -4
  114. package/packages/irc-core/src/commands/registration.ts +146 -2
  115. package/packages/irc-core/src/commands/sasl.ts +136 -19
  116. package/packages/irc-core/src/commands/topic.ts +10 -12
  117. package/packages/irc-core/src/commands/who.ts +1 -8
  118. package/packages/irc-core/src/config.ts +393 -20
  119. package/packages/irc-core/src/effects.ts +24 -0
  120. package/packages/irc-core/src/flood-control.ts +10 -10
  121. package/packages/irc-core/src/frame-rate-limit.ts +82 -0
  122. package/packages/irc-core/src/index.ts +8 -0
  123. package/packages/irc-core/src/oper-hashing.ts +43 -0
  124. package/packages/irc-core/src/oper-lockout.ts +87 -0
  125. package/packages/irc-core/src/ports.ts +395 -36
  126. package/packages/irc-core/src/protocol/bytes.ts +65 -0
  127. package/packages/irc-core/src/protocol/channel-name.ts +37 -0
  128. package/packages/irc-core/src/protocol/index.ts +12 -1
  129. package/packages/irc-core/src/protocol/outbound.ts +43 -10
  130. package/packages/irc-core/src/protocol/parser.ts +79 -10
  131. package/packages/irc-core/src/state/connection.ts +13 -0
  132. package/packages/irc-core/src/types.ts +228 -13
  133. package/packages/irc-core/src/ws-framing.ts +5 -4
  134. package/packages/irc-core/tests/bytes.test.ts +89 -0
  135. package/packages/irc-core/tests/certfp.test.ts +117 -0
  136. package/packages/irc-core/tests/commands/cap.test.ts +76 -2
  137. package/packages/irc-core/tests/commands/chanserv.test.ts +166 -0
  138. package/packages/irc-core/tests/commands/chathistory.test.ts +140 -0
  139. package/packages/irc-core/tests/commands/hostserv.test.ts +316 -0
  140. package/packages/irc-core/tests/commands/join.test.ts +78 -1
  141. package/packages/irc-core/tests/commands/names.test.ts +193 -0
  142. package/packages/irc-core/tests/commands/nickserv.test.ts +182 -2
  143. package/packages/irc-core/tests/commands/oper.test.ts +560 -2
  144. package/packages/irc-core/tests/commands/privmsg.test.ts +16 -0
  145. package/packages/irc-core/tests/commands/registration.test.ts +463 -1
  146. package/packages/irc-core/tests/commands/sasl.test.ts +596 -7
  147. package/packages/irc-core/tests/commands/topic.test.ts +137 -2
  148. package/packages/irc-core/tests/commands/unified-account.test.ts +2 -0
  149. package/packages/irc-core/tests/config.test.ts +534 -2
  150. package/packages/irc-core/tests/effects.test.ts +14 -0
  151. package/packages/irc-core/tests/flood-control.test.ts +29 -1
  152. package/packages/irc-core/tests/frame-rate-limit.test.ts +98 -0
  153. package/packages/irc-core/tests/oper-hashing.test.ts +60 -0
  154. package/packages/irc-core/tests/oper-lockout.test.ts +74 -0
  155. package/packages/irc-core/tests/outbound.test.ts +148 -0
  156. package/packages/irc-core/tests/parser.test.ts +287 -5
  157. package/packages/irc-core/tests/persistent-services-store.test.ts +141 -0
  158. package/packages/irc-core/tests/ports.test.ts +99 -7
  159. package/packages/irc-core/tests/services-store.test.ts +376 -14
  160. package/packages/irc-core/tests/ws-framing.test.ts +45 -0
  161. package/packages/irc-server/package.json +1 -1
  162. package/packages/irc-server/src/actor.ts +123 -8
  163. package/packages/irc-server/src/dispatch.ts +1 -0
  164. package/packages/irc-server/src/index.ts +7 -0
  165. package/packages/irc-server/src/redact.ts +159 -0
  166. package/packages/irc-server/src/runtime.ts +14 -0
  167. package/packages/irc-server/src/transport.ts +28 -1
  168. package/packages/irc-server/tests/actor.test.ts +544 -7
  169. package/packages/irc-server/tests/dispatch.test.ts +31 -0
  170. package/packages/irc-server/tests/redact.test.ts +198 -0
  171. package/packages/irc-server/tests/runtime.test.ts +2 -0
  172. package/packages/irc-server/tests/transport.test.ts +66 -0
  173. package/packages/irc-test-support/package.json +1 -1
  174. package/packages/irc-test-support/src/in-memory-harness.ts +4 -0
  175. package/scripts/package.json +1 -1
  176. package/tools/ci-hardening/package.json +2 -2
  177. package/tools/ci-hardening/src/cf-deploy-cli.ts +3 -0
  178. package/tools/ci-hardening/src/cf-deploy.ts +118 -0
  179. package/tools/ci-hardening/src/deploy-hostname.ts +118 -0
  180. package/tools/ci-hardening/src/env-var-drift.ts +192 -0
  181. package/tools/ci-hardening/src/hostname-guard.ts +11 -0
  182. package/tools/ci-hardening/src/index.ts +17 -0
  183. package/tools/ci-hardening/tests/__wrangler_missing__.toml +2 -0
  184. package/tools/ci-hardening/tests/__wrangler_placeholder__.toml +3 -0
  185. package/tools/ci-hardening/tests/__wrangler_real__.toml +3 -0
  186. package/tools/ci-hardening/tests/cf-deploy.test.ts +200 -0
  187. package/tools/ci-hardening/tests/deploy-hostname.test.ts +348 -0
  188. package/tools/ci-hardening/tests/env-var-drift.test.ts +284 -0
  189. package/tools/ci-hardening/vitest.config.ts +5 -1
  190. package/tools/hash-oper-cred.ts +85 -0
  191. package/tools/load-test/package.json +1 -1
  192. package/tools/tcp-ws-forwarder/package.json +1 -1
@@ -1,7 +1,9 @@
1
- import { describe, expect, it, vi } from 'vitest';
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
2
  import { getLsString } from '../../src/caps/capabilities';
3
+ import { type CertIdentity, certIdentityFromSubject } from '../../src/certfp';
3
4
  import { capReducer } from '../../src/commands/cap';
4
5
  import { authenticateReducer } from '../../src/commands/sasl';
6
+ import type { HashedAccountCredential } from '../../src/credential-hashing';
5
7
  import { Effect } from '../../src/effects';
6
8
  import type { Effect as EffectType, RawLine } from '../../src/effects';
7
9
  import type { AwayStore, MtlsIdentityProvider, ServicesStore } from '../../src/ports';
@@ -11,12 +13,16 @@ import {
11
13
  InMemoryAwayStore,
12
14
  InMemoryServicesStore,
13
15
  SequentialIdFactory,
16
+ resetVerifyNickTimingSpy,
17
+ setVerifyNickHashedPassword,
14
18
  } from '../../src/ports';
15
19
  import { encodeBase64 } from '../../src/protocol/base64';
16
20
  import type { IrcMessage } from '../../src/protocol/messages';
17
21
  import { type ConnectionState, createConnection } from '../../src/state/connection';
18
22
  import { type Ctx, type ServerConfig, buildCtx } from '../../src/types';
19
23
 
24
+ type VerifyHashedPasswordFn = (password: string, entry: HashedAccountCredential) => boolean;
25
+
20
26
  const serverConfig: ServerConfig = {
21
27
  serverName: 'irc.example.com',
22
28
  networkName: 'ExampleNet',
@@ -29,6 +35,17 @@ const serverConfig: ServerConfig = {
29
35
  quitMessage: 'Client Quit',
30
36
  };
31
37
 
38
+ /**
39
+ * Config with SASL EXTERNAL opted in — mirrors an operator setting the
40
+ * adapters' `EXTERNAL_ENABLED` env var. The mTLS-backed EXTERNAL tests
41
+ * bind this so they exercise the enabled path; the opt-in/secure-connection
42
+ * gate tests build their own configs explicitly.
43
+ */
44
+ const externalServerConfig: ServerConfig = {
45
+ ...serverConfig,
46
+ sasl: { externalEnabled: true },
47
+ };
48
+
32
49
  const L = (text: string): RawLine => ({ text });
33
50
 
34
51
  const SV = 'irc.example.com';
@@ -43,6 +60,10 @@ function saslReadyState(): ConnectionState {
43
60
  s.registration = 'registering';
44
61
  s.capNegotiating = true;
45
62
  s.caps.add('sasl');
63
+ // SASL EXTERNAL requires a secure (TLS) transport; the standard helper
64
+ // models the common secure client so the EXTERNAL describe inherits it.
65
+ // The insecure-connection gate has its own explicit test.
66
+ s.secure = true;
46
67
  return s;
47
68
  }
48
69
 
@@ -67,7 +88,11 @@ function makeCtx(
67
88
  away?: AwayStore,
68
89
  ): Ctx {
69
90
  return buildCtx({
70
- serverConfig,
91
+ // A test that binds an mTLS provider intends to exercise the EXTERNAL
92
+ // path, so it gets the EXTERNAL-enabled config (mirrors an operator
93
+ // setting EXTERNAL_ENABLED). The gate matrix tests below build their
94
+ // own ctxs to control the flag explicitly.
95
+ serverConfig: mtlsIdentity !== undefined ? externalServerConfig : serverConfig,
71
96
  clock: new FakeClock(1_000),
72
97
  ids: new SequentialIdFactory(),
73
98
  motd: EmptyMotdProvider,
@@ -83,11 +108,14 @@ class FakeMtlsIdentityProvider implements MtlsIdentityProvider {
83
108
  readonly calls: string[] = [];
84
109
  constructor(
85
110
  private readonly connId: string,
86
- private readonly identity: string | undefined,
111
+ private readonly subject: string | undefined,
112
+ private readonly fingerprint?: string,
87
113
  ) {}
88
- getIdentity(connId: string): string | undefined {
114
+ getIdentity(connId: string): CertIdentity | undefined {
89
115
  this.calls.push(connId);
90
- return connId === this.connId ? this.identity : undefined;
116
+ return connId === this.connId && this.subject !== undefined
117
+ ? certIdentityFromSubject(this.subject, this.fingerprint)
118
+ : undefined;
91
119
  }
92
120
  }
93
121
 
@@ -465,6 +493,57 @@ describe('authenticateReducer — PLAIN failure', () => {
465
493
  });
466
494
  });
467
495
 
496
+ // ============================================================================
497
+ // AUTHENTICATE PLAIN — timing equalisation (username-enumeration defence).
498
+ // The unknown-username and wrong-password paths must perform the SAME
499
+ // number of scrypt verifies (one each) and emit the identical 904 line, so
500
+ // a remote attacker cannot distinguish "user exists, wrong password" from
501
+ // "user unknown" by measuring latency between AUTHENTICATE and 904.
502
+ // ============================================================================
503
+
504
+ describe('authenticateReducer — PLAIN timing equalisation (username enumeration)', () => {
505
+ afterEach(() => {
506
+ resetVerifyNickTimingSpy();
507
+ });
508
+
509
+ function runPlainFailure(
510
+ authcid: string,
511
+ password: string,
512
+ ): { verifyCalls: number; line: string } {
513
+ const spy = vi.fn<VerifyHashedPasswordFn>(() => false);
514
+ setVerifyNickHashedPassword(spy);
515
+ let out: { verifyCalls: number; line: string };
516
+ try {
517
+ const state = saslReadyState();
518
+ const ctx = makeCtx(state, registeredServices('alice', 'secret'));
519
+ authenticateReducer(state, authenticate('PLAIN'), ctx);
520
+ const result = authenticateReducer(state, authenticate(plainPayload(authcid, password)), ctx);
521
+ out = { verifyCalls: spy.mock.calls.length, line: firstLineText(result.effects[0]) };
522
+ } finally {
523
+ setVerifyNickHashedPassword(null);
524
+ }
525
+ return out;
526
+ }
527
+
528
+ it('runs exactly one scrypt verify on the unknown-username path (no O(1) miss)', () => {
529
+ const { verifyCalls } = runPlainFailure('definitely-not-a-user', 'secret');
530
+ expect(verifyCalls).toBe(1);
531
+ });
532
+
533
+ it('runs exactly one scrypt verify on the wrong-password path', () => {
534
+ const { verifyCalls } = runPlainFailure('alice', 'wrong');
535
+ expect(verifyCalls).toBe(1);
536
+ });
537
+
538
+ it('emits the identical 904 ERR_SASLFAIL on both paths with equal verify cost', () => {
539
+ const unknownUser = runPlainFailure('definitely-not-a-user', 'secret');
540
+ const wrongPassword = runPlainFailure('alice', 'wrong');
541
+ expect(unknownUser.verifyCalls).toBe(wrongPassword.verifyCalls);
542
+ expect(unknownUser.line).toBe(`:${SV} 904 alice :SASL authentication failed`);
543
+ expect(wrongPassword.line).toBe(unknownUser.line);
544
+ });
545
+ });
546
+
468
547
  // ============================================================================
469
548
  // AUTHENTICATE PLAIN — no ServicesStore wired
470
549
  // ============================================================================
@@ -526,7 +605,7 @@ describe('authenticateReducer — EXTERNAL with mTLS', () => {
526
605
  ]);
527
606
  });
528
607
 
529
- it('forwards the resolved mTLS subject to services.verifyCertFP', () => {
608
+ it('forwards the resolved mTLS identity to services.verifyCertFP', () => {
530
609
  const state = saslReadyState();
531
610
  const services = registeredServices('alice', 'secret');
532
611
  services.addCertFP('alice', 'CN=alice');
@@ -537,7 +616,41 @@ describe('authenticateReducer — EXTERNAL with mTLS', () => {
537
616
  authenticateReducer(state, authenticate('EXTERNAL'), ctx);
538
617
  authenticateReducer(state, authenticate('+'), ctx);
539
618
 
540
- expect(verifyCertFP).toHaveBeenCalledWith('CN=alice');
619
+ expect(verifyCertFP).toHaveBeenCalledWith(certIdentityFromSubject('CN=alice'));
620
+ });
621
+
622
+ it('forwards the platform fingerprint on the identity to verifyCertFP', () => {
623
+ const state = saslReadyState();
624
+ const services = registeredServices('alice', 'secret');
625
+ services.addCertFingerprint('alice', 'aa112233');
626
+ const verifyCertFP = vi.spyOn(services, 'verifyCertFP');
627
+ const provider = new FakeMtlsIdentityProvider('c1', 'CN=alice', 'aa112233');
628
+ const ctx = makeCtx(state, services, provider);
629
+
630
+ authenticateReducer(state, authenticate('EXTERNAL'), ctx);
631
+ authenticateReducer(state, authenticate('+'), ctx);
632
+
633
+ expect(verifyCertFP).toHaveBeenCalledWith(
634
+ expect.objectContaining({ subject: 'CN=alice', fingerprint: 'aa112233' }),
635
+ );
636
+ expect(state.account).toBe('alice');
637
+ });
638
+
639
+ it('succeeds via an equivalent DN spelling (canonical compare)', () => {
640
+ const state = saslReadyState();
641
+ const services = registeredServices('alice', 'secret');
642
+ services.addCertFP('alice', 'CN=alice , O = Example Inc');
643
+ const provider = new FakeMtlsIdentityProvider('c1', 'O=Example Inc,CN=alice');
644
+ const ctx = makeCtx(state, services, provider);
645
+
646
+ authenticateReducer(state, authenticate('EXTERNAL'), ctx);
647
+ const out = authenticateReducer(state, authenticate('+'), ctx);
648
+
649
+ expect(state.account).toBe('alice');
650
+ expect(sendLinesOf(out.effects[0])).toEqual<RawLine[]>([
651
+ L(`:${SV} 900 alice alice!alice@example.com alice :You are now logged in as alice`),
652
+ L(`:${SV} 903 alice :SASL authentication successful`),
653
+ ]);
541
654
  });
542
655
 
543
656
  it('clears saslMech/saslBuffer after a successful EXTERNAL', () => {
@@ -635,6 +748,223 @@ describe('authenticateReducer — EXTERNAL with mTLS', () => {
635
748
  });
636
749
  });
637
750
 
751
+ // ============================================================================
752
+ // AUTHENTICATE EXTERNAL — operator opt-in + secure-connection gate
753
+ // ============================================================================
754
+
755
+ describe('authenticateReducer — EXTERNAL opt-in / secure-connection gate', () => {
756
+ /** Builds a ctx with full control over the sasl config + transport. */
757
+ function gateCtx(
758
+ state: ConnectionState,
759
+ opts: { externalEnabled: boolean; provider?: MtlsIdentityProvider },
760
+ ): Ctx {
761
+ return buildCtx({
762
+ serverConfig: { ...serverConfig, sasl: { externalEnabled: opts.externalEnabled } },
763
+ clock: new FakeClock(1_000),
764
+ ids: new SequentialIdFactory(),
765
+ motd: EmptyMotdProvider,
766
+ connection: state,
767
+ ...(opts.provider !== undefined ? { mtlsIdentity: opts.provider } : {}),
768
+ });
769
+ }
770
+
771
+ it('refuses EXTERNAL with 908 when the flag is unset, even with mTLS on a secure connection', () => {
772
+ const state = saslReadyState();
773
+ const provider = new FakeMtlsIdentityProvider('c1', 'CN=alice');
774
+ const ctx = gateCtx(state, { externalEnabled: false, provider });
775
+ const out = authenticateReducer(state, authenticate('EXTERNAL'), ctx);
776
+
777
+ expect(sendLinesOf(out.effects[0])).toEqual<RawLine[]>([
778
+ L(`:${SV} 908 alice PLAIN :are the available SASL mechanisms`),
779
+ ]);
780
+ expect(state.saslMech).toBeUndefined();
781
+ });
782
+
783
+ it('refuses EXTERNAL with 908 when the sasl block omits the flag entirely', () => {
784
+ // A deployment may set other sasl knobs (lockout tuning) without the
785
+ // EXTERNAL opt-in; the flag defaults off — absence is not consent.
786
+ const state = saslReadyState();
787
+ const provider = new FakeMtlsIdentityProvider('c1', 'CN=alice');
788
+ const ctx = buildCtx({
789
+ serverConfig: { ...serverConfig, sasl: { maxFailures: 3 } },
790
+ clock: new FakeClock(1_000),
791
+ ids: new SequentialIdFactory(),
792
+ motd: EmptyMotdProvider,
793
+ connection: state,
794
+ mtlsIdentity: provider,
795
+ });
796
+ const out = authenticateReducer(state, authenticate('EXTERNAL'), ctx);
797
+
798
+ expect(sendLinesOf(out.effects[0])).toEqual<RawLine[]>([
799
+ L(`:${SV} 908 alice PLAIN :are the available SASL mechanisms`),
800
+ ]);
801
+ expect(state.saslMech).toBeUndefined();
802
+ });
803
+
804
+ it('refuses EXTERNAL with 908 on an insecure connection even when the flag is enabled', () => {
805
+ const state = saslReadyState();
806
+ state.secure = false;
807
+ const provider = new FakeMtlsIdentityProvider('c1', 'CN=alice');
808
+ const ctx = gateCtx(state, { externalEnabled: true, provider });
809
+ const out = authenticateReducer(state, authenticate('EXTERNAL'), ctx);
810
+
811
+ expect(sendLinesOf(out.effects[0])).toEqual<RawLine[]>([
812
+ L(`:${SV} 908 alice PLAIN :are the available SASL mechanisms`),
813
+ ]);
814
+ expect(state.saslMech).toBeUndefined();
815
+ });
816
+
817
+ it('does not count the 908 opt-in / secure-connection refusal toward the lockout', () => {
818
+ // A protocol-level mechanism rejection is not a credential attempt;
819
+ // an attacker probing EXTERNAL on an insecure transport must not be
820
+ // able to burn the failure budget (nor succeed by accident).
821
+ const state = saslReadyState();
822
+ state.secure = false;
823
+ const provider = new FakeMtlsIdentityProvider('c1', 'CN=alice');
824
+ const ctx = gateCtx(state, { externalEnabled: true, provider });
825
+
826
+ for (let i = 0; i < 3; i++) {
827
+ const out = authenticateReducer(state, authenticate('EXTERNAL'), ctx);
828
+ expect(firstLineText(out.effects[0])).toContain(' 908 ');
829
+ }
830
+ expect(state.saslFailures).toBeUndefined();
831
+ });
832
+
833
+ it('enters the payload phase when the flag is enabled on a secure connection with mTLS', () => {
834
+ const state = saslReadyState();
835
+ const provider = new FakeMtlsIdentityProvider('c1', 'CN=alice');
836
+ const ctx = gateCtx(state, { externalEnabled: true, provider });
837
+ const out = authenticateReducer(state, authenticate('EXTERNAL'), ctx);
838
+
839
+ expect(state.saslMech).toBe('EXTERNAL');
840
+ expect(sendLinesOf(out.effects[0])).toEqual<RawLine[]>([L('AUTHENTICATE +')]);
841
+ });
842
+
843
+ it('succeeds (900/903) with a bound cert on a secure, EXTERNAL-enabled connection', () => {
844
+ const state = saslReadyState();
845
+ const services = registeredServices('alice', 'secret');
846
+ services.addCertFingerprint('alice', 'aa112233445566778899aabbccddeeff');
847
+ const provider = new FakeMtlsIdentityProvider(
848
+ 'c1',
849
+ 'CN=alice',
850
+ 'AA:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF',
851
+ );
852
+ const ctx = buildCtx({
853
+ serverConfig: externalServerConfig,
854
+ clock: new FakeClock(1_000),
855
+ ids: new SequentialIdFactory(),
856
+ motd: EmptyMotdProvider,
857
+ connection: state,
858
+ services,
859
+ mtlsIdentity: provider,
860
+ });
861
+
862
+ authenticateReducer(state, authenticate('EXTERNAL'), ctx);
863
+ const out = authenticateReducer(state, authenticate('+'), ctx);
864
+
865
+ expect(state.account).toBe('alice');
866
+ expect(sendLinesOf(out.effects[0])).toEqual<RawLine[]>([
867
+ L(`:${SV} 900 alice alice!alice@example.com alice :You are now logged in as alice`),
868
+ L(`:${SV} 903 alice :SASL authentication successful`),
869
+ ]);
870
+ });
871
+
872
+ it('fails with 904 on a secure, EXTERNAL-enabled connection when no binding matches the cert', () => {
873
+ const state = saslReadyState();
874
+ const services = registeredServices('alice', 'secret');
875
+ // alice has no cert binding; the presented cert is unknown to the store.
876
+ const provider = new FakeMtlsIdentityProvider('c1', 'CN=intruder');
877
+ const ctx = buildCtx({
878
+ serverConfig: externalServerConfig,
879
+ clock: new FakeClock(1_000),
880
+ ids: new SequentialIdFactory(),
881
+ motd: EmptyMotdProvider,
882
+ connection: state,
883
+ services,
884
+ mtlsIdentity: provider,
885
+ });
886
+
887
+ authenticateReducer(state, authenticate('EXTERNAL'), ctx);
888
+ const out = authenticateReducer(state, authenticate('+'), ctx);
889
+
890
+ expect(sendLinesOf(out.effects[0])).toEqual<RawLine[]>([
891
+ L(`:${SV} 904 alice :SASL authentication failed`),
892
+ ]);
893
+ expect(state.account).toBeUndefined();
894
+ expect(state.saslFailures).toEqual([1_000]);
895
+ });
896
+ });
897
+
898
+ // ============================================================================
899
+ // AUTHENTICATE EXTERNAL — transport-level non-support override
900
+ // ============================================================================
901
+
902
+ describe('authenticateReducer — EXTERNAL transport-level non-support override', () => {
903
+ /**
904
+ * Builds a ctx carrying the transport-level EXTERNAL non-support
905
+ * override — the seam a transport with no mTLS identity source (the
906
+ * Spectrum TCP container origin) uses to reject
907
+ * `AUTHENTICATE EXTERNAL` with an explicit, stable `904` instead of
908
+ * the generic three-way-gate `908`.
909
+ */
910
+ function unsupportedCtx(state: ConnectionState): Ctx {
911
+ return buildCtx({
912
+ serverConfig: {
913
+ ...serverConfig,
914
+ sasl: {
915
+ externalEnabled: true,
916
+ externalUnsupportedMessage: 'SASL EXTERNAL not supported on this transport',
917
+ },
918
+ },
919
+ clock: new FakeClock(1_000),
920
+ ids: new SequentialIdFactory(),
921
+ motd: EmptyMotdProvider,
922
+ connection: state,
923
+ mtlsIdentity: new FakeMtlsIdentityProvider('c1', 'CN=alice'),
924
+ });
925
+ }
926
+
927
+ it('rejects EXTERNAL with the configured 904 message even when the three-way gate holds', () => {
928
+ // The override is authoritative: a transport that cannot surface a
929
+ // client-cert identity must reject up front with the transport-
930
+ // specific message, never fall through to the generic refusal —
931
+ // even if every gate input (mTLS source, opt-in, secure) is bound.
932
+ const state = saslReadyState();
933
+ const ctx = unsupportedCtx(state);
934
+ const out = authenticateReducer(state, authenticate('EXTERNAL'), ctx);
935
+
936
+ expect(sendLinesOf(out.effects[0])).toEqual<RawLine[]>([
937
+ L(`:${SV} 904 alice :SASL EXTERNAL not supported on this transport`),
938
+ ]);
939
+ expect(state.saslMech).toBeUndefined();
940
+ });
941
+
942
+ it('does not count the transport-level 904 toward the failure lockout', () => {
943
+ // A transport-capability rejection is not a credential attempt; a
944
+ // client probing EXTERNAL must not burn the SASL failure budget.
945
+ const state = saslReadyState();
946
+ const ctx = unsupportedCtx(state);
947
+
948
+ for (let i = 0; i < 3; i++) {
949
+ const out = authenticateReducer(state, authenticate('EXTERNAL'), ctx);
950
+ expect(firstLineText(out.effects[0])).toContain(
951
+ ' 904 alice :SASL EXTERNAL not supported on this transport',
952
+ );
953
+ }
954
+ expect(state.saslFailures).toBeUndefined();
955
+ });
956
+
957
+ it('lists PLAIN only in 908 when the transport override is set', () => {
958
+ const state = saslReadyState();
959
+ const ctx = unsupportedCtx(state);
960
+ const out = authenticateReducer(state, authenticate('CRAM-MD5'), ctx);
961
+
962
+ expect(sendLinesOf(out.effects[0])).toEqual<RawLine[]>([
963
+ L(`:${SV} 908 alice PLAIN :are the available SASL mechanisms`),
964
+ ]);
965
+ });
966
+ });
967
+
638
968
  // ============================================================================
639
969
  // AUTHENTICATE <unknown mechanism>
640
970
  // ============================================================================
@@ -954,3 +1284,262 @@ describe('authenticateReducer — NickServ-registered nick via ServicesStore', (
954
1284
  expect(firstLineText(out.effects[0])).toBe(`:${SV} 904 alice :SASL authentication failed`);
955
1285
  });
956
1286
  });
1287
+
1288
+ // ============================================================================
1289
+ // Per-connection SASL failure lockout (brute-force hardening)
1290
+ // ============================================================================
1291
+
1292
+ /** Disconnect reason the reducer stamps once the failure lockout trips. */
1293
+ const SASL_LOCKOUT_REASON = 'too many failed SASL attempts';
1294
+
1295
+ describe('authenticateReducer — per-connection SASL failure lockout', () => {
1296
+ /**
1297
+ * Ctx builder with injectable clock + config so window/threshold tests can
1298
+ * advance time and override `sasl.maxFailures` / `sasl.failureWindowSeconds`.
1299
+ */
1300
+ function lockoutCtx(
1301
+ state: ConnectionState,
1302
+ clock: FakeClock,
1303
+ config: ServerConfig = serverConfig,
1304
+ ): Ctx {
1305
+ return buildCtx({
1306
+ serverConfig: config,
1307
+ clock,
1308
+ ids: new SequentialIdFactory(),
1309
+ motd: EmptyMotdProvider,
1310
+ connection: state,
1311
+ services: registeredServices('alice', 'secret'),
1312
+ });
1313
+ }
1314
+
1315
+ /** Drives one full failed PLAIN exchange; returns the payload-frame effects. */
1316
+ function failedAttempt(state: ConnectionState, ctx: Ctx): EffectType[] {
1317
+ authenticateReducer(state, authenticate('PLAIN'), ctx);
1318
+ const out = authenticateReducer(state, authenticate(plainPayload('alice', 'wrong')), ctx);
1319
+ return out.effects;
1320
+ }
1321
+
1322
+ it('emits 904 without a disconnect for the first two failed attempts', () => {
1323
+ const state = saslReadyState();
1324
+ const ctx = lockoutCtx(state, new FakeClock(1_000));
1325
+
1326
+ const first = failedAttempt(state, ctx);
1327
+ expect(first).toEqual<EffectType[]>([
1328
+ Effect.send('c1', [L(`:${SV} 904 alice :SASL authentication failed`)]),
1329
+ ]);
1330
+
1331
+ const second = failedAttempt(state, ctx);
1332
+ expect(second).toEqual<EffectType[]>([
1333
+ Effect.send('c1', [L(`:${SV} 904 alice :SASL authentication failed`)]),
1334
+ ]);
1335
+ });
1336
+
1337
+ it('records failed-attempt timestamps on the connection state', () => {
1338
+ const state = saslReadyState();
1339
+ const ctx = lockoutCtx(state, new FakeClock(1_000));
1340
+
1341
+ failedAttempt(state, ctx);
1342
+ failedAttempt(state, ctx);
1343
+
1344
+ expect(state.saslFailures).toEqual([1_000, 1_000]);
1345
+ });
1346
+
1347
+ it('disconnects after the third failed attempt, 904 delivered first', () => {
1348
+ const state = saslReadyState();
1349
+ const ctx = lockoutCtx(state, new FakeClock(1_000));
1350
+
1351
+ failedAttempt(state, ctx);
1352
+ failedAttempt(state, ctx);
1353
+ const third = failedAttempt(state, ctx);
1354
+
1355
+ expect(third).toEqual<EffectType[]>([
1356
+ Effect.send('c1', [L(`:${SV} 904 alice :SASL authentication failed`)]),
1357
+ Effect.disconnect('c1', SASL_LOCKOUT_REASON),
1358
+ ]);
1359
+ });
1360
+
1361
+ it('honours a configured sasl.maxFailures override', () => {
1362
+ const config: ServerConfig = { ...serverConfig, sasl: { maxFailures: 2 } };
1363
+ const state = saslReadyState();
1364
+ const ctx = lockoutCtx(state, new FakeClock(1_000), config);
1365
+
1366
+ failedAttempt(state, ctx);
1367
+ const second = failedAttempt(state, ctx);
1368
+
1369
+ expect(second).toEqual<EffectType[]>([
1370
+ Effect.send('c1', [L(`:${SV} 904 alice :SASL authentication failed`)]),
1371
+ Effect.disconnect('c1', SASL_LOCKOUT_REASON),
1372
+ ]);
1373
+ });
1374
+
1375
+ it('does not trip when prior failures fall outside the window (sliding window)', () => {
1376
+ const state = saslReadyState();
1377
+ const clock = new FakeClock(1_000);
1378
+ const ctx = lockoutCtx(state, clock);
1379
+
1380
+ failedAttempt(state, ctx); // t = 1_000
1381
+ clock.advance(30_000);
1382
+ failedAttempt(state, ctx); // t = 31_000
1383
+ clock.advance(31_000);
1384
+ const third = failedAttempt(state, ctx); // t = 62_000; t = 1_000 is 61s old → pruned
1385
+
1386
+ expect(third).toEqual<EffectType[]>([
1387
+ Effect.send('c1', [L(`:${SV} 904 alice :SASL authentication failed`)]),
1388
+ ]);
1389
+ expect(state.saslFailures).toEqual([31_000, 62_000]);
1390
+ });
1391
+
1392
+ it('trips once three failures land inside the window again after pruning', () => {
1393
+ const state = saslReadyState();
1394
+ const clock = new FakeClock(1_000);
1395
+ const ctx = lockoutCtx(state, clock);
1396
+
1397
+ failedAttempt(state, ctx); // t = 1_000
1398
+ clock.advance(30_000);
1399
+ failedAttempt(state, ctx); // t = 31_000
1400
+ clock.advance(31_000);
1401
+ failedAttempt(state, ctx); // t = 62_000 (count: 2)
1402
+ clock.advance(1_000);
1403
+ const tripping = failedAttempt(state, ctx); // t = 63_000 (31_000/62_000/63_000 in window)
1404
+
1405
+ expect(tripping).toEqual<EffectType[]>([
1406
+ Effect.send('c1', [L(`:${SV} 904 alice :SASL authentication failed`)]),
1407
+ Effect.disconnect('c1', SASL_LOCKOUT_REASON),
1408
+ ]);
1409
+ });
1410
+
1411
+ it('honours a configured sasl.failureWindowSeconds override', () => {
1412
+ const config: ServerConfig = { ...serverConfig, sasl: { failureWindowSeconds: 1 } };
1413
+ const state = saslReadyState();
1414
+ const clock = new FakeClock(1_000);
1415
+ const ctx = lockoutCtx(state, clock, config);
1416
+
1417
+ failedAttempt(state, ctx); // t = 1_000
1418
+ clock.advance(1_100);
1419
+ failedAttempt(state, ctx); // t = 2_100 (t = 1_000 pruned)
1420
+ clock.advance(1_100);
1421
+ const third = failedAttempt(state, ctx); // t = 3_200 (t = 2_100 pruned)
1422
+
1423
+ expect(third).toEqual<EffectType[]>([
1424
+ Effect.send('c1', [L(`:${SV} 904 alice :SASL authentication failed`)]),
1425
+ ]);
1426
+ expect(state.saslFailures).toEqual([3_200]);
1427
+ });
1428
+
1429
+ it('resets the failure counter after a successful authentication', () => {
1430
+ const state = saslReadyState();
1431
+ const ctx = lockoutCtx(state, new FakeClock(1_000));
1432
+
1433
+ failedAttempt(state, ctx);
1434
+ failedAttempt(state, ctx);
1435
+ expect(state.saslFailures).toEqual([1_000, 1_000]);
1436
+
1437
+ authenticateReducer(state, authenticate('PLAIN'), ctx);
1438
+ authenticateReducer(state, authenticate(plainPayload('alice', 'secret')), ctx);
1439
+
1440
+ expect(state.account).toBe('alice');
1441
+ expect(state.saslFailures).toBeUndefined();
1442
+ });
1443
+
1444
+ it('does not count AUTHENTICATE * (abort) as a failure or reset the counter', () => {
1445
+ const state = saslReadyState();
1446
+ const ctx = lockoutCtx(state, new FakeClock(1_000));
1447
+
1448
+ failedAttempt(state, ctx); // count: 1
1449
+
1450
+ // Abort mid-exchange: clears the exchange scratch state but must neither
1451
+ // count as a failure nor let the attacker launder the counter away.
1452
+ authenticateReducer(state, authenticate('PLAIN'), ctx);
1453
+ const abortOut = authenticateReducer(state, authenticate('*'), ctx);
1454
+ expect(firstLineText(abortOut.effects[0])).toContain(' 906 ');
1455
+
1456
+ failedAttempt(state, ctx); // count: 2 — no disconnect yet
1457
+ const third = failedAttempt(state, ctx); // count: 3 → trips
1458
+
1459
+ expect(third).toEqual<EffectType[]>([
1460
+ Effect.send('c1', [L(`:${SV} 904 alice :SASL authentication failed`)]),
1461
+ Effect.disconnect('c1', SASL_LOCKOUT_REASON),
1462
+ ]);
1463
+ });
1464
+
1465
+ it('does not count 908 unknown-mechanism rejections toward the lockout', () => {
1466
+ const state = saslReadyState();
1467
+ const ctx = lockoutCtx(state, new FakeClock(1_000));
1468
+
1469
+ for (let i = 0; i < 3; i++) {
1470
+ authenticateReducer(state, authenticate('CRAM-MD5'), ctx);
1471
+ }
1472
+
1473
+ const attempt = failedAttempt(state, ctx);
1474
+ expect(attempt).toEqual<EffectType[]>([
1475
+ Effect.send('c1', [L(`:${SV} 904 alice :SASL authentication failed`)]),
1476
+ ]);
1477
+ expect(state.saslFailures).toEqual([1_000]);
1478
+ });
1479
+
1480
+ it('does not count 905 ERR_SASLTOOLONG rejections toward the lockout', () => {
1481
+ const state = saslReadyState();
1482
+ const ctx = lockoutCtx(state, new FakeClock(1_000));
1483
+
1484
+ for (let i = 0; i < 3; i++) {
1485
+ authenticateReducer(state, authenticate('PLAIN'), ctx);
1486
+ const out = authenticateReducer(state, authenticate(`${'A'.repeat(8193)}`), ctx);
1487
+ expect(firstLineText(out.effects[0])).toContain(' 905 ');
1488
+ }
1489
+
1490
+ const attempt = failedAttempt(state, ctx);
1491
+ expect(attempt).toEqual<EffectType[]>([
1492
+ Effect.send('c1', [L(`:${SV} 904 alice :SASL authentication failed`)]),
1493
+ ]);
1494
+ expect(state.saslFailures).toEqual([1_000]);
1495
+ });
1496
+
1497
+ it('counts malformed-base64 904 failures toward the lockout', () => {
1498
+ const state = saslReadyState();
1499
+ const ctx = lockoutCtx(state, new FakeClock(1_000));
1500
+
1501
+ for (let i = 0; i < 2; i++) {
1502
+ authenticateReducer(state, authenticate('PLAIN'), ctx);
1503
+ const out = authenticateReducer(state, authenticate('!!!notbase64!!!'), ctx);
1504
+ expect(firstLineText(out.effects[0])).toContain(' 904 ');
1505
+ }
1506
+
1507
+ const third = failedAttempt(state, ctx);
1508
+ expect(third).toEqual<EffectType[]>([
1509
+ Effect.send('c1', [L(`:${SV} 904 alice :SASL authentication failed`)]),
1510
+ Effect.disconnect('c1', SASL_LOCKOUT_REASON),
1511
+ ]);
1512
+ });
1513
+
1514
+ it('counts EXTERNAL verification failures toward the lockout', () => {
1515
+ const state = saslReadyState();
1516
+ const services = registeredServices('alice', 'secret');
1517
+ const provider = new FakeMtlsIdentityProvider('c1', undefined);
1518
+ const clock = new FakeClock(1_000);
1519
+ const ctx = buildCtx({
1520
+ // EXTERNAL must be opted in for the exchange to reach the verify
1521
+ // step whose failure the lockout counts.
1522
+ serverConfig: externalServerConfig,
1523
+ clock,
1524
+ ids: new SequentialIdFactory(),
1525
+ motd: EmptyMotdProvider,
1526
+ connection: state,
1527
+ services,
1528
+ mtlsIdentity: provider,
1529
+ });
1530
+
1531
+ for (let i = 0; i < 2; i++) {
1532
+ authenticateReducer(state, authenticate('EXTERNAL'), ctx);
1533
+ const out = authenticateReducer(state, authenticate('+'), ctx);
1534
+ expect(firstLineText(out.effects[0])).toContain(' 904 ');
1535
+ }
1536
+
1537
+ authenticateReducer(state, authenticate('EXTERNAL'), ctx);
1538
+ const third = authenticateReducer(state, authenticate('+'), ctx);
1539
+
1540
+ expect(third.effects).toEqual<EffectType[]>([
1541
+ Effect.send('c1', [L(`:${SV} 904 alice :SASL authentication failed`)]),
1542
+ Effect.disconnect('c1', SASL_LOCKOUT_REASON),
1543
+ ]);
1544
+ });
1545
+ });