serverless-ircd 0.9.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 (254) hide show
  1. package/.github/workflows/ci.yml +28 -0
  2. package/.github/workflows/deploy-aws.yml +156 -32
  3. package/.github/workflows/deploy-cf-tcp.yml +35 -9
  4. package/.github/workflows/deploy-cf.yml +40 -14
  5. package/CHANGELOG.md +594 -0
  6. package/README.md +286 -60
  7. package/apps/aws-stack/README.md +3 -5
  8. package/apps/aws-stack/bin/aws.ts +118 -9
  9. package/apps/aws-stack/cdk.json +0 -3
  10. package/apps/aws-stack/package.json +3 -4
  11. package/apps/aws-stack/src/aws-stack.ts +398 -67
  12. package/apps/aws-stack/src/static-site.ts +323 -0
  13. package/apps/aws-stack/tests/smoke-helpers.test.ts +1 -1
  14. package/apps/aws-stack/tests/stack.test.ts +714 -105
  15. package/apps/aws-stack/tests/static-site.test.ts +491 -0
  16. package/apps/aws-stack/tests/synth-no-bundle.test.ts +0 -1
  17. package/apps/cf-tcp-container/Dockerfile +37 -5
  18. package/apps/cf-tcp-container/package.json +7 -3
  19. package/apps/cf-tcp-container/src/config-loader.ts +113 -2
  20. package/apps/cf-tcp-container/src/container-server.ts +267 -87
  21. package/apps/cf-tcp-container/src/main.ts +22 -7
  22. package/apps/cf-tcp-container/src/proxy-protocol.ts +112 -0
  23. package/apps/cf-tcp-container/terraform/spectrum.tf +40 -11
  24. package/apps/cf-tcp-container/tests/config-loader.test.ts +170 -0
  25. package/apps/cf-tcp-container/tests/container-server-tls.test.ts +382 -0
  26. package/apps/cf-tcp-container/tests/container-server.test.ts +358 -31
  27. package/apps/cf-tcp-container/tests/dockerfile.test.ts +110 -0
  28. package/apps/cf-tcp-container/tests/proxy-protocol.test.ts +187 -0
  29. package/apps/cf-tcp-container/tests/spectrum-terraform.test.ts +135 -0
  30. package/apps/cf-tcp-container/tests/tls-e2e.test.ts +5 -1
  31. package/apps/cf-tcp-container/wrangler.toml +18 -14
  32. package/apps/cf-worker/package.json +3 -4
  33. package/apps/cf-worker/src/worker.ts +77 -5
  34. package/apps/cf-worker/tests/raw-modules.d.ts +11 -0
  35. package/apps/cf-worker/tests/smoke.test.ts +4 -0
  36. package/apps/cf-worker/tests/wrangler-config.test.ts +47 -0
  37. package/apps/cf-worker/tests/ws-admission.test.ts +112 -0
  38. package/apps/cf-worker/tests/ws-rate-limit.test.ts +133 -0
  39. package/apps/cf-worker/wrangler.test.toml +15 -1
  40. package/apps/cf-worker/wrangler.toml +95 -77
  41. package/apps/local-cli/package.json +1 -1
  42. package/apps/local-cli/src/config-loader.ts +14 -2
  43. package/apps/local-cli/src/line-scanner.ts +26 -0
  44. package/apps/local-cli/src/server.ts +44 -19
  45. package/apps/local-cli/tests/line-scanner.test.ts +64 -0
  46. package/apps/local-cli/tests/tcp.test.ts +29 -0
  47. package/apps/web/landing/favicon.ico +0 -0
  48. package/apps/web/landing/index.html +1 -0
  49. package/apps/web/package.json +2 -2
  50. package/apps/web/scripts/build.mjs +66 -4
  51. package/apps/web/src/build-env.ts +125 -4
  52. package/apps/web/src/config-schema.ts +20 -6
  53. package/apps/web/static/{config.staging.json → config.prod-aws.json} +3 -2
  54. package/apps/web/tests/build-env.test.ts +210 -9
  55. package/apps/web/tests/build-smoke.test.ts +2 -2
  56. package/apps/web/tests/config-schema.test.ts +149 -25
  57. package/docs/AWS-Deployment.md +793 -118
  58. package/docs/AWS-TCP-Deployment.md +57 -47
  59. package/docs/Chat-History.md +55 -0
  60. package/docs/Cloudflare-Deployment-Guide.md +95 -114
  61. package/docs/Cloudflare-TCP-Deployment.md +160 -101
  62. package/docs/Release-Process.md +27 -23
  63. package/docs/SASL-EXTERNAL.md +175 -0
  64. package/docs/Services.md +69 -22
  65. package/docs/WebClientGuide.md +35 -26
  66. package/package.json +7 -10
  67. package/packages/aws-adapter/package.json +1 -1
  68. package/packages/aws-adapter/src/admission.ts +28 -13
  69. package/packages/aws-adapter/src/aws-runtime.ts +30 -3
  70. package/packages/aws-adapter/src/cdk-table-defs.ts +39 -16
  71. package/packages/aws-adapter/src/config-loader.ts +153 -8
  72. package/packages/aws-adapter/src/dynamo-services-store.ts +19 -0
  73. package/packages/aws-adapter/src/handlers/connect.ts +73 -1
  74. package/packages/aws-adapter/src/handlers/default.ts +279 -123
  75. package/packages/aws-adapter/src/handlers/index.ts +98 -25
  76. package/packages/aws-adapter/src/handlers/nlb-stream.ts +135 -14
  77. package/packages/aws-adapter/src/index.ts +5 -7
  78. package/packages/aws-adapter/src/ip-admission.ts +79 -0
  79. package/packages/aws-adapter/src/origin-allowlist.ts +94 -0
  80. package/packages/aws-adapter/src/serialize.ts +23 -0
  81. package/packages/aws-adapter/src/tables.ts +11 -12
  82. package/packages/aws-adapter/tests/admission.test.ts +60 -2
  83. package/packages/aws-adapter/tests/aws-harness.ts +23 -2
  84. package/packages/aws-adapter/tests/aws-runtime.test.ts +64 -0
  85. package/packages/aws-adapter/tests/config-loader.test.ts +217 -0
  86. package/packages/aws-adapter/tests/connect.test.ts +323 -3
  87. package/packages/aws-adapter/tests/default-frame-limit.test.ts +231 -0
  88. package/packages/aws-adapter/tests/default-occ.test.ts +226 -0
  89. package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +134 -1
  90. package/packages/aws-adapter/tests/handlers.test.ts +174 -12
  91. package/packages/aws-adapter/tests/migrate-accounts-to-services.test.ts +164 -0
  92. package/packages/aws-adapter/tests/nlb-secure.test.ts +362 -0
  93. package/packages/aws-adapter/tests/nlb-stream.test.ts +628 -9
  94. package/packages/aws-adapter/tests/origin-allowlist.test.ts +110 -0
  95. package/packages/aws-adapter/tests/ping-checker.test.ts +0 -1
  96. package/packages/aws-adapter/tests/stats.test.ts +0 -3
  97. package/packages/aws-adapter/tests/sweeper.test.ts +0 -1
  98. package/packages/aws-adapter/tests/tables.test.ts +1 -8
  99. package/packages/aws-adapter/tests/transactions.test.ts +0 -1
  100. package/packages/cf-adapter/package.json +1 -5
  101. package/packages/cf-adapter/src/cf-runtime.ts +100 -10
  102. package/packages/cf-adapter/src/channel-do.ts +13 -3
  103. package/packages/cf-adapter/src/config-loader.ts +133 -8
  104. package/packages/cf-adapter/src/connection-do.ts +406 -116
  105. package/packages/cf-adapter/src/counter-do.ts +142 -0
  106. package/packages/cf-adapter/src/d1-services-store.ts +105 -26
  107. package/packages/cf-adapter/src/env.ts +99 -10
  108. package/packages/cf-adapter/src/index.ts +17 -7
  109. package/packages/cf-adapter/src/rate-limit-do.ts +87 -0
  110. package/packages/cf-adapter/tests/cf-runtime.test.ts +205 -16
  111. package/packages/cf-adapter/tests/channel-do.test.ts +118 -1
  112. package/packages/cf-adapter/tests/config-loader.test.ts +159 -0
  113. package/packages/cf-adapter/tests/connection-do-counter.test.ts +165 -0
  114. package/packages/cf-adapter/tests/connection-do-coverage.test.ts +460 -0
  115. package/packages/cf-adapter/tests/connection-do-frame-limit.test.ts +177 -0
  116. package/packages/cf-adapter/tests/connection-do-pure.test.ts +164 -54
  117. package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +62 -38
  118. package/packages/cf-adapter/tests/connection-do-ws-spec-contract.test.ts +7 -4
  119. package/packages/cf-adapter/tests/counter-do.test.ts +181 -0
  120. package/packages/cf-adapter/tests/d1-services-store.test.ts +245 -3
  121. package/packages/cf-adapter/tests/rate-limit-do.test.ts +160 -0
  122. package/packages/cf-adapter/tests/serialize.test.ts +25 -0
  123. package/packages/cf-adapter/tests/worker/main.ts +4 -0
  124. package/packages/cf-adapter/wrangler.test.toml +18 -1
  125. package/packages/in-memory-runtime/package.json +1 -1
  126. package/packages/in-memory-runtime/src/in-memory-runtime.ts +25 -0
  127. package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +74 -0
  128. package/packages/irc-core/package.json +1 -1
  129. package/packages/irc-core/src/account-migration.ts +140 -0
  130. package/packages/irc-core/src/caps/capabilities.ts +20 -10
  131. package/packages/irc-core/src/certfp.ts +178 -0
  132. package/packages/irc-core/src/commands/account-auth.ts +16 -19
  133. package/packages/irc-core/src/commands/cap.ts +10 -2
  134. package/packages/irc-core/src/commands/chanserv.ts +117 -14
  135. package/packages/irc-core/src/commands/chathistory.ts +13 -5
  136. package/packages/irc-core/src/commands/hostserv.ts +84 -8
  137. package/packages/irc-core/src/commands/index.ts +2 -1
  138. package/packages/irc-core/src/commands/invite.ts +1 -7
  139. package/packages/irc-core/src/commands/join.ts +1 -16
  140. package/packages/irc-core/src/commands/kick.ts +1 -8
  141. package/packages/irc-core/src/commands/list.ts +1 -8
  142. package/packages/irc-core/src/commands/memoserv.ts +1 -1
  143. package/packages/irc-core/src/commands/mode.ts +1 -8
  144. package/packages/irc-core/src/commands/multiline.ts +4 -10
  145. package/packages/irc-core/src/commands/names.ts +53 -13
  146. package/packages/irc-core/src/commands/nickserv.ts +161 -11
  147. package/packages/irc-core/src/commands/oper.ts +361 -8
  148. package/packages/irc-core/src/commands/part.ts +4 -10
  149. package/packages/irc-core/src/commands/privmsg.ts +8 -4
  150. package/packages/irc-core/src/commands/registration.ts +148 -4
  151. package/packages/irc-core/src/commands/sasl.ts +154 -46
  152. package/packages/irc-core/src/commands/topic.ts +10 -12
  153. package/packages/irc-core/src/commands/who.ts +1 -8
  154. package/packages/irc-core/src/config.ts +424 -25
  155. package/packages/irc-core/src/credential-hashing.ts +11 -54
  156. package/packages/irc-core/src/effects.ts +24 -0
  157. package/packages/irc-core/src/flood-control.ts +10 -10
  158. package/packages/irc-core/src/frame-rate-limit.ts +82 -0
  159. package/packages/irc-core/src/index.ts +9 -0
  160. package/packages/irc-core/src/oper-hashing.ts +43 -0
  161. package/packages/irc-core/src/oper-lockout.ts +87 -0
  162. package/packages/irc-core/src/ports.ts +529 -190
  163. package/packages/irc-core/src/protocol/bytes.ts +65 -0
  164. package/packages/irc-core/src/protocol/channel-name.ts +37 -0
  165. package/packages/irc-core/src/protocol/index.ts +12 -1
  166. package/packages/irc-core/src/protocol/outbound.ts +43 -10
  167. package/packages/irc-core/src/protocol/parser.ts +79 -10
  168. package/packages/irc-core/src/state/connection.ts +13 -0
  169. package/packages/irc-core/src/types.ts +266 -23
  170. package/packages/irc-core/src/ws-framing.ts +5 -4
  171. package/packages/irc-core/tests/account-migration.test.ts +133 -0
  172. package/packages/irc-core/tests/bytes.test.ts +89 -0
  173. package/packages/irc-core/tests/certfp.test.ts +117 -0
  174. package/packages/irc-core/tests/commands/cap.test.ts +76 -2
  175. package/packages/irc-core/tests/commands/chanserv.test.ts +166 -0
  176. package/packages/irc-core/tests/commands/chathistory.test.ts +140 -0
  177. package/packages/irc-core/tests/commands/hostserv.test.ts +316 -0
  178. package/packages/irc-core/tests/commands/join.test.ts +78 -1
  179. package/packages/irc-core/tests/commands/markread.test.ts +54 -0
  180. package/packages/irc-core/tests/commands/memoserv.test.ts +19 -0
  181. package/packages/irc-core/tests/commands/names.test.ts +193 -0
  182. package/packages/irc-core/tests/commands/nickserv.test.ts +419 -3
  183. package/packages/irc-core/tests/commands/oper.test.ts +574 -1
  184. package/packages/irc-core/tests/commands/privmsg.test.ts +16 -0
  185. package/packages/irc-core/tests/commands/registration.test.ts +602 -133
  186. package/packages/irc-core/tests/commands/sasl.test.ts +742 -172
  187. package/packages/irc-core/tests/commands/topic.test.ts +137 -2
  188. package/packages/irc-core/tests/commands/unified-account.test.ts +104 -84
  189. package/packages/irc-core/tests/config.test.ts +534 -2
  190. package/packages/irc-core/tests/credential-hashing.test.ts +0 -78
  191. package/packages/irc-core/tests/effects.test.ts +14 -0
  192. package/packages/irc-core/tests/flood-control.test.ts +29 -1
  193. package/packages/irc-core/tests/frame-rate-limit.test.ts +98 -0
  194. package/packages/irc-core/tests/message-store.test.ts +5 -0
  195. package/packages/irc-core/tests/oper-hashing.test.ts +60 -0
  196. package/packages/irc-core/tests/oper-lockout.test.ts +74 -0
  197. package/packages/irc-core/tests/outbound.test.ts +148 -0
  198. package/packages/irc-core/tests/parser.test.ts +287 -5
  199. package/packages/irc-core/tests/persistent-services-store.test.ts +212 -12
  200. package/packages/irc-core/tests/ports.test.ts +170 -7
  201. package/packages/irc-core/tests/services-store.test.ts +567 -1
  202. package/packages/irc-core/tests/ws-framing.test.ts +45 -0
  203. package/packages/irc-core/vitest.config.ts +6 -1
  204. package/packages/irc-server/package.json +1 -1
  205. package/packages/irc-server/src/actor.ts +123 -22
  206. package/packages/irc-server/src/dispatch.ts +1 -0
  207. package/packages/irc-server/src/index.ts +7 -0
  208. package/packages/irc-server/src/redact.ts +159 -0
  209. package/packages/irc-server/src/runtime.ts +14 -0
  210. package/packages/irc-server/src/transport.ts +28 -1
  211. package/packages/irc-server/tests/actor.test.ts +563 -54
  212. package/packages/irc-server/tests/dispatch.test.ts +31 -0
  213. package/packages/irc-server/tests/redact.test.ts +198 -0
  214. package/packages/irc-server/tests/runtime.test.ts +2 -0
  215. package/packages/irc-server/tests/transport.test.ts +66 -0
  216. package/packages/irc-test-support/package.json +1 -1
  217. package/packages/irc-test-support/src/in-memory-harness.ts +4 -0
  218. package/pnpm-workspace.yaml +1 -0
  219. package/scripts/__tests__/deploy-web-aws.test.ts +491 -0
  220. package/scripts/deploy-web-aws.mjs +290 -0
  221. package/scripts/package.json +23 -0
  222. package/scripts/tsconfig.test.json +12 -0
  223. package/scripts/vitest.config.ts +19 -0
  224. package/tools/ci-hardening/package.json +2 -2
  225. package/tools/ci-hardening/src/cf-deploy-cli.ts +3 -0
  226. package/tools/ci-hardening/src/cf-deploy.ts +118 -0
  227. package/tools/ci-hardening/src/deploy-hostname.ts +118 -0
  228. package/tools/ci-hardening/src/env-var-drift.ts +192 -0
  229. package/tools/ci-hardening/src/hostname-guard.ts +11 -0
  230. package/tools/ci-hardening/src/index.ts +19 -0
  231. package/tools/ci-hardening/src/validate.ts +57 -0
  232. package/tools/ci-hardening/tests/__wrangler_missing__.toml +2 -0
  233. package/tools/ci-hardening/tests/__wrangler_placeholder__.toml +3 -0
  234. package/tools/ci-hardening/tests/__wrangler_real__.toml +3 -0
  235. package/tools/ci-hardening/tests/cf-deploy.test.ts +200 -0
  236. package/tools/ci-hardening/tests/deploy-aws-oidc.test.ts +96 -0
  237. package/tools/ci-hardening/tests/deploy-hostname.test.ts +348 -0
  238. package/tools/ci-hardening/tests/env-var-drift.test.ts +284 -0
  239. package/tools/ci-hardening/tests/validate.test.ts +42 -0
  240. package/tools/ci-hardening/vitest.config.ts +5 -1
  241. package/tools/hash-oper-cred.ts +85 -0
  242. package/tools/load-test/package.json +1 -1
  243. package/tools/migrate-accounts-to-services.ts +270 -0
  244. package/tools/package.json +2 -1
  245. package/tools/seed-aws-accounts.ts +35 -10
  246. package/tools/seed-cf-accounts.ts +42 -9
  247. package/tools/tcp-ws-forwarder/package.json +1 -1
  248. package/packages/aws-adapter/src/account-store.ts +0 -121
  249. package/packages/aws-adapter/src/dynamo-account-store.ts +0 -95
  250. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +0 -223
  251. package/packages/aws-adapter/tests/account-store.test.ts +0 -276
  252. package/packages/cf-adapter/src/d1-account-store.ts +0 -198
  253. package/packages/cf-adapter/tests/d1-account-store.test.ts +0 -274
  254. package/packages/irc-core/tests/account-store.test.ts +0 -131
