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
@@ -26,10 +26,15 @@
26
26
  *
27
27
  * The reducer is a pure `Reducer<ConnectionState>`: all side effects are
28
28
  * emitted as {@link Effect}s. The services store is synchronous (mirrors
29
- * the {@link AccountStore} / {@link MessageStore} pattern).
29
+ * the {@link MessageStore} pattern).
30
30
  */
31
31
 
32
- import { DEFAULT_NICK_ENFORCE_GRACE_MS } from '../config.js';
32
+ import {
33
+ DEFAULT_IDENTIFY_FREEZE_SECONDS,
34
+ DEFAULT_MAX_IDENTIFY_FAILURES,
35
+ DEFAULT_MIN_PASSWORD_LENGTH,
36
+ DEFAULT_NICK_ENFORCE_GRACE_MS,
37
+ } from '../config.js';
33
38
  import { Effect } from '../effects.js';
34
39
  import type { Effect as EffectType, RawLine } from '../effects.js';
35
40
  import type { NickEnforcePolicy, ServicesStore } from '../ports.js';
@@ -48,6 +53,41 @@ const SERVICES_HOST = 'services';
48
53
  /** Policies accepted by `SET ENFORCE` (lowercased input forms). */
49
54
  const ENFORCE_POLICIES: ReadonlySet<NickEnforcePolicy> = new Set(['none', 'ghost', 'kill']);
50
55
 
56
+ /**
57
+ * Fixed maximum password length accepted by `SET PASSWORD`. Bounds the
58
+ * scrypt input budget (256 is the practical limit of the scrypt input);
59
+ * not configurable. Over-long passwords are rejected before hashing.
60
+ */
61
+ const MAX_PASSWORD_LENGTH = 256;
62
+
63
+ /**
64
+ * Gate NOTICE sent to an unidentified caller (or one identified as a
65
+ * different account) attempting `SET ENFORCE`. Wording kept verbatim from
66
+ * the original inline literal so the existing ENFORCE test suite is not
67
+ * churned by the addition of `SET PASSWORD` (a sibling constant covers the
68
+ * PASSWORD gate).
69
+ */
70
+ const ENFORCE_GATE_NOTICE = 'You must identify before changing enforcement.';
71
+
72
+ /**
73
+ * Gate NOTICE sent to an unidentified caller (or one identified as a
74
+ * different account) attempting `SET PASSWORD`. Wording mirrors the ENFORCE
75
+ * gate's intent but speaks to "password" rather than "enforcement" so a
76
+ * caller who hits it understands which subcommand they tripped.
77
+ */
78
+ const PASSWORD_GATE_NOTICE = 'You must identify before changing your password.';
79
+
80
+ /**
81
+ * Formats the owner NOTICE delivered on the first successful identify
82
+ * after failed identifies were recorded against the account. Delivered
83
+ * once (the queued count is cleared with the delivery), so the owner
84
+ * learns their account was targeted even though every attempt failed.
85
+ */
86
+ function identifyFailureNoticeText(count: number): string {
87
+ const plural = count === 1 ? 'attempt' : 'attempts';
88
+ return `Warning: ${count} failed identify ${plural} recorded against your account since your last successful login.`;
89
+ }
90
+
51
91
  /**
52
92
  * Reducer for `PRIVMSG NickServ :<subcommand> <args…>`.
53
93
  * Authority: the invoking connection's {@link ConnectionState}.
@@ -157,13 +197,36 @@ function handleIdentify(
157
197
  return { state, effects };
158
198
  }
159
199
 
200
+ // Per-account freeze: once maxIdentifyFailures failed attempts were
201
+ // recorded within the sliding freeze window, reject BEFORE running
202
+ // scrypt (the freeze is the cheap rejection path; verifyNick is the
203
+ // expensive one an attacker would otherwise keep hitting).
204
+ const maxFailures =
205
+ ctx.serverConfig.nickServ?.maxIdentifyFailures ?? DEFAULT_MAX_IDENTIFY_FAILURES;
206
+ const freezeWindowMs =
207
+ (ctx.serverConfig.nickServ?.identifyFreezeSeconds ?? DEFAULT_IDENTIFY_FREEZE_SECONDS) * 1000;
208
+ if (services.isNickIdentifyFrozen(nick, { maxFailures, windowMs: freezeWindowMs })) {
209
+ effects.push(
210
+ notice(state, `Nick ${nick} is temporarily frozen due to too many failed identify attempts.`),
211
+ );
212
+ return { state, effects };
213
+ }
214
+
160
215
  const result = services.verifyNick(nick, password);
161
216
  if (!result.ok) {
217
+ services.recordIdentifyFailure(nick);
162
218
  effects.push(notice(state, 'Invalid password.'));
163
219
  return { state, effects };
164
220
  }
165
221
 
222
+ // A successful login delivers the queued owner notice (if any failed
223
+ // identifies were recorded since the last successful login) and resets
224
+ // the per-account failure accounting.
225
+ const failedSinceLogin = services.clearIdentifyFailures(nick);
166
226
  applyIdentify(state, result.account, services, effects);
227
+ if (failedSinceLogin > 0) {
228
+ effects.push(notice(state, identifyFailureNoticeText(failedSinceLogin)));
229
+ }
167
230
  return { state, effects };
168
231
  }
169
232
 
@@ -318,12 +381,24 @@ export function enforceRegisteredNick(state: ConnectionState, ctx: Ctx): EffectT
318
381
  // ============================================================================
319
382
 
320
383
  /**
321
- * Handles `SET <subkey> <args…>`. Currently only `ENFORCE none|ghost|kill`
322
- * is supported; unknown subkeys fall back to the ENFORCE syntax notice.
384
+ * Handles `SET <subkey> <args…>`. Supported subkeys:
385
+ *
386
+ * - `ENFORCE none|ghost|kill` — sets the per-account enforcement policy.
387
+ * - `PASSWORD <old> <new>` — self-service password change. Requires the
388
+ * current password to be re-supplied as a defence against a hijacked
389
+ * identified session (a stolen `+r` session should not let an attacker
390
+ * silently rotate the password and lock the real owner out — this is
391
+ * Atheme's shape, not the alternative "trust the gate" one-arg form).
392
+ * The new password is re-hashed via `hashAccountCredential` and
393
+ * overwrites the stored credential via
394
+ * {@link ServicesStore.setNickPassword}; the current session stays
395
+ * `+r` and other sessions of the same account are NOT kicked.
323
396
  *
324
- * Requires the caller to be identified as the account bound to the current
325
- * nick: enforcement is a per-account policy, so an unidentified client
326
- * (or one identified as a different account) cannot mutate it.
397
+ * Unknown subkeys fall back to the ENFORCE syntax notice.
398
+ *
399
+ * Both subkeys require the caller to be identified as the account bound to
400
+ * the current nick: an unidentified client (or one identified as a
401
+ * different account) cannot mutate the account.
327
402
  */
