serverless-ircd 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (192) hide show
  1. package/.github/workflows/ci.yml +28 -0
  2. package/.github/workflows/deploy-cf-tcp.yml +26 -2
  3. package/.github/workflows/deploy-cf.yml +26 -0
  4. package/CHANGELOG.md +289 -0
  5. package/README.md +153 -20
  6. package/apps/aws-stack/bin/aws.ts +36 -0
  7. package/apps/aws-stack/package.json +1 -1
  8. package/apps/aws-stack/src/aws-stack.ts +221 -15
  9. package/apps/aws-stack/tests/stack.test.ts +450 -16
  10. package/apps/cf-tcp-container/Dockerfile +37 -5
  11. package/apps/cf-tcp-container/package.json +7 -2
  12. package/apps/cf-tcp-container/src/config-loader.ts +113 -2
  13. package/apps/cf-tcp-container/src/container-server.ts +256 -79
  14. package/apps/cf-tcp-container/src/main.ts +22 -7
  15. package/apps/cf-tcp-container/src/proxy-protocol.ts +112 -0
  16. package/apps/cf-tcp-container/terraform/spectrum.tf +40 -11
  17. package/apps/cf-tcp-container/tests/config-loader.test.ts +170 -0
  18. package/apps/cf-tcp-container/tests/container-server-tls.test.ts +382 -0
  19. package/apps/cf-tcp-container/tests/container-server.test.ts +358 -31
  20. package/apps/cf-tcp-container/tests/dockerfile.test.ts +110 -0
  21. package/apps/cf-tcp-container/tests/proxy-protocol.test.ts +187 -0
  22. package/apps/cf-tcp-container/tests/spectrum-terraform.test.ts +135 -0
  23. package/apps/cf-tcp-container/tests/tls-e2e.test.ts +5 -1
  24. package/apps/cf-tcp-container/wrangler.toml +17 -4
  25. package/apps/cf-worker/package.json +2 -2
  26. package/apps/cf-worker/src/worker.ts +77 -5
  27. package/apps/cf-worker/tests/raw-modules.d.ts +11 -0
  28. package/apps/cf-worker/tests/smoke.test.ts +4 -0
  29. package/apps/cf-worker/tests/wrangler-config.test.ts +47 -0
  30. package/apps/cf-worker/tests/ws-admission.test.ts +112 -0
  31. package/apps/cf-worker/tests/ws-rate-limit.test.ts +133 -0
  32. package/apps/cf-worker/wrangler.test.toml +15 -1
  33. package/apps/cf-worker/wrangler.toml +86 -9
  34. package/apps/local-cli/package.json +1 -1
  35. package/apps/local-cli/src/config-loader.ts +14 -2
  36. package/apps/local-cli/src/line-scanner.ts +26 -0
  37. package/apps/local-cli/src/server.ts +23 -2
  38. package/apps/local-cli/tests/line-scanner.test.ts +64 -0
  39. package/apps/local-cli/tests/tcp.test.ts +29 -0
  40. package/apps/web/package.json +1 -1
  41. package/docs/AWS-Deployment.md +123 -22
  42. package/docs/AWS-TCP-Deployment.md +37 -2
  43. package/docs/Chat-History.md +55 -0
  44. package/docs/Cloudflare-Deployment-Guide.md +9 -2
  45. package/docs/Cloudflare-TCP-Deployment.md +135 -52
  46. package/docs/SASL-EXTERNAL.md +175 -0
  47. package/package.json +3 -3
  48. package/packages/aws-adapter/package.json +1 -1
  49. package/packages/aws-adapter/src/admission.ts +28 -13
  50. package/packages/aws-adapter/src/aws-runtime.ts +30 -3
  51. package/packages/aws-adapter/src/cdk-table-defs.ts +34 -6
  52. package/packages/aws-adapter/src/config-loader.ts +134 -6
  53. package/packages/aws-adapter/src/dynamo-services-store.ts +12 -0
  54. package/packages/aws-adapter/src/handlers/connect.ts +47 -1
  55. package/packages/aws-adapter/src/handlers/default.ts +95 -6
  56. package/packages/aws-adapter/src/handlers/index.ts +31 -2
  57. package/packages/aws-adapter/src/handlers/nlb-stream.ts +132 -8
  58. package/packages/aws-adapter/src/ip-admission.ts +79 -0
  59. package/packages/aws-adapter/src/serialize.ts +8 -0
  60. package/packages/aws-adapter/src/tables.ts +9 -0
  61. package/packages/aws-adapter/tests/admission.test.ts +60 -2
  62. package/packages/aws-adapter/tests/aws-harness.ts +23 -1
  63. package/packages/aws-adapter/tests/aws-runtime.test.ts +64 -0
  64. package/packages/aws-adapter/tests/config-loader.test.ts +151 -0
  65. package/packages/aws-adapter/tests/connect.test.ts +199 -2
  66. package/packages/aws-adapter/tests/default-frame-limit.test.ts +231 -0
  67. package/packages/aws-adapter/tests/default-occ.test.ts +10 -3
  68. package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +123 -1
  69. package/packages/aws-adapter/tests/handlers.test.ts +57 -1
  70. package/packages/aws-adapter/tests/nlb-secure.test.ts +362 -0
  71. package/packages/aws-adapter/tests/nlb-stream.test.ts +628 -9
  72. package/packages/cf-adapter/package.json +1 -1
  73. package/packages/cf-adapter/src/cf-runtime.ts +48 -9
  74. package/packages/cf-adapter/src/config-loader.ts +133 -8
  75. package/packages/cf-adapter/src/connection-do.ts +154 -21
  76. package/packages/cf-adapter/src/counter-do.ts +142 -0
  77. package/packages/cf-adapter/src/d1-services-store.ts +47 -5
  78. package/packages/cf-adapter/src/env.ts +88 -0
  79. package/packages/cf-adapter/src/index.ts +17 -1
  80. package/packages/cf-adapter/src/rate-limit-do.ts +87 -0
  81. package/packages/cf-adapter/tests/cf-runtime.test.ts +104 -15
  82. package/packages/cf-adapter/tests/config-loader.test.ts +159 -0
  83. package/packages/cf-adapter/tests/connection-do-counter.test.ts +165 -0
  84. package/packages/cf-adapter/tests/connection-do-frame-limit.test.ts +177 -0
  85. package/packages/cf-adapter/tests/connection-do-pure.test.ts +74 -5
  86. package/packages/cf-adapter/tests/connection-do-ws-spec-contract.test.ts +7 -4
  87. package/packages/cf-adapter/tests/counter-do.test.ts +181 -0
  88. package/packages/cf-adapter/tests/d1-services-store.test.ts +192 -1
  89. package/packages/cf-adapter/tests/rate-limit-do.test.ts +160 -0
  90. package/packages/cf-adapter/tests/worker/main.ts +4 -0
  91. package/packages/cf-adapter/wrangler.test.toml +18 -1
  92. package/packages/in-memory-runtime/package.json +1 -1
  93. package/packages/in-memory-runtime/src/in-memory-runtime.ts +25 -0
  94. package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +74 -0
  95. package/packages/irc-core/package.json +1 -1
  96. package/packages/irc-core/src/caps/capabilities.ts +20 -10
  97. package/packages/irc-core/src/certfp.ts +178 -0
  98. package/packages/irc-core/src/commands/cap.ts +10 -2
  99. package/packages/irc-core/src/commands/chanserv.ts +117 -14
  100. package/packages/irc-core/src/commands/chathistory.ts +13 -5
  101. package/packages/irc-core/src/commands/hostserv.ts +84 -8
  102. package/packages/irc-core/src/commands/index.ts +2 -1
  103. package/packages/irc-core/src/commands/invite.ts +1 -7
  104. package/packages/irc-core/src/commands/join.ts +1 -16
  105. package/packages/irc-core/src/commands/kick.ts +1 -8
  106. package/packages/irc-core/src/commands/list.ts +1 -8
  107. package/packages/irc-core/src/commands/mode.ts +1 -8
  108. package/packages/irc-core/src/commands/multiline.ts +4 -10
  109. package/packages/irc-core/src/commands/names.ts +53 -13
  110. package/packages/irc-core/src/commands/nickserv.ts +40 -1
  111. package/packages/irc-core/src/commands/oper.ts +361 -8
  112. package/packages/irc-core/src/commands/part.ts +4 -10
  113. package/packages/irc-core/src/commands/privmsg.ts +8 -4
  114. package/packages/irc-core/src/commands/registration.ts +146 -2
  115. package/packages/irc-core/src/commands/sasl.ts +136 -19
  116. package/packages/irc-core/src/commands/topic.ts +10 -12
  117. package/packages/irc-core/src/commands/who.ts +1 -8
  118. package/packages/irc-core/src/config.ts +393 -20
  119. package/packages/irc-core/src/effects.ts +24 -0
  120. package/packages/irc-core/src/flood-control.ts +10 -10
  121. package/packages/irc-core/src/frame-rate-limit.ts +82 -0
  122. package/packages/irc-core/src/index.ts +8 -0
  123. package/packages/irc-core/src/oper-hashing.ts +43 -0
  124. package/packages/irc-core/src/oper-lockout.ts +87 -0
  125. package/packages/irc-core/src/ports.ts +395 -36
  126. package/packages/irc-core/src/protocol/bytes.ts +65 -0
  127. package/packages/irc-core/src/protocol/channel-name.ts +37 -0
  128. package/packages/irc-core/src/protocol/index.ts +12 -1
  129. package/packages/irc-core/src/protocol/outbound.ts +43 -10
  130. package/packages/irc-core/src/protocol/parser.ts +79 -10
  131. package/packages/irc-core/src/state/connection.ts +13 -0
  132. package/packages/irc-core/src/types.ts +228 -13
  133. package/packages/irc-core/src/ws-framing.ts +5 -4
  134. package/packages/irc-core/tests/bytes.test.ts +89 -0
  135. package/packages/irc-core/tests/certfp.test.ts +117 -0
  136. package/packages/irc-core/tests/commands/cap.test.ts +76 -2
  137. package/packages/irc-core/tests/commands/chanserv.test.ts +166 -0
  138. package/packages/irc-core/tests/commands/chathistory.test.ts +140 -0
  139. package/packages/irc-core/tests/commands/hostserv.test.ts +316 -0
  140. package/packages/irc-core/tests/commands/join.test.ts +78 -1
  141. package/packages/irc-core/tests/commands/names.test.ts +193 -0
  142. package/packages/irc-core/tests/commands/nickserv.test.ts +182 -2
  143. package/packages/irc-core/tests/commands/oper.test.ts +560 -2
  144. package/packages/irc-core/tests/commands/privmsg.test.ts +16 -0
  145. package/packages/irc-core/tests/commands/registration.test.ts +463 -1
  146. package/packages/irc-core/tests/commands/sasl.test.ts +596 -7
  147. package/packages/irc-core/tests/commands/topic.test.ts +137 -2
  148. package/packages/irc-core/tests/commands/unified-account.test.ts +2 -0
  149. package/packages/irc-core/tests/config.test.ts +534 -2
  150. package/packages/irc-core/tests/effects.test.ts +14 -0
  151. package/packages/irc-core/tests/flood-control.test.ts +29 -1
  152. package/packages/irc-core/tests/frame-rate-limit.test.ts +98 -0
  153. package/packages/irc-core/tests/oper-hashing.test.ts +60 -0
  154. package/packages/irc-core/tests/oper-lockout.test.ts +74 -0
  155. package/packages/irc-core/tests/outbound.test.ts +148 -0
  156. package/packages/irc-core/tests/parser.test.ts +287 -5
  157. package/packages/irc-core/tests/persistent-services-store.test.ts +141 -0
  158. package/packages/irc-core/tests/ports.test.ts +99 -7
  159. package/packages/irc-core/tests/services-store.test.ts +376 -14
  160. package/packages/irc-core/tests/ws-framing.test.ts +45 -0
  161. package/packages/irc-server/package.json +1 -1
  162. package/packages/irc-server/src/actor.ts +123 -8
  163. package/packages/irc-server/src/dispatch.ts +1 -0
  164. package/packages/irc-server/src/index.ts +7 -0
  165. package/packages/irc-server/src/redact.ts +159 -0
  166. package/packages/irc-server/src/runtime.ts +14 -0
  167. package/packages/irc-server/src/transport.ts +28 -1
  168. package/packages/irc-server/tests/actor.test.ts +544 -7
  169. package/packages/irc-server/tests/dispatch.test.ts +31 -0
  170. package/packages/irc-server/tests/redact.test.ts +198 -0
  171. package/packages/irc-server/tests/runtime.test.ts +2 -0
  172. package/packages/irc-server/tests/transport.test.ts +66 -0
  173. package/packages/irc-test-support/package.json +1 -1
  174. package/packages/irc-test-support/src/in-memory-harness.ts +4 -0
  175. package/scripts/package.json +1 -1
  176. package/tools/ci-hardening/package.json +2 -2
  177. package/tools/ci-hardening/src/cf-deploy-cli.ts +3 -0
  178. package/tools/ci-hardening/src/cf-deploy.ts +118 -0
  179. package/tools/ci-hardening/src/deploy-hostname.ts +118 -0
  180. package/tools/ci-hardening/src/env-var-drift.ts +192 -0
  181. package/tools/ci-hardening/src/hostname-guard.ts +11 -0
  182. package/tools/ci-hardening/src/index.ts +17 -0
  183. package/tools/ci-hardening/tests/__wrangler_missing__.toml +2 -0
  184. package/tools/ci-hardening/tests/__wrangler_placeholder__.toml +3 -0
  185. package/tools/ci-hardening/tests/__wrangler_real__.toml +3 -0
  186. package/tools/ci-hardening/tests/cf-deploy.test.ts +200 -0
  187. package/tools/ci-hardening/tests/deploy-hostname.test.ts +348 -0
  188. package/tools/ci-hardening/tests/env-var-drift.test.ts +284 -0
  189. package/tools/ci-hardening/vitest.config.ts +5 -1
  190. package/tools/hash-oper-cred.ts +85 -0
  191. package/tools/load-test/package.json +1 -1
  192. package/tools/tcp-ws-forwarder/package.json +1 -1
