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,11 +1,26 @@
1
- import { describe, expect, it } from 'vitest';
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+ import {
3
+ OPER_HASHED_USER,
4
+ OPER_HASHED_VALID_PASSWORD,
5
+ OPER_HASHED_WRONG_PASSWORD,
6
+ OPER_PRECOMPUTED_DUMMY_HASHED,
7
+ OPER_PRECOMPUTED_HASHED_CRED,
8
+ resetOperLegacyPlaintextWarning,
9
+ resetOperTimingSpy,
10
+ setOperLegacyPlaintextWarner,
11
+ setOperVerifyHashedPassword,
12
+ } from '../../src/commands/oper';
2
13
  import { matchOperCred, operReducer } from '../../src/commands/oper';
14
+ import { type HashedAccountCredential, hashAccountCredential } from '../../src/credential-hashing';
3
15
  import { Effect } from '../../src/effects';
4
16
  import type { Effect as EffectType, RawLine } from '../../src/effects';
17
+ import { OperFailureStats } from '../../src/oper-lockout';
5
18
  import { EmptyMotdProvider, FakeClock, SequentialIdFactory } from '../../src/ports';
6
19
  import { type ConnectionState, createConnection } from '../../src/state/connection';
7
20
  import { type Ctx, type ServerConfig, buildCtx } from '../../src/types';
8
21
 
22
+ type VerifyHashedPasswordFn = (password: string, entry: HashedAccountCredential) => boolean;
23
+
9
24
  const baseServerConfig: ServerConfig = {
10
25
  serverName: 'irc.example.com',
11
26
  networkName: 'ExampleNet',
@@ -36,6 +51,27 @@ function makeCtx(state: ConnectionState, config: ServerConfig = serverConfigWith
36
51
  });
37
52
  }
38
53
 