@@ -1,29 +1,28 @@
1
- import { describe, expect, it } 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
- import type {
8
- AccountStore,
9
- AwayStore,
10
- MtlsIdentityProvider,
11
- SaslPayload,
12
- SaslResult,
13
- ServicesStore,
14
- } from '../../src/ports';
9
+ import type { AwayStore, MtlsIdentityProvider, ServicesStore } from '../../src/ports';
15
10
  import {
16
11
  EmptyMotdProvider,
17
12
  FakeClock,
18
13
  InMemoryAwayStore,
19
14
  InMemoryServicesStore,
20
15
  SequentialIdFactory,
16
+ resetVerifyNickTimingSpy,
17
+ setVerifyNickHashedPassword,
21
18
  } from '../../src/ports';
22
19
  import { encodeBase64 } from '../../src/protocol/base64';
23
20
  import type { IrcMessage } from '../../src/protocol/messages';
24
21
  import { type ConnectionState, createConnection } from '../../src/state/connection';
25
22
  import { type Ctx, type ServerConfig, buildCtx } from '../../src/types';
26
23
 
24
+ type VerifyHashedPasswordFn = (password: string, entry: HashedAccountCredential) => boolean;
25
+
27
26
  const serverConfig: ServerConfig = {
28
27
  serverName: 'irc.example.com',
29
28
  networkName: 'ExampleNet',
@@ -36,6 +35,17 @@ const serverConfig: ServerConfig = {
36
35
  quitMessage: 'Client Quit',
37
36
  };
38
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
+
39
49
  const L = (text: string): RawLine => ({ text });
40
50
 
41
51
  const SV = 'irc.example.com';
@@ -50,49 +60,62 @@ function saslReadyState(): ConnectionState {
50
60
  s.registration = 'registering';
51
61
  s.capNegotiating = true;
52
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;
53
67
  return s;
54
68
  }
55
69
 
70
+ /**
71
+ * Builds a {@link InMemoryServicesStore} with `nick` registered under
72
+ * `password`. The single credential home for SASL PLAIN / EXTERNAL / PASS.
73
+ */
74
+ function registeredServices(
75
+ nick = 'alice',
76
+ password = 'secret',
77
+ email = 'alice@example.com',
78
+ ): InMemoryServicesStore {
79
+ const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
80
+ services.registerNick(nick, password, email);
81
+ return services;
82
+ }
83
+
56
84
  function makeCtx(
57
85
  state: ConnectionState,
58
- accounts?: AccountStore,
59
- mtlsIdentity?: MtlsIdentityProvider,
60
86
  services?: ServicesStore,
87
+ mtlsIdentity?: MtlsIdentityProvider,
61
88
  away?: AwayStore,
62
89
  ): Ctx {
63
90
  return buildCtx({
64
- 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,
65
96
  clock: new FakeClock(1_000),
66
97
  ids: new SequentialIdFactory(),
67
98
  motd: EmptyMotdProvider,
68
99
  connection: state,
69
- ...(accounts !== undefined ? { accounts } : {}),
70
- ...(mtlsIdentity !== undefined ? { mtlsIdentity } : {}),
71
100
  ...(services !== undefined ? { services } : {}),
101
+ ...(mtlsIdentity !== undefined ? { mtlsIdentity } : {}),
72
102
  ...(away !== undefined ? { away } : {}),
73
103
  });
74
104
  }
75
105
 
76
- /** A capture-and-respond fake AccountStore. */
77
- class FakeAccountStore implements AccountStore {
78
- readonly calls: Array<{ mech: string; payload: SaslPayload }> = [];
79
- constructor(private readonly result: SaslResult) {}
80
- verify(mech: string, payload: SaslPayload): SaslResult {
81
- this.calls.push({ mech, payload });
82
- return this.result;
83
- }
84
- }
85
-
86
106
  /** A fake MtlsIdentityProvider that returns a canned identity for one connId. */
87
107
  class FakeMtlsIdentityProvider implements MtlsIdentityProvider {
88
108
  readonly calls: string[] = [];
89
109
  constructor(
90
110
  private readonly connId: string,
91
- private readonly identity: string | undefined,
111
+ private readonly subject: string | undefined,
112
+ private readonly fingerprint?: string,
92
113
  ) {}
93
- getIdentity(connId: string): string | undefined {
114
+ getIdentity(connId: string): CertIdentity | undefined {
94
115
  this.calls.push(connId);
95
- return connId === this.connId ? this.identity : undefined;
116
+ return connId === this.connId && this.subject !== undefined
117
+ ? certIdentityFromSubject(this.subject, this.fingerprint)
118
+ : undefined;
96
119
  }
97
120
  }
98
121
 
@@ -135,10 +158,10 @@ describe('authenticateReducer — PLAIN success', () => {
135
158
  expect(state.saslMech).toBe('PLAIN');
136
159
  });
137
160
 
138
- it('completes with 900 and 903 when the AccountStore accepts the credentials', () => {
161
+ it('completes with 900 and 903 when the ServicesStore accepts the credentials', () => {
139
162
  const state = saslReadyState();
140
- const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
141
- const ctx = makeCtx(state, accounts);
163
+ const services = registeredServices('alice', 'secret');
164
+ const ctx = makeCtx(state, services);
142
165
 
143
166
  authenticateReducer(state, authenticate('PLAIN'), ctx);
144
167
  const out = authenticateReducer(state, authenticate(plainPayload('alice', 'secret')), ctx);
@@ -152,8 +175,8 @@ describe('authenticateReducer — PLAIN success', () => {
152
175
 
153
176
  it('records the authenticated account name on the connection', () => {
154
177
  const state = saslReadyState();
155
- const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
156
- const ctx = makeCtx(state, accounts);
178
+ const services = registeredServices('alice', 'secret');
179
+ const ctx = makeCtx(state, services);
157
180
 
158
181
  authenticateReducer(state, authenticate('PLAIN'), ctx);
159
182
  authenticateReducer(state, authenticate(plainPayload('alice', 'secret')), ctx);
@@ -163,11 +186,10 @@ describe('authenticateReducer — PLAIN success', () => {
163
186
 
164
187
  it('seeds lastReadMarkers from the ServicesStore at identify', () => {
165
188
  const state = saslReadyState();
166
- const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
167
- const services = new InMemoryServicesStore();
189
+ const services = registeredServices('alice', 'secret');
168
190
  services.setLastReadMarker('alice', '#foo', 'm1');
169
191
  services.setLastReadMarker('alice', '#bar', 'm2');
170
- const ctx = makeCtx(state, accounts, undefined, services);
192
+ const ctx = makeCtx(state, services);
171
193
 
172
194
  authenticateReducer(state, authenticate('PLAIN'), ctx);
173
195
  authenticateReducer(state, authenticate(plainPayload('alice', 'secret')), ctx);
@@ -178,10 +200,9 @@ describe('authenticateReducer — PLAIN success', () => {
178
200
 
179
201
  it('does not seed markers for a different account', () => {
180
202
  const state = saslReadyState();
181
- const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
182
- const services = new InMemoryServicesStore();
203
+ const services = registeredServices('alice', 'secret');
183
204
  services.setLastReadMarker('bob', '#foo', 'm9');
184
- const ctx = makeCtx(state, accounts, undefined, services);
205
+ const ctx = makeCtx(state, services);
185
206
 
186
207
  authenticateReducer(state, authenticate('PLAIN'), ctx);
187
208
  authenticateReducer(state, authenticate(plainPayload('alice', 'secret')), ctx);
@@ -191,10 +212,10 @@ describe('authenticateReducer — PLAIN success', () => {
191
212
 
192
213
  it('replays the persisted away reason on identify (draft/pre-away)', () => {
193
214
  const state = saslReadyState();
194
- const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
215
+ const services = registeredServices('alice', 'secret');
195
216
  const away = new InMemoryAwayStore();
196
217
  away.set('alice', 'brb');
197
- const ctx = makeCtx(state, accounts, undefined, undefined, away);
218
+ const ctx = makeCtx(state, services, undefined, away);
198
219
 
199
220
  authenticateReducer(state, authenticate('PLAIN'), ctx);
200
221
  authenticateReducer(state, authenticate(plainPayload('alice', 'secret')), ctx);
@@ -204,10 +225,10 @@ describe('authenticateReducer — PLAIN success', () => {
204
225
 
205
226
  it('emits 306 RPL_NOWAWAY after identifying when an away reason is replayed', () => {
206
227
  const state = saslReadyState();
207
- const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
228
+ const services = registeredServices('alice', 'secret');
208
229
  const away = new InMemoryAwayStore();
209
230
  away.set('alice', 'brb');
210
- const ctx = makeCtx(state, accounts, undefined, undefined, away);
231
+ const ctx = makeCtx(state, services, undefined, away);
211
232
 
212
233
  authenticateReducer(state, authenticate('PLAIN'), ctx);
213
234
  const out = authenticateReducer(state, authenticate(plainPayload('alice', 'secret')), ctx);
@@ -220,9 +241,9 @@ describe('authenticateReducer — PLAIN success', () => {
220
241
 
221
242
  it('does not replay an away reason when none is persisted', () => {
222
243
  const state = saslReadyState();
223
- const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
244
+ const services = registeredServices('alice', 'secret');
224
245
  const away = new InMemoryAwayStore();
225
- const ctx = makeCtx(state, accounts, undefined, undefined, away);
246
+ const ctx = makeCtx(state, services, undefined, away);
226
247
 
227
248
  authenticateReducer(state, authenticate('PLAIN'), ctx);
228
249
  const out = authenticateReducer(state, authenticate(plainPayload('alice', 'secret')), ctx);
@@ -234,8 +255,8 @@ describe('authenticateReducer — PLAIN success', () => {
234
255
 
235
256
  it('does not replay an away reason when no AwayStore is bound', () => {
236
257
  const state = saslReadyState();
237
- const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
238
- const ctx = makeCtx(state, accounts); // no away store
258
+ const services = registeredServices('alice', 'secret');
259
+ const ctx = makeCtx(state, services); // no away store
239
260
 
240
261
  authenticateReducer(state, authenticate('PLAIN'), ctx);
241
262
  const out = authenticateReducer(state, authenticate(plainPayload('alice', 'secret')), ctx);
@@ -247,10 +268,10 @@ describe('authenticateReducer — PLAIN success', () => {
247
268
 
248
269
  it('looks up the persisted away reason case-insensitively', () => {
249
270
  const state = saslReadyState();
250
- const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
271
+ const services = registeredServices('alice', 'secret');
251
272
  const away = new InMemoryAwayStore();
252
273
  away.set('ALICE', 'brb');
253
- const ctx = makeCtx(state, accounts, undefined, undefined, away);
274
+ const ctx = makeCtx(state, services, undefined, away);
254
275
 
255
276
  authenticateReducer(state, authenticate('PLAIN'), ctx);
256
277
  authenticateReducer(state, authenticate(plainPayload('alice', 'secret')), ctx);
@@ -260,10 +281,9 @@ describe('authenticateReducer — PLAIN success', () => {
260
281
 
261
282
  it('delivers queued unread memos on a successful SASL login', () => {
262
283
  const state = saslReadyState();
263
- const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
264
- const services = new InMemoryServicesStore();
284
+ const services = registeredServices('alice', 'secret');
265
285
  services.recordMemo({ from: 'bob', to: 'alice', body: 'Hi alice!', sentAt: 1_000 });
266
- const ctx = makeCtx(state, accounts, undefined, services);
286
+ const ctx = makeCtx(state, services);
267
287
 
268
288
  authenticateReducer(state, authenticate('PLAIN'), ctx);
269
289
  const out = authenticateReducer(state, authenticate(plainPayload('alice', 'secret')), ctx);
@@ -276,10 +296,9 @@ describe('authenticateReducer — PLAIN success', () => {
276
296
 
277
297
  it('marks delivered memos as read after a successful SASL login', () => {
278
298
  const state = saslReadyState();
279
- const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
280
- const services = new InMemoryServicesStore();
299
+ const services = registeredServices('alice', 'secret');
281
300
  services.recordMemo({ from: 'bob', to: 'alice', body: 'Hi alice!', sentAt: 1_000 });
282
- const ctx = makeCtx(state, accounts, undefined, services);
301
+ const ctx = makeCtx(state, services);
283
302
 
284
303
  authenticateReducer(state, authenticate('PLAIN'), ctx);
285
304
  authenticateReducer(state, authenticate(plainPayload('alice', 'secret')), ctx);
@@ -289,11 +308,10 @@ describe('authenticateReducer — PLAIN success', () => {
289
308
 
290
309
  it('does not deliver already-read memos on a SASL login', () => {
291
310
  const state = saslReadyState();
292
- const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
293
- const services = new InMemoryServicesStore();
311
+ const services = registeredServices('alice', 'secret');
294
312
  services.recordMemo({ from: 'bob', to: 'alice', body: 'old', sentAt: 1_000 });
295
313
  services.markMemoRead('alice', 1);
296
- const ctx = makeCtx(state, accounts, undefined, services);
314
+ const ctx = makeCtx(state, services);
297
315
 
298
316
  authenticateReducer(state, authenticate('PLAIN'), ctx);
299
317
  const out = authenticateReducer(state, authenticate(plainPayload('alice', 'secret')), ctx);
@@ -305,10 +323,9 @@ describe('authenticateReducer — PLAIN success', () => {
305
323
  it('applies the assigned HostServ vhost on SASL PLAIN success', () => {
306
324
  const state = saslReadyState();
307
325
  state.host = 'real.example.net';
308
- const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
309
- const services = new InMemoryServicesStore();
326
+ const services = registeredServices('alice', 'secret');
310
327
  services.setVhost('alice', 'cool.example.net');
311
- const ctx = makeCtx(state, accounts, undefined, services);
328
+ const ctx = makeCtx(state, services);
312
329
 
313
330
  authenticateReducer(state, authenticate('PLAIN'), ctx);
314
331
  authenticateReducer(state, authenticate(plainPayload('alice', 'secret')), ctx);
@@ -321,10 +338,9 @@ describe('authenticateReducer — PLAIN success', () => {
321
338
  it('does not emit a CHGHOST broadcast on SASL success (pre-registration, no peers)', () => {
322
339
  const state = saslReadyState();
323
340
  state.host = 'real.example.net';
324
- const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
325
- const services = new InMemoryServicesStore();
341
+ const services = registeredServices('alice', 'secret');
326
342
  services.setVhost('alice', 'cool.example.net');
327
- const ctx = makeCtx(state, accounts, undefined, services);
343
+ const ctx = makeCtx(state, services);
328
344
 
329
345
  authenticateReducer(state, authenticate('PLAIN'), ctx);
330
346
  const out = authenticateReducer(state, authenticate(plainPayload('alice', 'secret')), ctx);
@@ -335,9 +351,8 @@ describe('authenticateReducer — PLAIN success', () => {
335
351
  it('leaves host unchanged on SASL success when no vhost is assigned', () => {
336
352
  const state = saslReadyState();
337
353
  state.host = 'real.example.net';
338
- const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
339
- const services = new InMemoryServicesStore();
340
- const ctx = makeCtx(state, accounts, undefined, services);
354
+ const services = registeredServices('alice', 'secret');
355
+ const ctx = makeCtx(state, services);
341
356
 
342
357
  authenticateReducer(state, authenticate('PLAIN'), ctx);
343
358
  authenticateReducer(state, authenticate(plainPayload('alice', 'secret')), ctx);
@@ -348,9 +363,8 @@ describe('authenticateReducer — PLAIN success', () => {
348
363
 
349
364
  it('delivers nothing on a SASL login when the account has no memos', () => {
350
365
  const state = saslReadyState();
351
- const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
352
- const services = new InMemoryServicesStore();
353
- const ctx = makeCtx(state, accounts, undefined, services);
366
+ const services = registeredServices('alice', 'secret');
367
+ const ctx = makeCtx(state, services);
354
368
 
355
369
  authenticateReducer(state, authenticate('PLAIN'), ctx);
356
370
  const out = authenticateReducer(state, authenticate(plainPayload('alice', 'secret')), ctx);
@@ -361,10 +375,9 @@ describe('authenticateReducer — PLAIN success', () => {
361
375
 
362
376
  it('looks up queued memos case-insensitively on a SASL login', () => {
363
377
  const state = saslReadyState();
364
- const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
365
- const services = new InMemoryServicesStore();
378
+ const services = registeredServices('alice', 'secret');
366
379
  services.recordMemo({ from: 'bob', to: 'ALICE', body: 'folded', sentAt: 1_000 });
367
- const ctx = makeCtx(state, accounts, undefined, services);
380
+ const ctx = makeCtx(state, services);
368
381
 
369
382
  authenticateReducer(state, authenticate('PLAIN'), ctx);
370
383
  const out = authenticateReducer(state, authenticate(plainPayload('alice', 'secret')), ctx);
@@ -377,8 +390,7 @@ describe('authenticateReducer — PLAIN success', () => {
377
390
 
378
391
  it('does not deliver memos when no ServicesStore is bound', () => {
379
392
  const state = saslReadyState();
380
- const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
381
- const ctx = makeCtx(state, accounts);
393
+ const ctx = makeCtx(state);
382
394
 
383
395
  authenticateReducer(state, authenticate('PLAIN'), ctx);
384
396
  const out = authenticateReducer(state, authenticate(plainPayload('alice', 'secret')), ctx);
@@ -389,8 +401,8 @@ describe('authenticateReducer — PLAIN success', () => {
389
401
 
390
402
  it('clears the in-progress mechanism after a successful authentication', () => {
391
403
  const state = saslReadyState();
392
- const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
393
- const ctx = makeCtx(state, accounts);
404
+ const services = registeredServices('alice', 'secret');
405
+ const ctx = makeCtx(state, services);
394
406
 
395
407
  authenticateReducer(state, authenticate('PLAIN'), ctx);
396
408
  authenticateReducer(state, authenticate(plainPayload('alice', 'secret')), ctx);
@@ -398,40 +410,36 @@ describe('authenticateReducer — PLAIN success', () => {
398
410
  expect(state.saslMech).toBeUndefined();
399
411
  });
400
412
 
401
- it('forwards parsed PLAIN credentials to the AccountStore', () => {
413
+ it('forwards parsed PLAIN credentials to services.verifyNick', () => {
402
414
  const state = saslReadyState();
403
- const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
404
- const ctx = makeCtx(state, accounts);
415
+ const services = registeredServices('alice', 'secret');
416
+ const verifyNick = vi.spyOn(services, 'verifyNick');
417
+ const ctx = makeCtx(state, services);
405
418
 
406
419
  authenticateReducer(state, authenticate('PLAIN'), ctx);
407
420
  authenticateReducer(state, authenticate(plainPayload('alice', 'secret')), ctx);
408
421
 
409
- expect(accounts.calls).toEqual([
410
- { mech: 'PLAIN', payload: { kind: 'PLAIN', username: 'alice', password: 'secret' } },
411
- ]);
422
+ expect(verifyNick).toHaveBeenCalledWith('alice', 'secret');
412
423
  });
413
424
 
414
425
  it('ignores the authorization identity field in the PLAIN payload', () => {
415
426
  const state = saslReadyState();
416
- const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
417
- const ctx = makeCtx(state, accounts);
427
+ const services = registeredServices('alice', 'secret');
428
+ const verifyNick = vi.spyOn(services, 'verifyNick');
429
+ const ctx = makeCtx(state, services);
418
430
 
419
431
  authenticateReducer(state, authenticate('PLAIN'), ctx);
420
432
  authenticateReducer(state, authenticate(plainPayload('alice', 'secret', 'bob')), ctx);
421
433
 
422
- expect(accounts.calls[0]?.payload).toEqual({
423
- kind: 'PLAIN',
424
- username: 'alice',
425
- password: 'secret',
426
- });
434
+ expect(verifyNick).toHaveBeenCalledWith('alice', 'secret');
427
435
  });
428
436
 
429
437
  it('targets `*` with an empty hostmask when the client has no nick yet', () => {
430
438
  const state = createConnection({ id: 'c1', connectedSince: 0 });
431
439
  state.capNegotiating = true;
432
440
  state.caps.add('sasl');
433
- const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
434
- const ctx = makeCtx(state, accounts);
441
+ const services = registeredServices('alice', 'secret');
442
+ const ctx = makeCtx(state, services);
435
443
 
436
444
  authenticateReducer(state, authenticate('PLAIN'), ctx);
437
445
  const out = authenticateReducer(state, authenticate(plainPayload('alice', 'secret')), ctx);
@@ -449,10 +457,10 @@ describe('authenticateReducer — PLAIN success', () => {
449
457
  // ============================================================================
450
458
 
451
459
  describe('authenticateReducer — PLAIN failure', () => {
452
- it('emits 904 ERR_SASLFAIL when the AccountStore rejects the credentials', () => {
460
+ it('emits 904 ERR_SASLFAIL when the ServicesStore rejects the credentials', () => {
453
461
  const state = saslReadyState();
454
- const accounts = new FakeAccountStore({ ok: false, reason: 'invalid credentials' });
455
- const ctx = makeCtx(state, accounts);
462
+ const services = registeredServices('alice', 'secret');
463
+ const ctx = makeCtx(state, services);
456
464
 
457
465
  authenticateReducer(state, authenticate('PLAIN'), ctx);
458
466
  const out = authenticateReducer(state, authenticate(plainPayload('alice', 'wrong')), ctx);
@@ -464,8 +472,8 @@ describe('authenticateReducer — PLAIN failure', () => {
464
472
 
465
473
  it('does not record an account on failure', () => {
466
474
  const state = saslReadyState();
467
- const accounts = new FakeAccountStore({ ok: false, reason: 'invalid credentials' });
468
- const ctx = makeCtx(state, accounts);
475
+ const services = registeredServices('alice', 'secret');
476
+ const ctx = makeCtx(state, services);
469
477
 
470
478
  authenticateReducer(state, authenticate('PLAIN'), ctx);
471
479
  authenticateReducer(state, authenticate(plainPayload('alice', 'wrong')), ctx);
@@ -475,8 +483,8 @@ describe('authenticateReducer — PLAIN failure', () => {
475
483
 
476
484
  it('clears the in-progress mechanism after a failure', () => {
477
485
  const state = saslReadyState();
478
- const accounts = new FakeAccountStore({ ok: false, reason: 'invalid credentials' });
479
- const ctx = makeCtx(state, accounts);
486
+ const services = registeredServices('alice', 'secret');
487
+ const ctx = makeCtx(state, services);
480
488
 
481
489
  authenticateReducer(state, authenticate('PLAIN'), ctx);
482
490
  authenticateReducer(state, authenticate(plainPayload('alice', 'wrong')), ctx);
@@ -486,11 +494,62 @@ describe('authenticateReducer — PLAIN failure', () => {
486
494
  });
487
495
 
488
496
  // ============================================================================
489
- // AUTHENTICATE PLAIN — no AccountStore wired
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
+
547
+ // ============================================================================
548
+ // AUTHENTICATE PLAIN — no ServicesStore wired
490
549
  // ============================================================================
491
550
 
492
- describe('authenticateReducer — no AccountStore configured', () => {
493
- it('emits 904 when the PLAIN payload arrives but no AccountStore is wired', () => {
551
+ describe('authenticateReducer — no ServicesStore configured', () => {
552
+ it('emits 904 when the PLAIN payload arrives but no ServicesStore is wired', () => {
494
553
  const state = saslReadyState();
495
554
  const ctx = makeCtx(state);
496
555
 
@@ -531,17 +590,63 @@ describe('authenticateReducer — EXTERNAL with mTLS', () => {
531
590
 
532
591
  it('succeeds (900/903) when a valid cert identity is presented', () => {
533
592
  const state = saslReadyState();
534
- const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
593
+ const services = registeredServices('alice', 'secret');
594
+ services.addCertFP('alice', 'CN=alice');
535
595
  const provider = new FakeMtlsIdentityProvider('c1', 'CN=alice');
536
- const ctx = makeCtx(state, accounts, provider);
596
+ const ctx = makeCtx(state, services, provider);
537
597
 
538
598
  authenticateReducer(state, authenticate('EXTERNAL'), ctx);
539
599
  const out = authenticateReducer(state, authenticate('+'), ctx);
540
600
 
541
601
  expect(state.account).toBe('alice');
542
- expect(accounts.calls).toEqual([
543
- { mech: 'EXTERNAL', payload: { kind: 'EXTERNAL', identity: 'CN=alice' } },
602
+ expect(sendLinesOf(out.effects[0])).toEqual<RawLine[]>([
603
+ L(`:${SV} 900 alice alice!alice@example.com alice :You are now logged in as alice`),
604
+ L(`:${SV} 903 alice :SASL authentication successful`),
544
605
  ]);
606
+ });
607
+
608
+ it('forwards the resolved mTLS identity to services.verifyCertFP', () => {
609
+ const state = saslReadyState();
610
+ const services = registeredServices('alice', 'secret');
611
+ services.addCertFP('alice', 'CN=alice');
612
+ const verifyCertFP = vi.spyOn(services, 'verifyCertFP');
613
+ const provider = new FakeMtlsIdentityProvider('c1', 'CN=alice');
614
+ const ctx = makeCtx(state, services, provider);
615
+
616
+ authenticateReducer(state, authenticate('EXTERNAL'), ctx);
617
+ authenticateReducer(state, authenticate('+'), ctx);
618
+
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');
545
650
  expect(sendLinesOf(out.effects[0])).toEqual<RawLine[]>([
546
651
  L(`:${SV} 900 alice alice!alice@example.com alice :You are now logged in as alice`),
547
652
  L(`:${SV} 903 alice :SASL authentication successful`),
@@ -550,9 +655,10 @@ describe('authenticateReducer — EXTERNAL with mTLS', () => {
550
655
 
551
656
  it('clears saslMech/saslBuffer after a successful EXTERNAL', () => {
552
657
  const state = saslReadyState();
553
- const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
658
+ const services = registeredServices('alice', 'secret');
659
+ services.addCertFP('alice', 'CN=alice');
554
660
  const provider = new FakeMtlsIdentityProvider('c1', 'CN=alice');
555
- const ctx = makeCtx(state, accounts, provider);
661
+ const ctx = makeCtx(state, services, provider);
556
662
 
557
663
  authenticateReducer(state, authenticate('EXTERNAL'), ctx);
558
664
  authenticateReducer(state, authenticate('+'), ctx);
@@ -563,11 +669,11 @@ describe('authenticateReducer — EXTERNAL with mTLS', () => {
563
669
 
564
670
  it('delivers queued memos on a successful EXTERNAL login', () => {
565
671
  const state = saslReadyState();
566
- const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
567
- const provider = new FakeMtlsIdentityProvider('c1', 'CN=alice');
568
- const services = new InMemoryServicesStore();
672
+ const services = registeredServices('alice', 'secret');
673
+ services.addCertFP('alice', 'CN=alice');
569
674
  services.recordMemo({ from: 'bob', to: 'alice', body: 'via mTLS', sentAt: 1_000 });
570
- const ctx = makeCtx(state, accounts, provider, services);
675
+ const provider = new FakeMtlsIdentityProvider('c1', 'CN=alice');
676
+ const ctx = makeCtx(state, services, provider);
571
677
 
572
678
  authenticateReducer(state, authenticate('EXTERNAL'), ctx);
573
679
  const out = authenticateReducer(state, authenticate('+'), ctx);
@@ -581,9 +687,26 @@ describe('authenticateReducer — EXTERNAL with mTLS', () => {
581
687
 
582
688
  it('fails with 904 when the provider has no identity for the connection', () => {
583
689
  const state = saslReadyState();
584
- const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
690
+ const services = registeredServices('alice', 'secret');
691
+ services.addCertFP('alice', 'CN=alice');
585
692
  const provider = new FakeMtlsIdentityProvider('c1', undefined);
586
- const ctx = makeCtx(state, accounts, provider);
693
+ const ctx = makeCtx(state, services, provider);
694
+
695
+ authenticateReducer(state, authenticate('EXTERNAL'), ctx);
696
+ const out = authenticateReducer(state, authenticate('+'), ctx);
697
+
698
+ expect(sendLinesOf(out.effects[0])).toEqual<RawLine[]>([
699
+ L(`:${SV} 904 alice :SASL authentication failed`),
700
+ ]);
701
+ expect(state.account).toBeUndefined();
702
+ });
703
+
704
+ it('fails with 904 when the subject is not attached to any account', () => {
705
+ const state = saslReadyState();
706
+ const services = registeredServices('alice', 'secret');
707
+ // No cert subject attached to alice; CN=evil is unknown to the store.
708
+ const provider = new FakeMtlsIdentityProvider('c1', 'CN=evil');
709
+ const ctx = makeCtx(state, services, provider);
587
710
 
588
711
  authenticateReducer(state, authenticate('EXTERNAL'), ctx);
589
712
  const out = authenticateReducer(state, authenticate('+'), ctx);
@@ -594,11 +717,12 @@ describe('authenticateReducer — EXTERNAL with mTLS', () => {
594
717
  expect(state.account).toBeUndefined();
595
718
  });
596
719
 
597
- it('fails with 904 when the account store rejects the identity', () => {
720
+ it('does not leak account existence on an unknown subject (no state change)', () => {
598
721
  const state = saslReadyState();
599
- const accounts = new FakeAccountStore({ ok: false, reason: 'untrusted certificate' });
722
+ const services = registeredServices('alice', 'secret');
723
+ services.addCertFP('alice', 'CN=alice');
600
724
  const provider = new FakeMtlsIdentityProvider('c1', 'CN=evil');
601
- const ctx = makeCtx(state, accounts, provider);
725
+ const ctx = makeCtx(state, services, provider);
602
726
 
603
727
  authenticateReducer(state, authenticate('EXTERNAL'), ctx);
604
728
  const out = authenticateReducer(state, authenticate('+'), ctx);
@@ -606,9 +730,11 @@ describe('authenticateReducer — EXTERNAL with mTLS', () => {
606
730
  expect(sendLinesOf(out.effects[0])).toEqual<RawLine[]>([
607
731
  L(`:${SV} 904 alice :SASL authentication failed`),
608
732
  ]);
733
+ expect(state.account).toBeUndefined();
734
+ expect(state.saslMech).toBeUndefined();
609
735
  });
610
736
 
611
- it('fails with 904 when no account store is configured', () => {
737
+ it('fails with 904 when no ServicesStore is configured', () => {
612
738
  const state = saslReadyState();
613
739
  const provider = new FakeMtlsIdentityProvider('c1', 'CN=alice');
614
740
  const ctx = makeCtx(state, undefined, provider);
@@ -622,6 +748,223 @@ describe('authenticateReducer — EXTERNAL with mTLS', () => {
622
748
  });
623
749
  });
624
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
+
625
968
  // ============================================================================
626
969
  // AUTHENTICATE <unknown mechanism>
627
970
  // ============================================================================
@@ -674,8 +1017,8 @@ describe('authenticateReducer — abort / already-authenticated', () => {
674
1017
 
675
1018
  it('emits 907 when sent after a successful SASL authentication', () => {
676
1019
  const state = saslReadyState();
677
- const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
678
- const ctx = makeCtx(state, accounts);
1020
+ const services = registeredServices('alice', 'secret');
1021
+ const ctx = makeCtx(state, services);
679
1022
 
680
1023
  authenticateReducer(state, authenticate('PLAIN'), ctx);
681
1024
  authenticateReducer(state, authenticate(plainPayload('alice', 'secret')), ctx);
@@ -736,13 +1079,14 @@ describe('authenticateReducer — client abort', () => {
736
1079
  describe('authenticateReducer — empty payload (`+`)', () => {
737
1080
  it('treats `AUTHENTICATE +` as the final (empty) chunk and rejects malformed PLAIN', () => {
738
1081
  const state = saslReadyState();
739
- const accounts = new FakeAccountStore({ ok: false, reason: 'empty' });
740
- const ctx = makeCtx(state, accounts);
1082
+ const services = registeredServices('alice', 'secret');
1083
+ const verifyNick = vi.spyOn(services, 'verifyNick');
1084
+ const ctx = makeCtx(state, services);
741
1085
 
742
1086
  authenticateReducer(state, authenticate('PLAIN'), ctx);
743
1087
  const out = authenticateReducer(state, authenticate('+'), ctx);
744
1088
 
745
- expect(accounts.calls).toEqual([]);
1089
+ expect(verifyNick).not.toHaveBeenCalled();
746
1090
  expect(firstLineText(out.effects[0])).toContain(' 904 ');
747
1091
  expect(state.saslMech).toBeUndefined();
748
1092
  });
@@ -755,8 +1099,9 @@ describe('authenticateReducer — empty payload (`+`)', () => {
755
1099
  describe('authenticateReducer — chunked payload', () => {
756
1100
  it('buffers chunks of exactly 400 bytes and verifies on the short final chunk', () => {
757
1101
  const state = saslReadyState();
758
- const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
759
- const ctx = makeCtx(state, accounts);
1102
+ const services = registeredServices('alice', 'p'.repeat(320));
1103
+ const verifyNick = vi.spyOn(services, 'verifyNick');
1104
+ const ctx = makeCtx(state, services);
760
1105
 
761
1106
  const longPassword = 'p'.repeat(320);
762
1107
  const payload = plainPayload('alice', longPassword);
@@ -771,19 +1116,17 @@ describe('authenticateReducer — chunked payload', () => {
771
1116
  const buffered = authenticateReducer(state, authenticate(first), ctx);
772
1117
  expect(buffered.effects).toEqual([]);
773
1118
  expect(state.saslMech).toBe('PLAIN');
774
- expect(accounts.calls).toEqual([]);
1119
+ expect(verifyNick).not.toHaveBeenCalled();
775
1120
 
776
1121
  authenticateReducer(state, authenticate(rest), ctx);
777
- expect(accounts.calls).toEqual([
778
- { mech: 'PLAIN', payload: { kind: 'PLAIN', username: 'alice', password: longPassword } },
779
- ]);
1122
+ expect(verifyNick).toHaveBeenCalledWith('alice', longPassword);
780
1123
  expect(state.account).toBe('alice');
781
1124
  });
782
1125
 
783
1126
  it('rejects an oversized payload with 905 ERR_SASLTOOLONG', () => {
784
1127
  const state = saslReadyState();
785
- const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
786
- const ctx = makeCtx(state, accounts);
1128
+ const services = registeredServices('alice', 'secret');
1129
+ const ctx = makeCtx(state, services);
787
1130
 
788
1131
  authenticateReducer(state, authenticate('PLAIN'), ctx);
789
1132
  const out = authenticateReducer(state, authenticate(`${'A'.repeat(8193)}`), ctx);
@@ -802,37 +1145,40 @@ describe('authenticateReducer — chunked payload', () => {
802
1145
  describe('authenticateReducer — malformed PLAIN', () => {
803
1146
  it('emits 904 when the base64 payload does not decode to three NUL-separated fields', () => {
804
1147
  const state = saslReadyState();
805
- const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
806
- const ctx = makeCtx(state, accounts);
1148
+ const services = registeredServices('alice', 'secret');
1149
+ const verifyNick = vi.spyOn(services, 'verifyNick');
1150
+ const ctx = makeCtx(state, services);
807
1151
 
808
1152
  authenticateReducer(state, authenticate('PLAIN'), ctx);
809
1153
  const out = authenticateReducer(state, authenticate(encodeBase64('onlyonefield')), ctx);
810
1154
 
811
1155
  expect(firstLineText(out.effects[0])).toContain(' 904 ');
812
- expect(accounts.calls).toEqual([]);
1156
+ expect(verifyNick).not.toHaveBeenCalled();
813
1157
  });
814
1158
 
815
1159
  it('emits 904 when the payload is not valid base64', () => {
816
1160
  const state = saslReadyState();
817
- const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
818
- const ctx = makeCtx(state, accounts);
1161
+ const services = registeredServices('alice', 'secret');
1162
+ const verifyNick = vi.spyOn(services, 'verifyNick');
1163
+ const ctx = makeCtx(state, services);
819
1164
 
820
1165
  authenticateReducer(state, authenticate('PLAIN'), ctx);
821
1166
  const out = authenticateReducer(state, authenticate('!!!notbase64!!!'), ctx);
822
1167
 
823
1168
  expect(firstLineText(out.effects[0])).toContain(' 904 ');
824
- expect(accounts.calls).toEqual([]);
1169
+ expect(verifyNick).not.toHaveBeenCalled();
825
1170
  });
826
1171
 
827
1172
  it('emits 908 when the first AUTHENTICATE frame is not a recognized mechanism', () => {
828
1173
  const state = saslReadyState();
829
- const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
830
- const ctx = makeCtx(state, accounts);
1174
+ const services = registeredServices('alice', 'secret');
1175
+ const verifyNick = vi.spyOn(services, 'verifyNick');
1176
+ const ctx = makeCtx(state, services);
831
1177
 
832
1178
  const out = authenticateReducer(state, authenticate(plainPayload('alice', 'secret')), ctx);
833
1179
 
834
1180
  expect(firstLineText(out.effects[0])).toContain(' 908 ');
835
- expect(accounts.calls).toEqual([]);
1181
+ expect(verifyNick).not.toHaveBeenCalled();
836
1182
  });
837
1183
  });
838
1184
 
@@ -879,8 +1225,8 @@ describe('authenticateReducer — CAP REQ sasl integration', () => {
879
1225
  state.user = 'alice';
880
1226
  state.host = 'example.com';
881
1227
  state.registration = 'registering';
882
- const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
883
- const ctx = makeCtx(state, accounts);
1228
+ const services = registeredServices('alice', 'secret');
1229
+ const ctx = makeCtx(state, services);
884
1230
 
885
1231
  capReducer(state, { command: 'CAP', params: ['REQ', 'sasl'], tags: {} }, ctx);
886
1232
  expect(state.caps.has('sasl')).toBe(true);
@@ -905,15 +1251,15 @@ describe('authenticateReducer — CAP REQ sasl integration', () => {
905
1251
  });
906
1252
 
907
1253
  // ============================================================================
908
- // SASL PLAIN — NickServ-registered accounts (ServicesStore fallback)
1254
+ // SASL PLAIN — NickServ-registered accounts (single credential home)
909
1255
  // ============================================================================
910
1256
 
911
1257
  describe('authenticateReducer — NickServ-registered nick via ServicesStore', () => {
912
- it('succeeds with credentials registered via NickServ when no AccountStore is bound', () => {
1258
+ it('succeeds with credentials registered via NickServ (no second registration)', () => {
913
1259
  const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
914
1260
  services.registerNick('alice', 'hunter2', 'alice@example.com');
915
1261
  const state = saslReadyState();
916
- const ctx = makeCtx(state, undefined, undefined, services);
1262
+ const ctx = makeCtx(state, services);
917
1263
 
918
1264
  authenticateReducer(state, authenticate('PLAIN'), ctx);
919
1265
  const out = authenticateReducer(state, authenticate(plainPayload('alice', 'hunter2')), ctx);
@@ -925,51 +1271,275 @@ describe('authenticateReducer — NickServ-registered nick via ServicesStore', (
925
1271
  );
926
1272
  });
927
1273
 
928
- it('succeeds with NickServ credentials even when a static AccountStore is also bound', () => {
1274
+ it('fails when neither the nick nor a matching credential verifies', () => {
929
1275
  const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
930
- services.registerNick('bob', 'bobpass', 'bob@example.com');
931
- // Static AccountStore does not know about bob; the services store does.
932
- const accounts = new FakeAccountStore({ ok: false, reason: 'invalid credentials' });
1276
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
933
1277
  const state = saslReadyState();
934
- state.nick = 'bob';
935
- state.user = 'bob';
936
- state.host = 'example.com';
937
- const ctx = makeCtx(state, accounts, undefined, services);
1278
+ const ctx = makeCtx(state, services);
938
1279
 
939
1280
  authenticateReducer(state, authenticate('PLAIN'), ctx);
940
- const out = authenticateReducer(state, authenticate(plainPayload('bob', 'bobpass')), ctx);
1281
+ const out = authenticateReducer(state, authenticate(plainPayload('alice', 'wrong')), ctx);
941
1282
 
942
- expect(state.account).toBe('bob');
943
- expect(sendLinesOf(out.effects[0]).map((l) => l.text)).toContain(
944
- `:${SV} 903 bob :SASL authentication successful`,
945
- );
1283
+ expect(state.account).toBeUndefined();
1284
+ expect(firstLineText(out.effects[0])).toBe(`:${SV} 904 alice :SASL authentication failed`);
946
1285
  });
1286
+ });
947
1287
 
948
- it('prefers the static AccountStore over the services store on a match', () => {
949
- const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
950
- services.registerNick('alice', 'servicespw', 'alice@example.com');
951
- const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
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 } };
952
1413
  const state = saslReadyState();
953
- const ctx = makeCtx(state, accounts, undefined, services);
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]);
954
1436
 
955
1437
  authenticateReducer(state, authenticate('PLAIN'), ctx);
956
- authenticateReducer(state, authenticate(plainPayload('alice', 'staticpw')), ctx);
1438
+ authenticateReducer(state, authenticate(plainPayload('alice', 'secret')), ctx);
957
1439
 
958
- // The static store vouched for the account; it is used.
959
- expect(accounts.calls).toHaveLength(1);
960
1440
  expect(state.account).toBe('alice');
1441
+ expect(state.saslFailures).toBeUndefined();
961
1442
  });
962
1443
 
963
- it('fails when neither the AccountStore nor the ServicesStore verifies the credential', () => {
964
- const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
965
- services.registerNick('alice', 'hunter2', 'alice@example.com');
1444
+ it('does not count AUTHENTICATE * (abort) as a failure or reset the counter', () => {
966
1445
  const state = saslReadyState();
967
- const ctx = makeCtx(state, undefined, undefined, services);
1446
+ const ctx = lockoutCtx(state, new FakeClock(1_000));
1447
+
1448
+ failedAttempt(state, ctx); // count: 1
968
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.
969
1452
  authenticateReducer(state, authenticate('PLAIN'), ctx);
970
- const out = authenticateReducer(state, authenticate(plainPayload('alice', 'wrong')), ctx);
1453
+ const abortOut = authenticateReducer(state, authenticate('*'), ctx);
1454
+ expect(firstLineText(abortOut.effects[0])).toContain(' 906 ');
971
1455
 
972
- expect(state.account).toBeUndefined();
973
- expect(firstLineText(out.effects[0])).toBe(`:${SV} 904 alice :SASL authentication failed`);
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
+ ]);
974
1544
  });
975
1545
  });