@@ -16,6 +16,8 @@
16
16
  * - `OPER <name> <password>` — authenticates and (on success) grants oper.
17
17
  * - `381 RPL_YOUREOPER`:
18
18
  * `:<server> 381 <nick> :You are now an IRC operator`
19
+ * - `451 ERR_NOTREGISTERED`:
20
+ * `:<server> 451 <nick> :You have not registered`
19
21
  * - `461 ERR_NEEDMOREPARAMS`:
20
22
  * `:<server> 461 <nick> OPER :Not enough parameters`
21
23
  * - `464 ERR_PASSWDMISMATCH`:
@@ -24,25 +26,61 @@
24
26
  * `:<server> 491 <nick> :No O-lines for your host`
25
27
  *
26
28
  * Behaviour:
29
+ * - Connection not yet registered (`state.registration !==
30
+ * 'registered'`) → `451 ERR_NOTREGISTERED`, mirroring the SETNAME
31
+ * gate. A pre-registration client has no nick yet, so an oper grant
32
+ * here would produce an unattributable `nick === undefined` oper
33
+ * (WHOIS/audit hostmasks degrade); the gate also keeps the lockout
34
+ * counters and credential store untouched for unregistered callers.
27
35
  * - Missing `<name>` or `<password>` → `461 ERR_NEEDMOREPARAMS`.