328
403
  function handleSet(
329
404
  state: ConnectionState,
@@ -331,8 +406,10 @@ function handleSet(
331
406
  ctx: Ctx,
332
407
  effects: EffectType[],
333
408
  ): { state: ConnectionState; effects: EffectType[] } {
334
- const services = ctx.services as ServicesStore;
335
409
  const subkey = args[0]?.toUpperCase();
410
+ if (subkey === 'PASSWORD') {
411
+ return handleSetPassword(state, args.slice(1), ctx, effects);
412
+ }
336
413
  if (subkey !== 'ENFORCE') {
337
414
  effects.push(enforceSyntaxNotice(state));
338
415
  return { state, effects };
@@ -347,10 +424,11 @@ function handleSet(
347
424
 
348
425
  const nick = state.nick;
349
426
  if (nick === undefined || state.account === undefined) {
350
- effects.push(notice(state, 'You must identify before changing enforcement.'));
427
+ effects.push(notice(state, ENFORCE_GATE_NOTICE));
351
428
  return { state, effects };
352
429
  }
353
430
 
431
+ const services = ctx.services as ServicesStore;
354
432
  const rec = services.getNick(nick);
355
433
  if (rec === undefined) {
356
434
  effects.push(notice(state, `Nick ${nick} is not registered.`));
@@ -358,7 +436,7 @@ function handleSet(
358
436
  }
359
437
  // The caller must be identified as the account that owns this nick.
360
438
  if (state.account !== rec.account) {
361
- effects.push(notice(state, 'You must identify before changing enforcement.'));
439
+ effects.push(notice(state, ENFORCE_GATE_NOTICE));
362
440
  return { state, effects };
363
441
  }
364
442
 
@@ -367,11 +445,80 @@ function handleSet(
367
445
  return { state, effects };
368
446
  }
369
447
 
448
+ /**
449
+ * Handles `SET PASSWORD <old> <new>`. Mirrors the `SET ENFORCE` identify
450
+ * gate (`state.account === rec.account`), then re-verifies the current
451
+ * password (no-leak parity with `IDENTIFY`'s `Invalid password.` notice)
452
+ * and enforces the configurable minimum / fixed maximum length on the new
453
+ * password before re-hashing and persisting.
454
+ */
455
+ function handleSetPassword(
456
+ state: ConnectionState,
457
+ args: string[],
458
+ ctx: Ctx,
459
+ effects: EffectType[],
460
+ ): { state: ConnectionState; effects: EffectType[] } {
461
+ const oldPassword = args[0];
462
+ const newPassword = args[1];
463
+ if (oldPassword === undefined || newPassword === undefined) {
464
+ effects.push(passwordSyntaxNotice(state));
465
+ return { state, effects };
466
+ }
467
+
468
+ const nick = state.nick;
469
+ if (nick === undefined || state.account === undefined) {
470
+ effects.push(notice(state, PASSWORD_GATE_NOTICE));
471
+ return { state, effects };
472
+ }
473
+
474
+ const services = ctx.services as ServicesStore;
475
+ const rec = services.getNick(nick);
476
+ if (rec === undefined) {
477
+ effects.push(notice(state, `Nick ${nick} is not registered.`));
478
+ return { state, effects };
479
+ }
480
+ // The caller must be identified as the account that owns this nick.
481
+ if (state.account !== rec.account) {
482
+ effects.push(notice(state, PASSWORD_GATE_NOTICE));
483
+ return { state, effects };
484
+ }
485
+
486
+ // Re-verify the current password (defence against a hijacked `+r`
487
+ // session). Same `verifyNick` path `IDENTIFY` uses → same `Invalid
488
+ // password.` notice → no information leak about whether the account
489
+ // exists (the gate above already proved it does).
490
+ if (!services.verifyNick(nick, oldPassword).ok) {
491
+ effects.push(notice(state, 'Invalid password.'));
492
+ return { state, effects };
493
+ }
494
+
495
+ const minLen = ctx.serverConfig.nickServ?.minPasswordLength ?? DEFAULT_MIN_PASSWORD_LENGTH;
496
+ if (newPassword.length < minLen) {
497
+ effects.push(notice(state, `Password is too short (minimum ${minLen} characters).`));
498
+ return { state, effects };
499
+ }
500
+ if (newPassword.length > MAX_PASSWORD_LENGTH) {
501
+ effects.push(
502
+ notice(state, `Password is too long (maximum ${MAX_PASSWORD_LENGTH} characters).`),
503
+ );
504
+ return { state, effects };
505
+ }
506
+
507
+ services.setNickPassword(nick, newPassword);
508
+ effects.push(notice(state, 'Password changed.'));
509
+ return { state, effects };
510
+ }
511
+
370
512
  /** Builds the `SET ENFORCE` syntax NOTICE. */
371
513
  function enforceSyntaxNotice(state: ConnectionState): EffectType {
372
514
  return notice(state, 'Syntax: SET ENFORCE none|ghost|kill');
373
515
  }
374
516
 
517
+ /** Builds the `SET PASSWORD` syntax NOTICE. */
518
+ function passwordSyntaxNotice(state: ConnectionState): EffectType {
519
+ return notice(state, 'Syntax: SET PASSWORD <old-password> <new-password>');
520
+ }
521
+
375
522
  // ============================================================================
376
523
  // Notice helpers
377
524
  // ============================================================================
@@ -387,7 +534,10 @@ function helpNotice(state: ConnectionState): EffectType {
387
534
  }
388
535
 
389
536
  function unknownNotice(state: ConnectionState): EffectType {
390
- return notice(state, 'Unknown command. Available: REGISTER, IDENTIFY, DROP, INFO, SET');
537
+ return notice(
538
+ state,
539
+ 'Unknown command. Available: REGISTER, IDENTIFY, DROP, INFO, SET (ENFORCE, PASSWORD)',
540
+ );
391
541
  }
392
542
 
393
543
  /**