54
+ /**
55
+ * Builds a ctx with a per-IP failed-OPER tracker bound, sharing `clock`
56
+ * between the tracker and the ctx so window arithmetic is deterministic
57
+ * under FakeClock advances.
58
+ */
59
+ function makeTrackedCtx(
60
+ state: ConnectionState,
61
+ tracker: OperFailureStats,
62
+ clock: FakeClock,
63
+ config: ServerConfig = serverConfigWithCreds,
64
+ ): Ctx {
65
+ return buildCtx({
66
+ serverConfig: config,
67
+ clock,
68
+ ids: new SequentialIdFactory(),
69
+ motd: EmptyMotdProvider,
70
+ connection: state,
71
+ operFailures: tracker,
72
+ });
73
+ }
74
+
39
75
  function makeState(): ConnectionState {
40
76
  const s = createConnection({ id: 'c1', connectedSince: 0 });
41
77
  s.nick = 'alice';
@@ -81,6 +117,24 @@ describe('operReducer — successful authentication', () => {
81
117
  expect(modeLines).toContain(':alice!alice@example.com MODE alice +o');
82
118
  });
83
119
 
120
+ it('falls back to ? / * in the MODE echo when the connection has no nick or hostmask', () => {
121
+ // A connection with no nick/user/host: hostmaskOf returns undefined
122
+ // and state.nick is undefined, so modeEchoLine falls back to '?' / '*'.
123
+ // Registered so the registration gate passes (the formatter fallback,
124
+ // not the gate, is under test here).
125
+ const state = createConnection({ id: 'c1', connectedSince: 0 });
126
+ state.registration = 'registered';
127
+ const ctx = makeCtx(state);
128
+
129
+ const out = operReducer(state, oper('alice', 'secret'), ctx);
130
+
131
+ expect(out.state.userModes.oper).toBe(true);
132
+ const modeLines = out.effects
133
+ .filter((e): e is Extract<EffectType, { tag: 'Send' }> => e.tag === 'Send')
134
+ .flatMap((e) => e.lines.map((l) => l.text));
135
+ expect(modeLines).toContain(':? MODE * +o');
136
+ });
137
+
84
138
  it('matches the second configured credential pair', () => {
85
139
  const state = makeState();
86
140
  const ctx = makeCtx(state);
@@ -228,7 +282,10 @@ describe('operReducer — missing parameters', () => {
228
282
  });
229
283
 
230
284
  it('uses "*" as the nick in 461 when the connection has no nick yet', () => {
285
+ // Registered (a synthetic nick-less connection) so the test exercises
286
+ // the 461 nick fallback rather than the registration gate.
231
287
  const state = createConnection({ id: 'c1', connectedSince: 0 });
288
+ state.registration = 'registered';
232
289
  const ctx = makeCtx(state);
233
290
 
234
291
  const out = operReducer(state, oper(), ctx);
@@ -239,6 +296,69 @@ describe('operReducer — missing parameters', () => {
239
296
  });
240
297
  });
241
298
 
299
+ describe('operReducer — registration gate', () => {
300
+ it('rejects a pre-registration connection with 451 ERR_NOTREGISTERED without granting oper', () => {
301
+ const state = makeState();
302
+ state.registration = 'pre-registration';
303
+ const ctx = makeCtx(state);
304
+
305
+ const out = operReducer(state, oper('alice', 'secret'), ctx);
306
+
307
+ expect(out.state.userModes.oper).toBe(false);
308
+ expect(out.effects).toEqual<EffectType[]>([
309
+ Effect.send('c1', [L(':irc.example.com 451 alice :You have not registered')]),
310
+ ]);
311
+ });
312
+
313
+ it('stamps lastSeen even when the registration gate rejects', () => {
314
+ const state = makeState();
315
+ state.registration = 'pre-registration';
316
+ expect(state.lastSeen).toBe(0);
317
+ const ctx = makeCtx(state);
318
+
319
+ operReducer(state, oper('alice', 'secret'), ctx);
320
+
321
+ expect(state.lastSeen).toBe(5_000);
322
+ });
323
+
324
+ it('checks the registration gate before parameter validation (451 wins over 461)', () => {
325
+ const state = makeState();
326
+ state.registration = 'pre-registration';
327
+ const ctx = makeCtx(state);
328
+
329
+ const out = operReducer(state, oper(), ctx);
330
+
331
+ expect(out.effects).toEqual<EffectType[]>([
332
+ Effect.send('c1', [L(':irc.example.com 451 alice :You have not registered')]),
333
+ ]);
334
+ });
335
+
336
+ it('records no lockout failure for a pre-registration rejection', () => {
337
+ const clock = new FakeClock(1_000);
338
+ const tracker = new OperFailureStats(clock);
339
+ const state = makeState();
340
+ state.registration = 'pre-registration';
341
+ const ctx = makeTrackedCtx(state, tracker, clock);
342
+
343
+ operReducer(state, oper('alice', 'secret'), ctx);
344
+
345
+ expect(tracker.recentFailures('example.com', 60_000)).toBe(0);
346
+ });
347
+
348
+ it('grants oper post-registration (gate passes through to the credential path)', () => {
349
+ const state = makeState();
350
+ const ctx = makeCtx(state);
351
+
352
+ const out = operReducer(state, oper('alice', 'secret'), ctx);
353
+
354
+ expect(out.state.userModes.oper).toBe(true);
355
+ expect(out.effects).toEqual<EffectType[]>([
356
+ Effect.send('c1', [L(':irc.example.com 381 alice :You are now an IRC operator')]),
357
+ Effect.send('c1', [L(':alice!alice@example.com MODE alice +o')]),
358
+ ]);
359
+ });
360
+ });
361
+
242
362
  describe('matchOperCred', () => {
243
363
  it('returns true when a credential pair matches exactly', () => {
244
364
  const creds = [
@@ -268,3 +388,456 @@ describe('matchOperCred', () => {
268
388
  expect(matchOperCred(creds, 'alice', 'Secret')).toBe(false);
269
389
  });
270
390
  });
391
+
392
+ // ============================================================================
393
+ // Hashed credentials (scrypt) — the new at-rest shape.
394
+ // ============================================================================
395
+
396
+ describe('matchOperCred — hashed credentials', () => {
397
+ afterEach(() => {
398
+ resetOperLegacyPlaintextWarning();
399
+ resetOperTimingSpy();
400
+ });
401
+
402
+ it('verifies a correct password against the precomputed scrypt-hashed cred', () => {
403
+ expect(
404
+ matchOperCred([OPER_PRECOMPUTED_HASHED_CRED], OPER_HASHED_USER, OPER_HASHED_VALID_PASSWORD),
405
+ ).toBe(true);
406
+ });
407
+
408
+ it('rejects a wrong password against a scrypt-hashed cred', () => {
409
+ expect(
410
+ matchOperCred([OPER_PRECOMPUTED_HASHED_CRED], OPER_HASHED_USER, OPER_HASHED_WRONG_PASSWORD),
411
+ ).toBe(false);
412
+ });
413
+
414
+ it('rejects an unknown user against a scrypt-hashed cred', () => {
415
+ expect(
416
+ matchOperCred(
417
+ [OPER_PRECOMPUTED_HASHED_CRED],
418
+ 'definitely-not-a-user',
419
+ OPER_HASHED_VALID_PASSWORD,
420
+ ),
421
+ ).toBe(false);
422
+ });
423
+
424
+ it('round-trips a freshly-hashed credential via hashAccountCredential', () => {
425
+ const entry = hashAccountCredential('admin', 's3cret-password');
426
+ const cred = { user: entry.account, salt: entry.salt, hash: entry.hash };
427
+ expect(matchOperCred([cred], 'admin', 's3cret-password')).toBe(true);
428
+ expect(matchOperCred([cred], 'admin', 'wrong')).toBe(false);
429
+ });
430
+ });
431
+
432
+ // ============================================================================
433
+ // Timing equalisation — matchOperCred runs a dummy scrypt verify on the
434
+ // user-unknown path so a timing attacker cannot distinguish "user exists"
435
+ // from "user unknown".
436
+ // ============================================================================
437
+
438
+ describe('matchOperCred — timing-equalising dummy verify on unknown user', () => {
439
+ afterEach(() => {
440
+ resetOperLegacyPlaintextWarning();
441
+ resetOperTimingSpy();
442
+ });
443
+
444
+ it('invokes exactly one verify on a known user with the wrong password', () => {
445
+ const spy = vi.fn<VerifyHashedPasswordFn>(() => false);
446
+ setOperVerifyHashedPassword(spy);
447
+ try {
448
+ matchOperCred([OPER_PRECOMPUTED_HASHED_CRED], OPER_HASHED_USER, OPER_HASHED_WRONG_PASSWORD);
449
+ expect(spy).toHaveBeenCalledTimes(1);
450
+ } finally {
451
+ setOperVerifyHashedPassword(null);
452
+ }
453
+ });
454
+
455
+ it('invokes exactly one (dummy) verify on an unknown user — no O(1) short-circuit', () => {
456
+ const spy = vi.fn<VerifyHashedPasswordFn>(() => false);
457
+ setOperVerifyHashedPassword(spy);
458
+ try {
459
+ matchOperCred(
460
+ [OPER_PRECOMPUTED_HASHED_CRED],
461
+ 'definitely-not-a-user',
462
+ OPER_HASHED_VALID_PASSWORD,
463
+ );
464
+ // Critical: a single dummy verify runs even though no user matched,
465
+ // equalising wall-clock cost with the wrong-password path.
466
+ expect(spy).toHaveBeenCalledTimes(1);
467
+ // The dummy verify is fed a fixed precomputed entry (never the
468
+ // attacker's payload as the "user") so the timing is independent
469
+ // of attacker input.
470
+ expect(spy.mock.calls[0]?.[1]).toBe(OPER_PRECOMPUTED_DUMMY_HASHED);
471
+ } finally {
472
+ setOperVerifyHashedPassword(null);
473
+ }
474
+ });
475
+
476
+ it('does not invoke a dummy verify when the user matches and password verifies', () => {
477
+ const spy = vi.fn<VerifyHashedPasswordFn>(() => true);
478
+ setOperVerifyHashedPassword(spy);
479
+ try {
480
+ matchOperCred([OPER_PRECOMPUTED_HASHED_CRED], OPER_HASHED_USER, OPER_HASHED_VALID_PASSWORD);
481
+ expect(spy).toHaveBeenCalledTimes(1);
482
+ } finally {
483
+ setOperVerifyHashedPassword(null);
484
+ }
485
+ });
486
+ });
487
+
488
+ // ============================================================================
489
+ // Legacy plaintext fallback (deprecation cycle) — keeps verifying but warns
490
+ // once per boot.
491
+ // ============================================================================
492
+
493
+ describe('matchOperCred — legacy plaintext fallback (deprecation cycle)', () => {
494
+ afterEach(() => {
495
+ resetOperLegacyPlaintextWarning();
496
+ resetOperTimingSpy();
497
+ });
498
+
499
+ it('still verifies a legacy {user,password} cred', () => {
500
+ const warns: string[] = [];
501
+ setOperLegacyPlaintextWarner((msg) => {
502
+ warns.push(msg);
503
+ });
504
+ try {
505
+ expect(matchOperCred([{ user: 'alice', password: 'secret' }], 'alice', 'secret')).toBe(true);
506
+ expect(matchOperCred([{ user: 'alice', password: 'secret' }], 'alice', 'wrong')).toBe(false);
507
+ } finally {
508
+ setOperLegacyPlaintextWarner(null);
509
+ }
510
+ });
511
+
512
+ it('warns exactly once per boot when a legacy plaintext cred is encountered', () => {
513
+ const warns: string[] = [];
514
+ setOperLegacyPlaintextWarner((msg) => {
515
+ warns.push(msg);
516
+ });
517
+ try {
518
+ matchOperCred([{ user: 'alice', password: 'secret' }], 'alice', 'secret');
519
+ matchOperCred([{ user: 'bob', password: 'pw' }], 'bob', 'pw');
520
+ matchOperCred([{ user: 'carol', password: 'pw' }], 'carol', 'pw');
521
+ expect(warns).toHaveLength(1);
522
+ expect(warns[0]).toMatch(/deprecat/iu);
523
+ } finally {
524
+ setOperLegacyPlaintextWarner(null);
525
+ }
526
+ });
527
+
528
+ it('does NOT warn when only hashed creds are configured', () => {
529
+ const warns: string[] = [];
530
+ setOperLegacyPlaintextWarner((msg) => {
531
+ warns.push(msg);
532
+ });
533
+ try {
534
+ matchOperCred([OPER_PRECOMPUTED_HASHED_CRED], OPER_HASHED_USER, OPER_HASHED_VALID_PASSWORD);
535
+ expect(warns).toHaveLength(0);
536
+ } finally {
537
+ setOperLegacyPlaintextWarner(null);
538
+ }
539
+ });
540
+
541
+ it('restores the default console.warn sink when setOperLegacyPlaintextWarner(null) is called', () => {
542
+ // Spy on console.warn to confirm the default sink routes there after
543
+ // a `null` restore. The previous test installed a custom sink; this
544
+ // call MUST revert to console.warn so production emissions land in
545
+ // the platform's observability pipeline.
546
+ const calls: string[] = [];
547
+ const original = console.warn;
548
+ const warnSpy = (msg: string) => {
549
+ calls.push(msg);
550
+ };
551
+ console.warn = warnSpy;
552
+ try {
553
+ setOperLegacyPlaintextWarner(null);
554
+ // Force the warn flag to fire on the next call.
555
+ resetOperLegacyPlaintextWarning();
556
+ matchOperCred([{ user: 'alice', password: 'secret' }], 'alice', 'secret');
557
+ expect(calls).toHaveLength(1);
558
+ expect(calls[0]).toMatch(/deprecat/iu);
559
+ } finally {
560
+ console.warn = original;
561
+ resetOperLegacyPlaintextWarning();
562
+ setOperLegacyPlaintextWarner(null);
563
+ }
564
+ });
565
+ });
566
+
567
+ // ============================================================================
568
+ // Per-IP failed-OPER lockout — threshold crossing, success-resets-counter,
569
+ // window expiry, and the oper-notice emission.
570
+ // ============================================================================
571
+
572
+ describe('operReducer — per-IP failed-OPER lockout', () => {
573
+ afterEach(() => {
574
+ resetOperTimingSpy();
575
+ resetOperLegacyPlaintextWarning();
576
+ });
577
+
578
+ it('rejects the next OPER with 491 after 3 consecutive credential failures from one IP', () => {
579
+ const clock = new FakeClock(5_000);
580
+ const tracker = new OperFailureStats(clock);
581
+ const state = makeState();
582
+
583
+ for (let i = 0; i < 3; i++) {
584
+ const out = operReducer(
585
+ state,
586
+ oper('alice', `wrong-${i}`),
587
+ makeTrackedCtx(state, tracker, clock),
588
+ );
589
+ expect(out.state.userModes.oper).toBe(false);
590
+ expect(out.effects).toEqual<EffectType[]>([
591
+ Effect.send('c1', [L(':irc.example.com 464 alice :Password Incorrect')]),
592
+ ]);
593
+ }
594
+
595
+ // The 4th attempt is rejected without consulting the credential store —
596
+ // even with the CORRECT password the lockout wins.
597
+ const locked = operReducer(
598
+ state,
599
+ oper('alice', 'secret'),
600
+ makeTrackedCtx(state, tracker, clock),
601
+ );
602
+ expect(locked.state.userModes.oper).toBe(false);
603
+ const texts = locked.effects.flatMap((e) =>
604
+ e.tag === 'Send' ? e.lines.map((l) => l.text) : [],
605
+ );
606
+ expect(texts).toContain(':irc.example.com 491 alice :No O-lines for your host');
607
+ });
608
+
609
+ it('does not invoke the credential verifier once the IP is locked out', () => {
610
+ const clock = new FakeClock(5_000);
611
+ const tracker = new OperFailureStats(clock);
612
+ const config: ServerConfig = {
613
+ ...baseServerConfig,
614
+ operCreds: [OPER_PRECOMPUTED_HASHED_CRED],
615
+ };
616
+ const state = makeState();
617
+
618
+ for (let i = 0; i < 3; i++) {
619
+ operReducer(
620
+ state,
621
+ oper(OPER_HASHED_USER, OPER_HASHED_WRONG_PASSWORD),
622
+ makeTrackedCtx(state, tracker, clock, config),
623
+ );
624
+ }
625
+
626
+ const spy = vi.fn<VerifyHashedPasswordFn>(() => true);
627
+ setOperVerifyHashedPassword(spy);
628
+ try {
629
+ const locked = operReducer(
630
+ state,
631
+ oper(OPER_HASHED_USER, OPER_HASHED_VALID_PASSWORD),
632
+ makeTrackedCtx(state, tracker, clock, config),
633
+ );
634
+ // Critical: the lockout short-circuits BEFORE matchOperCred, so the
635
+ // (stubbed) scrypt verifier is never invoked for the locked attempt.
636
+ expect(spy).toHaveBeenCalledTimes(0);
637
+ expect(locked.state.userModes.oper).toBe(false);
638
+ } finally {
639
+ setOperVerifyHashedPassword(null);
640
+ }
641
+ });
642
+
643
+ it('emits a BroadcastOperNotice effect to all opers when the lockout trips', () => {
644
+ const clock = new FakeClock(5_000);
645
+ const tracker = new OperFailureStats(clock);
646
+ const state = makeState();
647
+
648
+ for (let i = 0; i < 3; i++) {
649
+ operReducer(state, oper('alice', 'wrong'), makeTrackedCtx(state, tracker, clock));
650
+ }
651
+
652
+ const locked = operReducer(
653
+ state,
654
+ oper('alice', 'secret'),
655
+ makeTrackedCtx(state, tracker, clock),
656
+ );
657
+ expect(locked.effects).toEqual<EffectType[]>([
658
+ Effect.send('c1', [L(':irc.example.com 491 alice :No O-lines for your host')]),
659
+ Effect.broadcastOperNotice([
660
+ L(':irc.example.com NOTICE * :OPER lockout triggered for example.com'),
661
+ ]),
662
+ ]);
663
+ });
664
+
665
+ it('resets the per-IP failure counter on successful OPER', () => {
666
+ const clock = new FakeClock(5_000);
667
+ const tracker = new OperFailureStats(clock);
668
+ const state = makeState();
669
+
670
+ // Two failures, then a success that resets the counter.
671
+ operReducer(state, oper('alice', 'wrong'), makeTrackedCtx(state, tracker, clock));
672
+ operReducer(state, oper('alice', 'wrong'), makeTrackedCtx(state, tracker, clock));
673
+ const ok = operReducer(state, oper('alice', 'secret'), makeTrackedCtx(state, tracker, clock));
674
+ expect(ok.state.userModes.oper).toBe(true);
675
+
676
+ // Two more failures after the reset: only 2 recent failures remain, so
677
+ // the next correct attempt is NOT locked out.
678
+ state.userModes.oper = false;
679
+ operReducer(state, oper('alice', 'wrong'), makeTrackedCtx(state, tracker, clock));
680
+ operReducer(state, oper('alice', 'wrong'), makeTrackedCtx(state, tracker, clock));
681
+ const after = operReducer(
682
+ state,
683
+ oper('alice', 'secret'),
684
+ makeTrackedCtx(state, tracker, clock),
685
+ );
686
+ expect(after.state.userModes.oper).toBe(true);
687
+ });
688
+
689
+ it('forgets failures once the configured window has elapsed', () => {
690
+ const clock = new FakeClock(5_000);
691
+ const tracker = new OperFailureStats(clock);
692
+ const state = makeState();
693
+
694
+ for (let i = 0; i < 3; i++) {
695
+ operReducer(state, oper('alice', 'wrong'), makeTrackedCtx(state, tracker, clock));
696
+ }
697
+
698
+ // Default window is 300s; advancing past it prunes every recorded
699
+ // failure, so a subsequent correct attempt succeeds.
700
+ clock.advance(300_001);
701
+ const after = operReducer(
702
+ state,
703
+ oper('alice', 'secret'),
704
+ makeTrackedCtx(state, tracker, clock),
705
+ );
706
+ expect(after.state.userModes.oper).toBe(true);
707
+ expect(tracker.recentFailures('example.com', 300_000)).toBe(0);
708
+ });
709
+
710
+ it('honours oper.maxFailures and oper.failureWindowSeconds from ServerConfig', () => {
711
+ const clock = new FakeClock(5_000);
712
+ const tracker = new OperFailureStats(clock);
713
+ const config: ServerConfig = {
714
+ ...serverConfigWithCreds,
715
+ oper: { maxFailures: 1, failureWindowSeconds: 60 },
716
+ };
717
+ const state = makeState();
718
+
719
+ // maxFailures: 1 — the SECOND attempt from this IP is already locked.
720
+ operReducer(state, oper('alice', 'wrong'), makeTrackedCtx(state, tracker, clock, config));
721
+ const locked = operReducer(
722
+ state,
723
+ oper('alice', 'secret'),
724
+ makeTrackedCtx(state, tracker, clock, config),
725
+ );
726
+ expect(locked.state.userModes.oper).toBe(false);
727
+ expect(locked.effects).toEqual<EffectType[]>([
728
+ Effect.send('c1', [L(':irc.example.com 491 alice :No O-lines for your host')]),
729
+ Effect.broadcastOperNotice([
730
+ L(':irc.example.com NOTICE * :OPER lockout triggered for example.com'),
731
+ ]),
732
+ ]);
733
+
734
+ // failureWindowSeconds: 60 — after 61s the single failure expires.
735
+ clock.advance(61_000);
736
+ const after = operReducer(
737
+ state,
738
+ oper('alice', 'wrong'),
739
+ makeTrackedCtx(state, tracker, clock, config),
740
+ );
741
+ expect(after.effects).toEqual<EffectType[]>([
742
+ Effect.send('c1', [L(':irc.example.com 464 alice :Password Incorrect')]),
743
+ ]);
744
+ });
745
+
746
+ it('falls back to the default thresholds when the oper config omits the fields', () => {
747
+ const clock = new FakeClock(5_000);
748
+ const tracker = new OperFailureStats(clock);
749
+ const config: ServerConfig = { ...serverConfigWithCreds, oper: {} };
750
+ const state = makeState();
751
+
752
+ // Defaults: 3 failures. Two failures are not yet enough to lock.
753
+ operReducer(state, oper('alice', 'wrong'), makeTrackedCtx(state, tracker, clock, config));
754
+ operReducer(state, oper('alice', 'wrong'), makeTrackedCtx(state, tracker, clock, config));
755
+ const notLocked = operReducer(
756
+ state,
757
+ oper('alice', 'secret'),
758
+ makeTrackedCtx(state, tracker, clock, config),
759
+ );
760
+ expect(notLocked.state.userModes.oper).toBe(true);
761
+
762
+ // Three failures (post-success reset makes it a fresh run) then lock.
763
+ state.userModes.oper = false;
764
+ for (let i = 0; i < 3; i++) {
765
+ operReducer(state, oper('alice', 'wrong'), makeTrackedCtx(state, tracker, clock, config));
766
+ }
767
+ const locked = operReducer(
768
+ state,
769
+ oper('alice', 'secret'),
770
+ makeTrackedCtx(state, tracker, clock, config),
771
+ );
772
+ expect(locked.state.userModes.oper).toBe(false);
773
+ });
774
+
775
+ it('does not lock out when no failure tracker is bound (ctx.operFailures undefined)', () => {
776
+ const state = makeState();
777
+ for (let i = 0; i < 5; i++) {
778
+ const out = operReducer(state, oper('alice', 'wrong'), makeCtx(state));
779
+ expect(out.effects).toEqual<EffectType[]>([
780
+ Effect.send('c1', [L(':irc.example.com 464 alice :Password Incorrect')]),
781
+ ]);
782
+ }
783
+ });
784
+
785
+ it('skips lockout accounting when the connection has no host set', () => {
786
+ const clock = new FakeClock(5_000);
787
+ const tracker = new OperFailureStats(clock);
788
+ const state = createConnection({ id: 'c1', connectedSince: 0 });
789
+ state.nick = 'alice';
790
+ state.registration = 'registered';
791
+
792
+ // No host → no lockout key → failures are never recorded, so the
793
+ // credential store keeps being consulted.
794
+ for (let i = 0; i < 5; i++) {
795
+ const out = operReducer(state, oper('alice', 'wrong'), makeTrackedCtx(state, tracker, clock));
796
+ expect(out.effects).toEqual<EffectType[]>([
797
+ Effect.send('c1', [L(':irc.example.com 464 alice :Password Incorrect')]),
798
+ ]);
799
+ }
800
+ });
801
+
802
+ it('still re-confirms 381 for an already-oper connection from a locked-out IP', () => {
803
+ const clock = new FakeClock(5_000);
804
+ const tracker = new OperFailureStats(clock);
805
+ const state = makeState();
806
+
807
+ for (let i = 0; i < 3; i++) {
808
+ operReducer(state, oper('alice', 'wrong'), makeTrackedCtx(state, tracker, clock));
809
+ }
810
+
811
+ // A different connection from the same host that is ALREADY oper: the
812
+ // idempotent re-confirm path runs before the lockout gate.
813
+ const operState = makeState();
814
+ operState.userModes.oper = true;
815
+ const out = operReducer(
816
+ operState,
817
+ oper('alice', 'secret'),
818
+ makeTrackedCtx(operState, tracker, clock),
819
+ );
820
+ expect(out.effects).toEqual<EffectType[]>([
821
+ Effect.send('c1', [L(':irc.example.com 381 alice :You are now an IRC operator')]),
822
+ ]);
823
+ });
824
+
825
+ it('keys failures per host so distinct hosts do not lock each other out', () => {
826
+ const clock = new FakeClock(5_000);
827
+ const tracker = new OperFailureStats(clock);
828
+ const attacker = makeState();
829
+ const victim = makeState();
830
+ victim.host = '10.0.0.2';
831
+
832
+ for (let i = 0; i < 3; i++) {
833
+ operReducer(attacker, oper('alice', 'wrong'), makeTrackedCtx(attacker, tracker, clock));
834
+ }
835
+
836
+ const out = operReducer(
837
+ victim,
838
+ oper('alice', 'secret'),
839
+ makeTrackedCtx(victim, tracker, clock),
840
+ );
841
+ expect(out.state.userModes.oper).toBe(true);
842
+ });
843
+ });
@@ -97,6 +97,22 @@ describe('isChannelTarget', () => {
97
97
  it('returns false for a name with a space', () => {
98
98
  expect(isChannelTarget('foo bar')).toBe(false);
99
99
  });
100
+
101
+ it('returns false for a channel name containing NUL', () => {
102
+ expect(isChannelTarget('#foo\0bar')).toBe(false);
103
+ });
104
+
105
+ it('returns false for a channel name containing a control character', () => {
106
+ expect(isChannelTarget('#foo\x01bar')).toBe(false);
107
+ });
108
+
109
+ it('returns true for a channel name containing !', () => {
110
+ expect(isChannelTarget('#foo!bar')).toBe(true);
111
+ });
112
+
113
+ it('returns true for a channel name containing @', () => {
114
+ expect(isChannelTarget('#foo@bar')).toBe(true);
115
+ });
100
116
  });
101
117
 
102
118
  // ============================================================================