28
36
  * - Already an operator → graceful idempotent no-op that re-confirms with
29
37
  * `381` (no re-authentication required; the connection is already privy).
30
38
  * No `MODE` echo is emitted because the user mode did not change.
31
39
  * - No operator credentials configured (absent or empty) →
32
40
  * `491 ERR_NOOPERHOST`.
41
+ * - Per-IP lockout tripped (see below) → `491 ERR_NOOPERHOST` WITHOUT
42
+ * consulting the credential store, plus a `BroadcastOperNotice` effect
43
+ * (`:<server> NOTICE * :OPER lockout triggered for <host>`) so every
44
+ * online oper learns a brute-force attempt is being throttled.
33
45
  * - Matching credential pair → grant oper, emit `381`, then emit a
34
46
  * `:<hostmask> MODE <nick> +o` notification to the connection so the
35
47
  * client learns its user mode changed (mirrors the user `MODE` echo).
36
48
  * - Wrong password / unknown name → `464 ERR_PASSWDMISMATCH`.
49
+ *
50
+ * Brute-force throttle (per-IP lockout):
51
+ * When a `ctx.operFailures` tracker is bound (the runtime owns one
52
+ * shared counter, `AdmissionStats`-style), every `464` records a
53
+ * failure for the connection's source host and every success resets it.
54
+ * Once `serverConfig.oper.maxFailures` failures (default 3) accumulate
55
+ * within `oper.failureWindowSeconds` (default 300s), subsequent `OPER`
56
+ * from that host are short-circuited above — the credential store (and
57
+ * its scrypt cost) is never consulted for a locked-out host, so online
58
+ * guessing is capped at maxFailures attempts per window per host.
59
+ *
60
+ * Lockout key: `state.host`. `ConnectionState` carries no dedicated IP
61
+ * field; transports stamp the peer address into `host` at connect time
62
+ * (AWS NLB / raw TCP supply the source IP verbatim). With cloaking
63
+ * enabled `host` holds the deterministic cloak — a pure function of the
64
+ * real IP — so per-IP grouping still holds; a HostServ vhost active on
65
+ * the connection re-keys that one connection. A connection with no host
66
+ * set is never tracked (no key → no counter → the credential gate alone
67
+ * applies).
37
68
  */
38
69
 
70
+ import { timingSafeEqual } from 'node:crypto';
71
+ import { DEFAULT_OPER_FAILURE_WINDOW_SECONDS, DEFAULT_OPER_MAX_FAILURES } from '../config.js';
72
+ import {
73
+ type HashedAccountCredential,
74
+ hashAccountCredential,
75
+ verifyHashedPassword,
76
+ } from '../credential-hashing.js';
39
77
  import { Effect } from '../effects.js';
40
78
  import type { RawLine } from '../effects.js';
41
79
  import { Numerics } from '../protocol/numerics.js';
42
80
  import { hostmaskOf } from '../state/connection.js';
43
81
  import type { ConnectionState } from '../state/connection.js';
44
- import type { OperCred } from '../types.js';
45
- import type { Ctx, Reducer } from '../types.js';
82
+ import type { OperCred, OperCredHashed, OperCredPlaintext } from '../types.js';
83
+ import type { Ctx, Reducer, ReducerResult } from '../types.js';
46
84
 
47
85
  /** Fixed text emitted for `381 RPL_YOUREOPER`. */
48
86
  const YOUREOPER_TRAILING_TEXT = 'You are now an IRC operator';
@@ -56,18 +94,235 @@ const NOOPERHOST_TRAILING_TEXT = 'No O-lines for your host';
56
94
  /** Fixed text emitted for `461 ERR_NEEDMOREPARAMS`. */
57
95
  const NEEDMOREPARAMS_TRAILING_TEXT = 'Not enough parameters';
58
96
 
97
+ /** Fixed text emitted for `451 ERR_NOTREGISTERED` (matches `SETNAME`). */
98
+ const NOTREGISTERED_TRAILING_TEXT = 'You have not registered';
99
+
100
+ /** Fixed prefix of the oper-notice broadcast when the lockout gate trips. */
101
+ const OPER_LOCKOUT_NOTICE_PREFIX = 'OPER lockout triggered for';
102
+
103
+ // ============================================================================
104
+ // Hashed-oper credential verify — scrypt + timing-safe user compare.
105
+ // ============================================================================
106
+
107
+ /**
108
+ * Fixed salt used to bake the precomputed fixture entries below. 16 bytes
109
+ * of `0x42` so the resulting scrypt derivations are deterministic across
110
+ * Node versions and test runs (the salt is never used to verify real
111
+ * attacker traffic — only the fixture entries).
112
+ */
113
+ const OPER_FIXTURE_SALT = new Uint8Array(16).fill(0x42);
114
+
115
+ /**
116
+ * User name baked into the precomputed dummy hashed entry. Never matches a
117
+ * real oper name (contains a NUL) so the dummy verify never collides with
118
+ * a configured credential.
119
+ */
120
+ const OPER_DUMMY_USER = '\u0000oper-timing-dummy';
121
+
122
+ /**
123
+ * Plaintext password baked into the precomputed dummy hashed entry. Never
124
+ * matches a real oper password (contains a NUL) so the dummy verify always
125
+ * fails — its purpose is to spend the same scrypt work as the
126
+ * known-user-with-wrong-password path, NOT to authenticate.
127
+ */
128
+ const OPER_DUMMY_PASSWORD = '\u0000oper-timing-dummy';
129
+
130
+ /** User name baked into {@link OPER_PRECOMPUTED_HASHED_CRED}. */
131
+ export const OPER_HASHED_USER = 'oper-hashed-fixture-user';
132
+
133
+ /** Password that verifies against {@link OPER_PRECOMPUTED_HASHED_CRED}. */
134
+ export const OPER_HASHED_VALID_PASSWORD = 'oper-hashed-fixture-password';
135
+
136
+ /** Password that does NOT verify against {@link OPER_PRECOMPUTED_HASHED_CRED}. */
137
+ export const OPER_HASHED_WRONG_PASSWORD = 'definitely-not-the-right-password';
138
+
139
+ /**
140
+ * Fixed scrypt-hashed credential used to equalise timing on the
141
+ * user-unknown path. {@link matchOperCred} always invokes exactly one
142
+ * verify — against this entry when no configured user matches, or against
143
+ * the matching cred when one does — so a remote attacker cannot
144
+ * distinguish "user exists, wrong password" from "user unknown" via
145
+ * wall-clock cost. Mirrors the {@link PASS_AUTH_DUMMY_NICK} pattern in
146
+ * `account-auth.ts`.
147
+ */
148
+ export const OPER_PRECOMPUTED_DUMMY_HASHED: HashedAccountCredential = hashAccountCredential(
149
+ OPER_DUMMY_USER,
150
+ OPER_DUMMY_PASSWORD,
151
+ {
152
+ salt: OPER_FIXTURE_SALT,
153
+ },
154
+ );
155
+
156
+ /**
157
+ * The precomputed hashed {@link OperCredHashed} used by tests to exercise
158
+ * the hashed-verify arm of {@link matchOperCred} deterministically without
159
+ * paying scrypt cost in the test fixture setup. The salt is fixed (the
160
+ * {@link OPER_FIXTURE_SALT} vector) so the resulting `hash` is stable
161
+ * across runs and Node versions; the password is
162
+ * {@link OPER_HASHED_VALID_PASSWORD}.
163
+ */
164
+ export const OPER_PRECOMPUTED_HASHED_CRED: OperCredHashed = (() => {
165
+ const entry = hashAccountCredential(OPER_HASHED_USER, OPER_HASHED_VALID_PASSWORD, {
166
+ salt: OPER_FIXTURE_SALT,
167
+ });
168
+ return { user: entry.account, salt: entry.salt, hash: entry.hash };
169
+ })();
170
+
171
+ /**
172
+ * Type-narrowing predicate: true iff `cred` is the legacy plaintext shape.
173
+ */
174
+ function isLegacyPlaintext(cred: OperCred): cred is OperCredPlaintext {
175
+ return (cred as OperCredPlaintext).password !== undefined;
176
+ }
177
+
178
+ /**
179
+ * Timing-safe byte-wise string comparison. Returns false immediately on
180
+ * length mismatch (the length is itself public — it is observable in the
181
+ * user-name field on the wire — so leaking it does not aid the attacker).
182
+ * For equal-length inputs every byte is compared via `timingSafeEqual`
183
+ * with no short-circuit.
184
+ */
185
+ function timingSafeStringEqual(a: string, b: string): boolean {
186
+ const aBuf = Buffer.from(a, 'utf8');
187
+ const bBuf = Buffer.from(b, 'utf8');
188
+ if (aBuf.length !== bBuf.length) return false;
189
+ return timingSafeEqual(aBuf, bBuf);
190
+ }
191
+
192
+ /**
193
+ * Indirection over {@link verifyHashedPassword} so tests can swap in a
194
+ * stub to assert call counts (timing-equalisation test). Defaults to the
195
+ * real primitive; `null` restores the default.
196
+ */
197
+ let verifyHashedPasswordFn: (password: string, entry: HashedAccountCredential) => boolean =
198
+ verifyHashedPassword;
199
+
200
+ /**
201
+ * Test seam: replaces the verify primitive with `fn` (or restores the
202
+ * default when `fn === null`). Exported for tests; production callers
203
+ * MUST NOT use.
204
+ *
205
+ * @internal
206
+ */
207
+ export function setOperVerifyHashedPassword(
208
+ fn: ((password: string, entry: HashedAccountCredential) => boolean) | null,
209
+ ): void {
210
+ verifyHashedPasswordFn = fn ?? verifyHashedPassword;
211
+ }
212
+
213
+ /** @internal test seam: clears any spy registered above. */
214
+ export function resetOperTimingSpy(): void {
215
+ verifyHashedPasswordFn = verifyHashedPassword;
216
+ }
217
+
218
+ /**
219
+ * Per-boot flag guarding the legacy-plaintext deprecation warning so the
220
+ * message is emitted exactly once per process (the configured cred list
221
+ * rarely changes within a single boot, and per-call spam would drown the
222
+ * signal).
223
+ */
224
+ let legacyPlaintextWarnEmitted = false;
225
+
226
+ /**
227
+ * Default warner: `console.warn` on every platform the project ships
228
+ * (Node for Lambda / local-cli, `nodejs_compat` on Cloudflare Workers).
229
+ * `console.warn` is intentionally the sink rather than a structured
230
+ * logger because `matchOperCred` is a pure helper with no logger
231
+ * injection seam; adapters that want richer routing can wrap the boot
232
+ * config inspection separately.
233
+ */
234
+ let operLegacyPlaintextWarner: (msg: string) => void = (msg) => {
235
+ console.warn(msg);
236
+ };
237
+
238
+ /** Fixed deprecation text emitted once per boot when a legacy cred is seen. */
239
+ const LEGACY_PLAINTEXT_WARNING =
240
+ 'Oper credentials configured in the deprecated {user,password} plaintext form. ' +
241
+ 'Run tools/hash-oper-cred.ts to migrate to the scrypt-hashed {user,salt,hash} form ' +
242
+ 'before the next release; the plaintext form will be removed.';
243
+
244
+ /**
245
+ * @internal test seam: replaces the warner sink. Pass `null` to restore
246
+ * the default `console.warn` sink.
247
+ */
248
+ export function setOperLegacyPlaintextWarner(fn: ((msg: string) => void) | null): void {
249
+ operLegacyPlaintextWarner =
250
+ fn ??
251
+ ((msg) => {
252
+ console.warn(msg);
253
+ });
254
+ }
255
+
256
+ /** @internal test seam: clears the per-boot warning flag. */
257
+ export function resetOperLegacyPlaintextWarning(): void {
258
+ legacyPlaintextWarnEmitted = false;
259
+ }
260
+
59
261
  /**
60
- * Returns true iff a credential pair in `creds` matches `name` / `password`
61
- * exactly. Extracted as a pure helper so the match logic is testable in
62
- * isolation and independent of connection state. Comparison is case
63
- * sensitive and byte-exact (case-mapping is owned elsewhere).
262
+ * Returns true iff a credential pair in `creds` matches `name` / `password`.
263
+ *
264
+ * Extracted as a pure helper so the match logic is testable in isolation
265
+ * and independent of connection state. Verification is timing-safe end to
266
+ * end:
267
+ * - The `user` field is compared via `timingSafeEqual` byte-wise (with
268
+ * a length precheck; the length is public on the wire).
269
+ * - For the at-rest hashed form, the password is verified via the shared
270
+ * {@link verifyHashedPassword} scrypt primitive (already
271
+ * `timingSafeEqual`-based).
272
+ * - For the deprecated legacy plaintext form, the password is verified
273
+ * via `timingSafeEqual` byte-wise as well.
274
+ * - When the user is unknown, a dummy scrypt verify runs against a
275
+ * fixed precomputed entry ({@link OPER_PRECOMPUTED_DUMMY_HASHED}) so
276
+ * the wall-clock cost is indistinguishable from the known-user path —
277
+ * a remote attacker cannot enumerate oper names via timing.
278
+ *
279
+ * The legacy plaintext form triggers a one-shot `warn` per boot so
280
+ * operators notice and rotate; the deprecation cycle removes the form
281
+ * entirely in a future release.
64
282
  */
65
283
  export function matchOperCred(
66
284
  creds: ReadonlyArray<OperCred>,
67
285
  name: string,
68
286
  password: string,
69
287
  ): boolean {
70
- return creds.some((c) => c.user === name && c.password === password);
288
+ // Locate the cred whose user matches `name` (timing-safe on the user
289
+ // field). Iterate the WHOLE list — never short-circuit on a match — so
290
+ // the iteration count does not leak which entry matched.
291
+ let candidate: OperCred | undefined;
292
+ let sawLegacyPlaintext = false;
293
+ for (const c of creds) {
294
+ if (isLegacyPlaintext(c)) sawLegacyPlaintext = true;
295
+ if (timingSafeStringEqual(c.user, name)) {
296
+ candidate = c;
297
+ }
298
+ }
299
+
300
+ if (sawLegacyPlaintext && !legacyPlaintextWarnEmitted) {
301
+ legacyPlaintextWarnEmitted = true;
302
+ operLegacyPlaintextWarner(LEGACY_PLAINTEXT_WARNING);
303
+ }
304
+
305
+ if (candidate === undefined) {
306
+ // Unknown user: equalise timing with a dummy scrypt verify against a
307
+ // fixed precomputed entry. The result is discarded; the cost is the
308
+ // point.
309
+ verifyHashedPasswordFn(password, OPER_PRECOMPUTED_DUMMY_HASHED);
310
+ return false;
311
+ }
312
+
313
+ if (!isLegacyPlaintext(candidate)) {
314
+ // At-rest hashed form: delegate to the shared scrypt verify.
315
+ const entry: HashedAccountCredential = {
316
+ account: candidate.user,
317
+ algorithm: 'scrypt',
318
+ salt: candidate.salt,
319
+ hash: candidate.hash,
320
+ };
321
+ return verifyHashedPasswordFn(password, entry);
322
+ }
323
+
324
+ // Legacy plaintext (deprecation cycle): timing-safe byte compare.
325
+ return timingSafeStringEqual(candidate.password, password);
71
326
  }
72
327
 
73
328
  /**
@@ -75,11 +330,27 @@ export function matchOperCred(
75
330
  *
76
331
  * Verifies the supplied pair against `serverConfig.operCreds` and, on
77
332
  * success, grants the oper user mode. Emits the appropriate numeric reply
78
- * in every branch. See the file header for the full decision table.
333
+ * in every branch. See the file header for the full decision table
334
+ * (including the per-IP brute-force lockout).
79
335
  */
80
336
  export const operReducer: Reducer<ConnectionState> = (state, msg, ctx) => {
81
337
  state.lastSeen = ctx.clock.now();
82
338
 
339
+ // Registration gate (mirrors the SETNAME reducer): OPER is meaningless —
340
+ // and unattributable — before the welcome block. Runs first so a
341
+ // pre-registration caller never reaches the credential store, the
342
+ // lockout counters, or the oper-grant path.
343
+ if (state.registration !== 'registered') {
344
+ return {
345
+ state,
346
+ effects: [
347
+ Effect.send(ctx.connId, [
348
+ numericLine(ctx, Numerics.ERR_NOTREGISTERED, NOTREGISTERED_TRAILING_TEXT),
349
+ ]),
350
+ ],
351
+ };
352
+ }
353
+
83
354
  const name = msg.params[0];
84
355
  const password = msg.params[1];
85
356
 
@@ -95,6 +366,8 @@ export const operReducer: Reducer<ConnectionState> = (state, msg, ctx) => {
95
366
  }
96
367
 
97
368
  // Already an operator: graceful idempotent re-confirm, no re-auth needed.
369
+ // Runs BEFORE the lockout gate — an authenticated oper never loses 381
370
+ // because a (possibly different) connection from their host is throttled.
98
371
  if (state.userModes.oper) {
99
372
  return {
100
373
  state,
@@ -106,6 +379,15 @@ export const operReducer: Reducer<ConnectionState> = (state, msg, ctx) => {
106
379
  };
107
380
  }
108
381
 
382
+ // Per-IP brute-force gate: once the source host has exhausted its failure
383
+ // budget, reject without consulting the credential store and notify every
384
+ // oper. Runs before the creds-configured check so the locked-out path
385
+ // performs zero credential work of any kind.
386
+ const lockout = operLockoutResult(state, ctx);
387
+ if (lockout !== undefined) {
388
+ return lockout;
389
+ }
390
+
109
391
  const creds = ctx.serverConfig.operCreds;
110
392
  if (creds === undefined || creds.length === 0) {
111
393
  return {
@@ -120,6 +402,8 @@ export const operReducer: Reducer<ConnectionState> = (state, msg, ctx) => {
120
402
 
121
403
  if (matchOperCred(creds, name, password)) {
122
404
  state.userModes.oper = true;
405
+ // A legitimate oper's success never contributes to a later lockout.
406
+ resetOperFailures(state, ctx);
123
407
  return {
124
408
  state,
125
409
  effects: [
@@ -131,6 +415,8 @@ export const operReducer: Reducer<ConnectionState> = (state, msg, ctx) => {
131
415
  };
132
416
  }
133
417
 
418
+ // Credential failure: count it against the source host's budget.
419
+ recordOperFailure(state, ctx);
134
420
  return {
135
421
  state,
136
422
  effects: [
@@ -141,6 +427,73 @@ export const operReducer: Reducer<ConnectionState> = (state, msg, ctx) => {
141
427
  };
142
428
  };
143
429
 
430
+ /**
431
+ * Returns the lockout rejection ({@link ReducerResult}) when the source
432
+ * host has exhausted its failed-OPER budget, or `undefined` when the
433
+ * credential path should proceed. No-ops (returns `undefined`) when no
434
+ * tracker is bound, the connection has no host, or the in-window failure
435
+ * count is still below the configured threshold.
436
+ */
437
+ function operLockoutResult(
438
+ state: ConnectionState,
439
+ ctx: Ctx,
440
+ ): ReducerResult<ConnectionState> | undefined {
441
+ const tracker = ctx.operFailures;
442
+ if (tracker === undefined) return undefined;
443
+ const key = state.host;
444
+ if (key === undefined) return undefined;
445
+
446
+ const { maxFailures, windowMs } = operLockoutThresholds(ctx);
447
+ if (tracker.recentFailures(key, windowMs) < maxFailures) return undefined;
448
+
449
+ return {
450
+ state,
451
+ effects: [
452
+ Effect.send(ctx.connId, [
453
+ numericLine(ctx, Numerics.ERR_NOOPERHOST, NOOPERHOST_TRAILING_TEXT),
454
+ ]),
455
+ Effect.broadcastOperNotice([operLockoutNoticeLine(ctx, key)]),
456
+ ],
457
+ };
458
+ }
459
+
460
+ /**
461
+ * Resolves the lockout thresholds from `serverConfig.oper`, falling back
462
+ * to the documented defaults when the config (or an individual field) is
463
+ * omitted.
464
+ */
465
+ function operLockoutThresholds(ctx: Ctx): { maxFailures: number; windowMs: number } {
466
+ const cfg = ctx.serverConfig.oper;
467
+ const maxFailures = cfg?.maxFailures ?? DEFAULT_OPER_MAX_FAILURES;
468
+ const failureWindowSeconds = cfg?.failureWindowSeconds ?? DEFAULT_OPER_FAILURE_WINDOW_SECONDS;
469
+ return { maxFailures, windowMs: failureWindowSeconds * 1000 };
470
+ }
471
+
472
+ /**
473
+ * Builds the `:<server> NOTICE * :OPER lockout triggered for <host>` line
474
+ * fanned out to every oper. Target `*` (not a nick) because the notice is
475
+ * server-authored and addressed to the oper corps as a whole.
476
+ */
477
+ function operLockoutNoticeLine(ctx: Ctx, host: string): RawLine {
478
+ return { text: `:${ctx.serverName} NOTICE * :${OPER_LOCKOUT_NOTICE_PREFIX} ${host}` };
479
+ }
480
+
481
+ /** Records a credential failure against the host's budget (no-op untracked). */
482
+ function recordOperFailure(state: ConnectionState, ctx: Ctx): void {
483
+ const tracker = ctx.operFailures;
484
+ const key = state.host;
485
+ if (tracker === undefined || key === undefined) return;
486
+ tracker.recordFailure(key);
487
+ }
488
+
489
+ /** Clears the host's failure budget after a successful authentication. */
490
+ function resetOperFailures(state: ConnectionState, ctx: Ctx): void {
491
+ const tracker = ctx.operFailures;
492
+ const key = state.host;
493
+ if (tracker === undefined || key === undefined) return;
494
+ tracker.reset(key);
495
+ }
496
+
144
497
  /**
145
498
  * Builds a `:<server> <code> <nick> [<middle>] :<trailing>` line. The nick
146
499
  * falls back to `*` when the connection has not yet registered one, matching
@@ -8,26 +8,20 @@
8
8
  * `ctx.connection.joinedChannels` (the connection's joined list) are
9
9
  * expressed as effects / cross-authority mutations the actor layer persists.
10
10
  *
11
- * Multi-channel PART (`PART #a,#b :reason`) is split by the actor layer;
12
- * this reducer handles one channel per invocation.
11
+ * Multi-channel PART (`PART #a,#b :reason`) is split by the actor layer,
12
+ * which also enforces `maxTargetsPerCommand` (dropped channels get a
13
+ * `407 ERR_TOOMANYTARGETS`); this reducer handles one channel per invocation.
13
14
  */
14
15
 
15
16
  import { Effect } from '../effects.js';
16
17
  import type { Effect as EffectType, RawLine } from '../effects.js';
18
+ import { isValidChannelName } from '../protocol/channel-name.js';
17
19
  import { Numerics } from '../protocol/numerics.js';
18
20
  import type { ChannelState } from '../state/channel.js';
19
21
  import { hostmaskOf } from '../state/connection.js';
20
22
  import type { ConnectionState } from '../state/connection.js';
21
23
  import type { Ctx, Reducer } from '../types.js';
22
24
 
23
- /** Channel-name validation mirrors JOIN: same grammar, same length cap. */
24
- const CHANNEL_NAME_RE = /^[#&][^\s,:]+$/u;
25
-
26
- function isValidChannelName(name: string, maxLen: number): boolean {
27
- if (name.length === 0 || name.length > maxLen) return false;
28
- return CHANNEL_NAME_RE.test(name);
29
- }
30
-
31
25
  /**
32
26
  * Formats a single numeric error line addressed to the connection's current
33
27
  * nick (or `*` when unregistered). Optional `middle` is emitted before the
@@ -25,16 +25,20 @@
25
25
 
26
26
  import { Effect } from '../effects.js';
27
27
  import type { Effect as EffectType, RawLine } from '../effects.js';
28
+ import { CHANNEL_NAME_RE } from '../protocol/channel-name.js';
28
29
  import { Numerics } from '../protocol/numerics.js';
29
30
  import type { ChannelState } from '../state/channel.js';
30
31
  import { hostmaskOf } from '../state/connection.js';
31
32
  import type { ConnectionState } from '../state/connection.js';
32
33
  import type { Ctx, Reducer } from '../types.js';
33
34
 
34
- /** Channel-name grammar; mirrors {@link isValidChannelName} in `join.ts`. */
35
- const CHANNEL_NAME_RE = /^[#&][^\s,:]+$/u;
36
-
37
- /** Returns true iff `name` looks like a channel target (vs. a nick target). */
35
+ /**
36
+ * Returns true iff `name` looks like a channel target (vs. a nick target).
37
+ * Routing-only check against the shared channel-name grammar: it applies no
38
+ * byte-length cap, so an over-long `#name` still routes to the channel path
39
+ * (where the channel cannot exist) rather than silently becoming a nick
40
+ * message.
41
+ */
38
42
  function isChannelTarget(name: string): boolean {
39
43
  return CHANNEL_NAME_RE.test(name);
40
44
  }