serverless-ircd 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (192) hide show
  1. package/.github/workflows/ci.yml +28 -0
  2. package/.github/workflows/deploy-cf-tcp.yml +26 -2
  3. package/.github/workflows/deploy-cf.yml +26 -0
  4. package/CHANGELOG.md +289 -0
  5. package/README.md +153 -20
  6. package/apps/aws-stack/bin/aws.ts +36 -0
  7. package/apps/aws-stack/package.json +1 -1
  8. package/apps/aws-stack/src/aws-stack.ts +221 -15
  9. package/apps/aws-stack/tests/stack.test.ts +450 -16
  10. package/apps/cf-tcp-container/Dockerfile +37 -5
  11. package/apps/cf-tcp-container/package.json +7 -2
  12. package/apps/cf-tcp-container/src/config-loader.ts +113 -2
  13. package/apps/cf-tcp-container/src/container-server.ts +256 -79
  14. package/apps/cf-tcp-container/src/main.ts +22 -7
  15. package/apps/cf-tcp-container/src/proxy-protocol.ts +112 -0
  16. package/apps/cf-tcp-container/terraform/spectrum.tf +40 -11
  17. package/apps/cf-tcp-container/tests/config-loader.test.ts +170 -0
  18. package/apps/cf-tcp-container/tests/container-server-tls.test.ts +382 -0
  19. package/apps/cf-tcp-container/tests/container-server.test.ts +358 -31
  20. package/apps/cf-tcp-container/tests/dockerfile.test.ts +110 -0
  21. package/apps/cf-tcp-container/tests/proxy-protocol.test.ts +187 -0
  22. package/apps/cf-tcp-container/tests/spectrum-terraform.test.ts +135 -0
  23. package/apps/cf-tcp-container/tests/tls-e2e.test.ts +5 -1
  24. package/apps/cf-tcp-container/wrangler.toml +17 -4
  25. package/apps/cf-worker/package.json +2 -2
  26. package/apps/cf-worker/src/worker.ts +77 -5
  27. package/apps/cf-worker/tests/raw-modules.d.ts +11 -0
  28. package/apps/cf-worker/tests/smoke.test.ts +4 -0
  29. package/apps/cf-worker/tests/wrangler-config.test.ts +47 -0
  30. package/apps/cf-worker/tests/ws-admission.test.ts +112 -0
  31. package/apps/cf-worker/tests/ws-rate-limit.test.ts +133 -0
  32. package/apps/cf-worker/wrangler.test.toml +15 -1
  33. package/apps/cf-worker/wrangler.toml +86 -9
  34. package/apps/local-cli/package.json +1 -1
  35. package/apps/local-cli/src/config-loader.ts +14 -2
  36. package/apps/local-cli/src/line-scanner.ts +26 -0
  37. package/apps/local-cli/src/server.ts +23 -2
  38. package/apps/local-cli/tests/line-scanner.test.ts +64 -0
  39. package/apps/local-cli/tests/tcp.test.ts +29 -0
  40. package/apps/web/package.json +1 -1
  41. package/docs/AWS-Deployment.md +123 -22
  42. package/docs/AWS-TCP-Deployment.md +37 -2
  43. package/docs/Chat-History.md +55 -0
  44. package/docs/Cloudflare-Deployment-Guide.md +9 -2
  45. package/docs/Cloudflare-TCP-Deployment.md +135 -52
  46. package/docs/SASL-EXTERNAL.md +175 -0
  47. package/package.json +3 -3
  48. package/packages/aws-adapter/package.json +1 -1
  49. package/packages/aws-adapter/src/admission.ts +28 -13
  50. package/packages/aws-adapter/src/aws-runtime.ts +30 -3
  51. package/packages/aws-adapter/src/cdk-table-defs.ts +34 -6
  52. package/packages/aws-adapter/src/config-loader.ts +134 -6
  53. package/packages/aws-adapter/src/dynamo-services-store.ts +12 -0
  54. package/packages/aws-adapter/src/handlers/connect.ts +47 -1
  55. package/packages/aws-adapter/src/handlers/default.ts +95 -6
  56. package/packages/aws-adapter/src/handlers/index.ts +31 -2
  57. package/packages/aws-adapter/src/handlers/nlb-stream.ts +132 -8
  58. package/packages/aws-adapter/src/ip-admission.ts +79 -0
  59. package/packages/aws-adapter/src/serialize.ts +8 -0
  60. package/packages/aws-adapter/src/tables.ts +9 -0
  61. package/packages/aws-adapter/tests/admission.test.ts +60 -2
  62. package/packages/aws-adapter/tests/aws-harness.ts +23 -1
  63. package/packages/aws-adapter/tests/aws-runtime.test.ts +64 -0
  64. package/packages/aws-adapter/tests/config-loader.test.ts +151 -0
  65. package/packages/aws-adapter/tests/connect.test.ts +199 -2
  66. package/packages/aws-adapter/tests/default-frame-limit.test.ts +231 -0
  67. package/packages/aws-adapter/tests/default-occ.test.ts +10 -3
  68. package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +123 -1
  69. package/packages/aws-adapter/tests/handlers.test.ts +57 -1
  70. package/packages/aws-adapter/tests/nlb-secure.test.ts +362 -0
  71. package/packages/aws-adapter/tests/nlb-stream.test.ts +628 -9
  72. package/packages/cf-adapter/package.json +1 -1
  73. package/packages/cf-adapter/src/cf-runtime.ts +48 -9
  74. package/packages/cf-adapter/src/config-loader.ts +133 -8
  75. package/packages/cf-adapter/src/connection-do.ts +154 -21
  76. package/packages/cf-adapter/src/counter-do.ts +142 -0
  77. package/packages/cf-adapter/src/d1-services-store.ts +47 -5
  78. package/packages/cf-adapter/src/env.ts +88 -0
  79. package/packages/cf-adapter/src/index.ts +17 -1
  80. package/packages/cf-adapter/src/rate-limit-do.ts +87 -0
  81. package/packages/cf-adapter/tests/cf-runtime.test.ts +104 -15
  82. package/packages/cf-adapter/tests/config-loader.test.ts +159 -0
  83. package/packages/cf-adapter/tests/connection-do-counter.test.ts +165 -0
  84. package/packages/cf-adapter/tests/connection-do-frame-limit.test.ts +177 -0
  85. package/packages/cf-adapter/tests/connection-do-pure.test.ts +74 -5
  86. package/packages/cf-adapter/tests/connection-do-ws-spec-contract.test.ts +7 -4
  87. package/packages/cf-adapter/tests/counter-do.test.ts +181 -0
  88. package/packages/cf-adapter/tests/d1-services-store.test.ts +192 -1
  89. package/packages/cf-adapter/tests/rate-limit-do.test.ts +160 -0
  90. package/packages/cf-adapter/tests/worker/main.ts +4 -0
  91. package/packages/cf-adapter/wrangler.test.toml +18 -1
  92. package/packages/in-memory-runtime/package.json +1 -1
  93. package/packages/in-memory-runtime/src/in-memory-runtime.ts +25 -0
  94. package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +74 -0
  95. package/packages/irc-core/package.json +1 -1
  96. package/packages/irc-core/src/caps/capabilities.ts +20 -10
  97. package/packages/irc-core/src/certfp.ts +178 -0
  98. package/packages/irc-core/src/commands/cap.ts +10 -2
  99. package/packages/irc-core/src/commands/chanserv.ts +117 -14
  100. package/packages/irc-core/src/commands/chathistory.ts +13 -5
  101. package/packages/irc-core/src/commands/hostserv.ts +84 -8
  102. package/packages/irc-core/src/commands/index.ts +2 -1
  103. package/packages/irc-core/src/commands/invite.ts +1 -7
  104. package/packages/irc-core/src/commands/join.ts +1 -16
  105. package/packages/irc-core/src/commands/kick.ts +1 -8
  106. package/packages/irc-core/src/commands/list.ts +1 -8
  107. package/packages/irc-core/src/commands/mode.ts +1 -8
  108. package/packages/irc-core/src/commands/multiline.ts +4 -10
  109. package/packages/irc-core/src/commands/names.ts +53 -13
  110. package/packages/irc-core/src/commands/nickserv.ts +40 -1
  111. package/packages/irc-core/src/commands/oper.ts +361 -8
  112. package/packages/irc-core/src/commands/part.ts +4 -10
  113. package/packages/irc-core/src/commands/privmsg.ts +8 -4
  114. package/packages/irc-core/src/commands/registration.ts +146 -2
  115. package/packages/irc-core/src/commands/sasl.ts +136 -19
  116. package/packages/irc-core/src/commands/topic.ts +10 -12
  117. package/packages/irc-core/src/commands/who.ts +1 -8
  118. package/packages/irc-core/src/config.ts +393 -20
  119. package/packages/irc-core/src/effects.ts +24 -0
  120. package/packages/irc-core/src/flood-control.ts +10 -10
  121. package/packages/irc-core/src/frame-rate-limit.ts +82 -0
  122. package/packages/irc-core/src/index.ts +8 -0
  123. package/packages/irc-core/src/oper-hashing.ts +43 -0
  124. package/packages/irc-core/src/oper-lockout.ts +87 -0
  125. package/packages/irc-core/src/ports.ts +395 -36
  126. package/packages/irc-core/src/protocol/bytes.ts +65 -0
  127. package/packages/irc-core/src/protocol/channel-name.ts +37 -0
  128. package/packages/irc-core/src/protocol/index.ts +12 -1
  129. package/packages/irc-core/src/protocol/outbound.ts +43 -10
  130. package/packages/irc-core/src/protocol/parser.ts +79 -10
  131. package/packages/irc-core/src/state/connection.ts +13 -0
  132. package/packages/irc-core/src/types.ts +228 -13
  133. package/packages/irc-core/src/ws-framing.ts +5 -4
  134. package/packages/irc-core/tests/bytes.test.ts +89 -0
  135. package/packages/irc-core/tests/certfp.test.ts +117 -0
  136. package/packages/irc-core/tests/commands/cap.test.ts +76 -2
  137. package/packages/irc-core/tests/commands/chanserv.test.ts +166 -0
  138. package/packages/irc-core/tests/commands/chathistory.test.ts +140 -0
  139. package/packages/irc-core/tests/commands/hostserv.test.ts +316 -0
  140. package/packages/irc-core/tests/commands/join.test.ts +78 -1
  141. package/packages/irc-core/tests/commands/names.test.ts +193 -0
  142. package/packages/irc-core/tests/commands/nickserv.test.ts +182 -2
  143. package/packages/irc-core/tests/commands/oper.test.ts +560 -2
  144. package/packages/irc-core/tests/commands/privmsg.test.ts +16 -0
  145. package/packages/irc-core/tests/commands/registration.test.ts +463 -1
  146. package/packages/irc-core/tests/commands/sasl.test.ts +596 -7
  147. package/packages/irc-core/tests/commands/topic.test.ts +137 -2
  148. package/packages/irc-core/tests/commands/unified-account.test.ts +2 -0
  149. package/packages/irc-core/tests/config.test.ts +534 -2
  150. package/packages/irc-core/tests/effects.test.ts +14 -0
  151. package/packages/irc-core/tests/flood-control.test.ts +29 -1
  152. package/packages/irc-core/tests/frame-rate-limit.test.ts +98 -0
  153. package/packages/irc-core/tests/oper-hashing.test.ts +60 -0
  154. package/packages/irc-core/tests/oper-lockout.test.ts +74 -0
  155. package/packages/irc-core/tests/outbound.test.ts +148 -0
  156. package/packages/irc-core/tests/parser.test.ts +287 -5
  157. package/packages/irc-core/tests/persistent-services-store.test.ts +141 -0
  158. package/packages/irc-core/tests/ports.test.ts +99 -7
  159. package/packages/irc-core/tests/services-store.test.ts +376 -14
  160. package/packages/irc-core/tests/ws-framing.test.ts +45 -0
  161. package/packages/irc-server/package.json +1 -1
  162. package/packages/irc-server/src/actor.ts +123 -8
  163. package/packages/irc-server/src/dispatch.ts +1 -0
  164. package/packages/irc-server/src/index.ts +7 -0
  165. package/packages/irc-server/src/redact.ts +159 -0
  166. package/packages/irc-server/src/runtime.ts +14 -0
  167. package/packages/irc-server/src/transport.ts +28 -1
  168. package/packages/irc-server/tests/actor.test.ts +544 -7
  169. package/packages/irc-server/tests/dispatch.test.ts +31 -0
  170. package/packages/irc-server/tests/redact.test.ts +198 -0
  171. package/packages/irc-server/tests/runtime.test.ts +2 -0
  172. package/packages/irc-server/tests/transport.test.ts +66 -0
  173. package/packages/irc-test-support/package.json +1 -1
  174. package/packages/irc-test-support/src/in-memory-harness.ts +4 -0
  175. package/scripts/package.json +1 -1
  176. package/tools/ci-hardening/package.json +2 -2
  177. package/tools/ci-hardening/src/cf-deploy-cli.ts +3 -0
  178. package/tools/ci-hardening/src/cf-deploy.ts +118 -0
  179. package/tools/ci-hardening/src/deploy-hostname.ts +118 -0
  180. package/tools/ci-hardening/src/env-var-drift.ts +192 -0
  181. package/tools/ci-hardening/src/hostname-guard.ts +11 -0
  182. package/tools/ci-hardening/src/index.ts +17 -0
  183. package/tools/ci-hardening/tests/__wrangler_missing__.toml +2 -0
  184. package/tools/ci-hardening/tests/__wrangler_placeholder__.toml +3 -0
  185. package/tools/ci-hardening/tests/__wrangler_real__.toml +3 -0
  186. package/tools/ci-hardening/tests/cf-deploy.test.ts +200 -0
  187. package/tools/ci-hardening/tests/deploy-hostname.test.ts +348 -0
  188. package/tools/ci-hardening/tests/env-var-drift.test.ts +284 -0
  189. package/tools/ci-hardening/vitest.config.ts +5 -1
  190. package/tools/hash-oper-cred.ts +85 -0
  191. package/tools/load-test/package.json +1 -1
  192. package/tools/tcp-ws-forwarder/package.json +1 -1
@@ -1,4 +1,6 @@
1
- import { describe, expect, it } from 'vitest';
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+ import { CERT_FP_PREFIX, certIdentityFromSubject } from '../src/certfp';
3
+ import type { HashedAccountCredential } from '../src/credential-hashing';
2
4
  import {
3
5
  type ChannelLevelOp,
4
6
  type ChannelRegisterResult,
@@ -13,8 +15,13 @@ import {
13
15
  type ServicesAuthResult,
14
16
  type ServicesSnapshot,
15
17
  type ServicesStore,
18
+ VERIFY_NICK_PRECOMPUTED_DUMMY,
19
+ resetVerifyNickTimingSpy,
20
+ setVerifyNickHashedPassword,
16
21
  } from '../src/ports';
17
22
 
23
+ type VerifyHashedPasswordFn = (password: string, entry: HashedAccountCredential) => boolean;
24
+
18
25
  const NOW = 1_700_000_000_000;
19
26
 
20
27
  function store(): InMemoryServicesStore {
@@ -117,6 +124,101 @@ describe('InMemoryServicesStore — NickServ IDENTIFY (verifyNick)', () => {
117
124
  });
118
125
  });
119
126
 
127
+ // ============================================================================
128
+ // verifyNick timing equalisation — the unknown-nick miss must perform the
129
+ // same scrypt work as a wrong-password verify so callers (SASL PLAIN, PASS
130
+ // auth, NickServ IDENTIFY/SETPASS) cannot enumerate registered nicks by
131
+ // measuring latency.
132
+ // ============================================================================
133
+
134
+ describe('InMemoryServicesStore — verifyNick timing equalisation', () => {
135
+ afterEach(() => {
136
+ resetVerifyNickTimingSpy();
137
+ });
138
+
139
+ it('runs exactly one dummy scrypt verify on an unknown nick — no O(1) short-circuit', () => {
140
+ const s = store();
141
+ const spy = vi.fn<VerifyHashedPasswordFn>(() => false);
142
+ setVerifyNickHashedPassword(spy);
143
+ try {
144
+ expect(s.verifyNick('ghost', 'hunter2').ok).toBe(false);
145
+ // Critical: a single dummy verify runs even though the nick is unknown,
146
+ // equalising wall-clock cost with the wrong-password path.
147
+ expect(spy).toHaveBeenCalledTimes(1);
148
+ // The dummy verify is fed the candidate password and runs against the
149
+ // fixed precomputed entry (never a real credential), exactly as a real
150
+ // verify would be — the cost is the point, the result is discarded.
151
+ expect(spy.mock.calls[0]?.[0]).toBe('hunter2');
152
+ expect(spy.mock.calls[0]?.[1]).toBe(VERIFY_NICK_PRECOMPUTED_DUMMY);
153
+ } finally {
154
+ setVerifyNickHashedPassword(null);
155
+ }
156
+ });
157
+
158
+ it('runs exactly one scrypt verify against the stored credential on a wrong password', () => {
159
+ const s = store();
160
+ s.registerNick('alice', 'hunter2', 'alice@example.com');
161
+ const spy = vi.fn<VerifyHashedPasswordFn>(() => false);
162
+ setVerifyNickHashedPassword(spy);
163
+ try {
164
+ expect(s.verifyNick('alice', 'wrong').ok).toBe(false);
165
+ expect(spy).toHaveBeenCalledTimes(1);
166
+ expect(spy.mock.calls[0]?.[0]).toBe('wrong');
167
+ expect(spy.mock.calls[0]?.[1]?.account).toBe('alice');
168
+ } finally {
169
+ setVerifyNickHashedPassword(null);
170
+ }
171
+ });
172
+
173
+ it('costs the same single verify on the unknown-nick and wrong-password paths', () => {
174
+ const s = store();
175
+ s.registerNick('alice', 'hunter2', 'alice@example.com');
176
+ const unknownNick = vi.fn<VerifyHashedPasswordFn>(() => false);
177
+ setVerifyNickHashedPassword(unknownNick);
178
+ try {
179
+ s.verifyNick('ghost', 'hunter2');
180
+ } finally {
181
+ setVerifyNickHashedPassword(null);
182
+ }
183
+ const wrongPassword = vi.fn<VerifyHashedPasswordFn>(() => false);
184
+ setVerifyNickHashedPassword(wrongPassword);
185
+ try {
186
+ s.verifyNick('alice', 'wrong');
187
+ } finally {
188
+ setVerifyNickHashedPassword(null);
189
+ }
190
+ expect(unknownNick).toHaveBeenCalledTimes(1);
191
+ expect(wrongPassword).toHaveBeenCalledTimes(1);
192
+ });
193
+
194
+ it('still returns the identical failure result on the unknown-nick path', () => {
195
+ const s = store();
196
+ const spy = vi.fn<VerifyHashedPasswordFn>(() => false);
197
+ setVerifyNickHashedPassword(spy);
198
+ try {
199
+ expect(s.verifyNick('ghost', 'hunter2')).toEqual<ServicesAuthResult>({
200
+ ok: false,
201
+ reason: 'invalid credentials',
202
+ });
203
+ } finally {
204
+ setVerifyNickHashedPassword(null);
205
+ }
206
+ });
207
+
208
+ it('verifies a correct password through the same seam with no dummy call', () => {
209
+ const s = store();
210
+ s.registerNick('alice', 'hunter2', 'alice@example.com');
211
+ const spy = vi.fn<VerifyHashedPasswordFn>((_pw, entry) => entry.account === 'alice');
212
+ setVerifyNickHashedPassword(spy);
213
+ try {
214
+ expect(s.verifyNick('alice', 'hunter2').ok).toBe(true);
215
+ expect(spy).toHaveBeenCalledTimes(1);
216
+ } finally {
217
+ setVerifyNickHashedPassword(null);
218
+ }
219
+ });
220
+ });
221
+
120
222
  // ============================================================================
121
223
  // NickServ CertFP — hydrate round-trip (legacy snapshot without certSubjects)
122
224
  // ============================================================================
@@ -134,6 +236,8 @@ describe('InMemoryServicesStore — CertFP snapshot hydration', () => {
134
236
  createdAt: NOW,
135
237
  enforce: 'none',
136
238
  certSubjects: undefined as unknown as string[],
239
+ identifyFailures: [],
240
+ pendingIdentifyFailureNotice: 0,
137
241
  credential: { account: 'alice', algorithm: 'scrypt', salt: 's', hash: 'aGVsbG8=' },
138
242
  },
139
243
  ],
@@ -157,7 +261,120 @@ describe('InMemoryServicesStore — CertFP snapshot hydration', () => {
157
261
  });
158
262
 
159
263
  // ============================================================================
160
- // NickServ CertFP SASL EXTERNAL surface (verifyCertFP / addCertFP / removeCertFP)
264
+ // NickServ — failed-IDENTIFY throttle (record / frozen / clear)
265
+ // ============================================================================
266
+
267
+ describe('InMemoryServicesStore — identify-failure throttle', () => {
268
+ const WINDOW = { maxFailures: 3, windowMs: 300_000 };
269
+
270
+ it('records failures and freezes once the threshold is met inside the window', () => {
271
+ const clock = new FakeClock(NOW);
272
+ const s = new InMemoryServicesStore({ clock });
273
+ s.registerNick('alice', 'hunter2', 'alice@example.com');
274
+
275
+ s.recordIdentifyFailure('alice');
276
+ s.recordIdentifyFailure('alice');
277
+ expect(s.isNickIdentifyFrozen('alice', WINDOW)).toBe(false);
278
+ s.recordIdentifyFailure('alice');
279
+ expect(s.isNickIdentifyFrozen('alice', WINDOW)).toBe(true);
280
+ });
281
+
282
+ it('prunes failures that aged out of the window (freeze lifts)', () => {
283
+ const clock = new FakeClock(NOW);
284
+ const s = new InMemoryServicesStore({ clock });
285
+ s.registerNick('alice', 'hunter2', 'alice@example.com');
286
+
287
+ s.recordIdentifyFailure('alice'); // at NOW
288
+ clock.advance(400_000);
289
+ s.recordIdentifyFailure('alice'); // at NOW + 400_000 (first failure is 400s old)
290
+ s.recordIdentifyFailure('alice');
291
+ // Only the two recent failures survive the prune → below threshold.
292
+ expect(s.isNickIdentifyFrozen('alice', WINDOW)).toBe(false);
293
+
294
+ clock.advance(1);
295
+ s.recordIdentifyFailure('alice'); // third failure inside the window
296
+ expect(s.isNickIdentifyFrozen('alice', WINDOW)).toBe(true);
297
+ });
298
+
299
+ it('clearIdentifyFailures returns the queued notice count and resets the throttle', () => {
300
+ const clock = new FakeClock(NOW);
301
+ const s = new InMemoryServicesStore({ clock });
302
+ s.registerNick('alice', 'hunter2', 'alice@example.com');
303
+
304
+ s.recordIdentifyFailure('alice');
305
+ s.recordIdentifyFailure('alice');
306
+ expect(s.clearIdentifyFailures('alice')).toBe(2);
307
+
308
+ // Both the window and the pending notice are reset.
309
+ expect(s.isNickIdentifyFrozen('alice', { maxFailures: 1, windowMs: 300_000 })).toBe(false);
310
+ expect(s.clearIdentifyFailures('alice')).toBe(0);
311
+ });
312
+
313
+ it('is a no-op for an unknown nick (record / frozen / clear)', () => {
314
+ const clock = new FakeClock(NOW);
315
+ const s = new InMemoryServicesStore({ clock });
316
+ s.recordIdentifyFailure('ghost');
317
+ expect(s.isNickIdentifyFrozen('ghost', WINDOW)).toBe(false);
318
+ expect(s.clearIdentifyFailures('ghost')).toBe(0);
319
+ });
320
+
321
+ it('round-trips the throttle state through a snapshot reload', () => {
322
+ const clock = new FakeClock(NOW);
323
+ const original = new InMemoryServicesStore({ clock });
324
+ original.registerNick('alice', 'hunter2', 'alice@example.com');
325
+ original.recordIdentifyFailure('alice');
326
+ clock.advance(1);
327
+ original.recordIdentifyFailure('alice');
328
+
329
+ const reloaded = new InMemoryServicesStore({
330
+ clock: new FakeClock(NOW + 1),
331
+ snapshot: original.snapshot(),
332
+ });
333
+ // A re-identify from a fresh connection (fresh store) still sees the
334
+ // recorded failures and the queued owner notice.
335
+ expect(reloaded.isNickIdentifyFrozen('alice', { maxFailures: 2, windowMs: 300_000 })).toBe(
336
+ true,
337
+ );
338
+ expect(reloaded.clearIdentifyFailures('alice')).toBe(2);
339
+ });
340
+
341
+ it('defaults the throttle fields when a legacy snapshot row omits them', () => {
342
+ const legacySnap: ServicesSnapshot = {
343
+ nicks: [
344
+ {
345
+ nick: 'alice',
346
+ account: 'alice',
347
+ email: 'a@x',
348
+ createdAt: NOW,
349
+ enforce: 'none',
350
+ certSubjects: [],
351
+ identifyFailures: undefined as unknown as number[],
352
+ pendingIdentifyFailureNotice: undefined as unknown as number,
353
+ credential: { account: 'alice', algorithm: 'scrypt', salt: 's', hash: 'aGVsbG8=' },
354
+ },
355
+ ],
356
+ channels: [],
357
+ access: [],
358
+ levels: [],
359
+ vhosts: [],
360
+ pendingVhosts: [],
361
+ memos: [],
362
+ readMarkers: [],
363
+ akills: [],
364
+ jupes: [],
365
+ };
366
+ const s = new InMemoryServicesStore({ clock: new FakeClock(NOW), snapshot: legacySnap });
367
+ expect(s.isNickIdentifyFrozen('alice', { maxFailures: 1, windowMs: 300_000 })).toBe(false);
368
+ expect(s.clearIdentifyFailures('alice')).toBe(0);
369
+ // Mutations after a legacy hydrate behave like a fresh registration.
370
+ s.recordIdentifyFailure('alice');
371
+ expect(s.isNickIdentifyFrozen('alice', { maxFailures: 1, windowMs: 300_000 })).toBe(true);
372
+ });
373
+ });
374
+
375
+ // ============================================================================
376
+ // NickServ CertFP — SASL EXTERNAL surface (verifyCertFP / addCertFP /
377
+ // addCertFingerprint / removeCertFP)
161
378
  // ============================================================================
162
379
 
163
380
  describe('InMemoryServicesStore — NickServ CertFP (verifyCertFP)', () => {
@@ -165,7 +382,7 @@ describe('InMemoryServicesStore — NickServ CertFP (verifyCertFP)', () => {
165
382
  const s = store();
166
383
  s.registerNick('alice', 'hunter2', 'alice@example.com');
167
384
  s.addCertFP('alice', 'CN=alice');
168
- expect(s.verifyCertFP('CN=alice')).toEqual<ServicesAuthResult>({
385
+ expect(s.verifyCertFP(certIdentityFromSubject('CN=alice'))).toEqual<ServicesAuthResult>({
169
386
  ok: true,
170
387
  account: 'alice',
171
388
  });
@@ -175,7 +392,7 @@ describe('InMemoryServicesStore — NickServ CertFP (verifyCertFP)', () => {
175
392
  const s = store();
176
393
  s.registerNick('alice', 'hunter2', 'alice@example.com');
177
394
  s.addCertFP('alice', 'CN=alice');
178
- const out = s.verifyCertFP('CN=evil');
395
+ const out = s.verifyCertFP(certIdentityFromSubject('CN=evil'));
179
396
  expect(out.ok).toBe(false);
180
397
  if (out.ok) return;
181
398
  expect(out.reason.length).toBeGreaterThan(0);
@@ -184,15 +401,15 @@ describe('InMemoryServicesStore — NickServ CertFP (verifyCertFP)', () => {
184
401
  it('returns ok:false when no nick has any cert subject registered', () => {
185
402
  const s = store();
186
403
  s.registerNick('alice', 'hunter2', 'alice@example.com');
187
- expect(s.verifyCertFP('CN=alice').ok).toBe(false);
404
+ expect(s.verifyCertFP(certIdentityFromSubject('CN=alice')).ok).toBe(false);
188
405
  });
189
406
 
190
- it('matches the subject case-sensitively (byte-exact)', () => {
407
+ it('matches the subject value case-sensitively (values are NOT folded)', () => {
191
408
  const s = store();
192
409
  s.registerNick('alice', 'hunter2', 'alice@example.com');
193
410
  s.addCertFP('alice', 'CN=Alice');
194
- expect(s.verifyCertFP('CN=alice').ok).toBe(false);
195
- expect(s.verifyCertFP('CN=Alice').ok).toBe(true);
411
+ expect(s.verifyCertFP(certIdentityFromSubject('CN=alice')).ok).toBe(false);
412
+ expect(s.verifyCertFP(certIdentityFromSubject('CN=Alice')).ok).toBe(true);
196
413
  });
197
414
 
198
415
  it('resolves the account that owns the subject (multiple accounts)', () => {
@@ -200,7 +417,7 @@ describe('InMemoryServicesStore — NickServ CertFP (verifyCertFP)', () => {
200
417
  s.registerNick('alice', 'pw', 'a@e');
201
418
  s.registerNick('bob', 'pw', 'b@e');
202
419
  s.addCertFP('bob', 'CN=bob');
203
- expect(s.verifyCertFP('CN=bob')).toEqual<ServicesAuthResult>({
420
+ expect(s.verifyCertFP(certIdentityFromSubject('CN=bob'))).toEqual<ServicesAuthResult>({
204
421
  ok: true,
205
422
  account: 'bob',
206
423
  });
@@ -227,7 +444,124 @@ describe('InMemoryServicesStore — NickServ CertFP (verifyCertFP)', () => {
227
444
  });
228
445
  });
229
446
 
230
- describe('InMemoryServicesStore — NickServ CertFP mutators (addCertFP / removeCertFP)', () => {
447
+ describe('InMemoryServicesStore — NickServ CertFP canonical-DN verify', () => {
448
+ it('verifies a semantically-equal DN spelling (whitespace + ordering differ)', () => {
449
+ const s = store();
450
+ s.registerNick('alice', 'hunter2', 'alice@example.com');
451
+ s.addCertFP('alice', 'CN=alice , O = Example Inc');
452
+ const presented = certIdentityFromSubject('O=Example Inc,CN=alice');
453
+ expect(s.verifyCertFP(presented)).toEqual<ServicesAuthResult>({
454
+ ok: true,
455
+ account: 'alice',
456
+ });
457
+ });
458
+
459
+ it('migrates a legacy verbatim binding to the canonical form on first use', () => {
460
+ const s = store();
461
+ s.registerNick('alice', 'hunter2', 'alice@example.com');
462
+ s.addCertFP('alice', 'CN=alice , O=Example Inc');
463
+ s.verifyCertFP(certIdentityFromSubject('O=Example Inc,CN=alice'));
464
+ expect(s.getNick('alice')?.certSubjects).toEqual(['cn=alice,o=Example Inc']);
465
+ });
466
+
467
+ it('leaves an already-canonical binding untouched on verify', () => {
468
+ const s = store();
469
+ s.registerNick('alice', 'hunter2', 'alice@example.com');
470
+ s.addCertFP('alice', 'cn=alice');
471
+ s.verifyCertFP(certIdentityFromSubject('CN=alice'));
472
+ expect(s.getNick('alice')?.certSubjects).toEqual(['cn=alice']);
473
+ });
474
+
475
+ it('rejects when the presented DN differs beyond spacing/ordering/type case', () => {
476
+ const s = store();
477
+ s.registerNick('alice', 'hunter2', 'alice@example.com');
478
+ s.addCertFP('alice', 'CN=alice,O=Example Inc');
479
+ expect(s.verifyCertFP(certIdentityFromSubject('CN=alice,O=Evil Inc')).ok).toBe(false);
480
+ });
481
+ });
482
+
483
+ describe('InMemoryServicesStore — NickServ CertFP fingerprint verify', () => {
484
+ it('verifies via a fingerprint binding alone (subject spelling irrelevant)', () => {
485
+ const s = store();
486
+ s.registerNick('alice', 'hunter2', 'alice@example.com');
487
+ s.addCertFingerprint('alice', 'aa112233445566778899aabbccddeeff');
488
+ expect(
489
+ s.verifyCertFP(certIdentityFromSubject('CN=whatever', 'aa112233445566778899aabbccddeeff')),
490
+ ).toEqual<ServicesAuthResult>({ ok: true, account: 'alice' });
491
+ });
492
+
493
+ it('prefers the fingerprint binding over a competing DN binding', () => {
494
+ const s = store();
495
+ s.registerNick('alice', 'pw', 'a@e');
496
+ s.registerNick('bob', 'pw', 'b@e');
497
+ // bob is bound by the cert subject DN; alice is bound by the cert DER
498
+ // fingerprint. Both would match the presented cert — the fingerprint
499
+ // binding must win.
500
+ s.addCertFP('bob', 'CN=shared,O=TwoCerts');
501
+ s.addCertFingerprint('alice', 'aa112233445566778899aabbccddeeff');
502
+ expect(
503
+ s.verifyCertFP(
504
+ certIdentityFromSubject('CN=shared,O=TwoCerts', 'aa112233445566778899aabbccddeeff'),
505
+ ),
506
+ ).toEqual<ServicesAuthResult>({ ok: true, account: 'alice' });
507
+ });
508
+
509
+ it('normalises fingerprint spelling (colons, case) before comparing', () => {
510
+ const s = store();
511
+ s.registerNick('alice', 'hunter2', 'alice@example.com');
512
+ s.addCertFingerprint('alice', 'AA:11:22:33');
513
+ const out = s.verifyCertFP(certIdentityFromSubject('CN=alice', 'aa112233'));
514
+ expect(out.ok).toBe(true);
515
+ });
516
+
517
+ it('pins the platform fingerprint onto a DN-bound account on first verify', () => {
518
+ const s = store();
519
+ s.registerNick('alice', 'hunter2', 'alice@example.com');
520
+ s.addCertFP('alice', 'CN=alice');
521
+ s.verifyCertFP(certIdentityFromSubject('CN=alice', 'aa112233'));
522
+ expect(s.getNick('alice')?.certSubjects).toContain(`${CERT_FP_PREFIX}aa112233`);
523
+ });
524
+
525
+ it('migrates a legacy binding and pins the fingerprint in one first use', () => {
526
+ const s = store();
527
+ s.registerNick('alice', 'hunter2', 'alice@example.com');
528
+ s.addCertFP('alice', 'CN=alice , O=Example Inc');
529
+ s.verifyCertFP(certIdentityFromSubject('O=Example Inc,CN=alice', 'aa112233'));
530
+ expect(s.getNick('alice')?.certSubjects).toEqual([
531
+ 'cn=alice,o=Example Inc',
532
+ `${CERT_FP_PREFIX}aa112233`,
533
+ ]);
534
+ });
535
+
536
+ it('does not pin a fingerprint twice on repeated verifies', () => {
537
+ const s = store();
538
+ s.registerNick('alice', 'hunter2', 'alice@example.com');
539
+ s.addCertFP('alice', 'CN=alice');
540
+ s.verifyCertFP(certIdentityFromSubject('CN=alice', 'aa112233'));
541
+ s.verifyCertFP(certIdentityFromSubject('CN=alice', 'aa112233'));
542
+ const fps = s.getNick('alice')?.certSubjects.filter((e) => e.startsWith(CERT_FP_PREFIX));
543
+ expect(fps).toEqual([`${CERT_FP_PREFIX}aa112233`]);
544
+ });
545
+
546
+ it('rejects an unknown fingerprint and unknown DN (unknown cert)', () => {
547
+ const s = store();
548
+ s.registerNick('alice', 'hunter2', 'alice@example.com');
549
+ s.addCertFP('alice', 'CN=alice');
550
+ s.addCertFingerprint('alice', 'aa112233');
551
+ expect(s.verifyCertFP(certIdentityFromSubject('CN=evil', 'ffffffffffffffff')).ok).toBe(false);
552
+ });
553
+
554
+ it('falls back to the DN arm when the fingerprint matches nothing', () => {
555
+ const s = store();
556
+ s.registerNick('alice', 'hunter2', 'alice@example.com');
557
+ s.addCertFP('alice', 'CN=alice');
558
+ // Platform-supplied fingerprint is unknown to the store; the DN binding
559
+ // still authorises the cert.
560
+ expect(s.verifyCertFP(certIdentityFromSubject('CN=alice', 'ffffffffffffffff')).ok).toBe(true);
561
+ });
562
+ });
563
+
564
+ describe('InMemoryServicesStore — NickServ CertFP mutators (addCertFP / addCertFingerprint / removeCertFP)', () => {
231
565
  it('addCertFP attaches a subject to a registered nick', () => {
232
566
  const s = store();
233
567
  s.registerNick('alice', 'hunter2', 'alice@example.com');
@@ -238,7 +572,7 @@ describe('InMemoryServicesStore — NickServ CertFP mutators (addCertFP / remove
238
572
  it('addCertFP is a no-op on an unregistered nick', () => {
239
573
  const s = store();
240
574
  s.addCertFP('ghost', 'CN=ghost');
241
- expect(s.verifyCertFP('CN=ghost').ok).toBe(false);
575
+ expect(s.verifyCertFP(certIdentityFromSubject('CN=ghost')).ok).toBe(false);
242
576
  });
243
577
 
244
578
  it('addCertFP does not duplicate an already-attached subject', () => {
@@ -253,19 +587,47 @@ describe('InMemoryServicesStore — NickServ CertFP mutators (addCertFP / remove
253
587
  const s = store();
254
588
  s.registerNick('Alice', 'hunter2', 'alice@example.com');
255
589
  s.addCertFP('alice', 'CN=alice');
256
- expect(s.verifyCertFP('CN=alice')).toEqual<ServicesAuthResult>({
590
+ expect(s.verifyCertFP(certIdentityFromSubject('CN=alice'))).toEqual<ServicesAuthResult>({
257
591
  ok: true,
258
592
  account: 'Alice',
259
593
  });
260
594
  });
261
595
 
596
+ it('addCertFingerprint attaches a fingerprint binding to a registered nick', () => {
597
+ const s = store();
598
+ s.registerNick('alice', 'hunter2', 'alice@example.com');
599
+ expect(s.addCertFingerprint('alice', 'AA:11')).toBe(true);
600
+ expect(s.getNick('alice')?.certSubjects).toEqual([`${CERT_FP_PREFIX}aa11`]);
601
+ });
602
+
603
+ it('addCertFingerprint does not duplicate an equivalent fingerprint', () => {
604
+ const s = store();
605
+ s.registerNick('alice', 'hunter2', 'alice@example.com');
606
+ s.addCertFingerprint('alice', 'aa11');
607
+ s.addCertFingerprint('alice', 'AA:11');
608
+ expect(s.getNick('alice')?.certSubjects).toEqual([`${CERT_FP_PREFIX}aa11`]);
609
+ });
610
+
611
+ it('addCertFingerprint is a no-op on an unregistered nick', () => {
612
+ const s = store();
613
+ expect(s.addCertFingerprint('ghost', 'aa11')).toBe(false);
614
+ });
615
+
262
616
  it('removeCertFP detaches a subject from a registered nick', () => {
263
617
  const s = store();
264
618
  s.registerNick('alice', 'hunter2', 'alice@example.com');
265
619
  s.addCertFP('alice', 'CN=alice');
266
620
  expect(s.removeCertFP('alice', 'CN=alice')).toBe(true);
267
621
  expect(s.getNick('alice')?.certSubjects).toEqual([]);
268
- expect(s.verifyCertFP('CN=alice').ok).toBe(false);
622
+ expect(s.verifyCertFP(certIdentityFromSubject('CN=alice')).ok).toBe(false);
623
+ });
624
+
625
+ it('removeCertFP detaches a stored fingerprint binding entry', () => {
626
+ const s = store();
627
+ s.registerNick('alice', 'hunter2', 'alice@example.com');
628
+ s.addCertFingerprint('alice', 'aa11');
629
+ expect(s.removeCertFP('alice', `${CERT_FP_PREFIX}aa11`)).toBe(true);
630
+ expect(s.getNick('alice')?.certSubjects).toEqual([]);
269
631
  });
270
632
 
271
633
  it('removeCertFP returns false when the subject is not attached', () => {
@@ -292,7 +654,7 @@ describe('InMemoryServicesStore — NickServ CertFP mutators (addCertFP / remove
292
654
  s.registerNick('alice', 'hunter2', 'alice@example.com');
293
655
  s.addCertFP('alice', 'CN=alice');
294
656
  s.dropNick('alice');
295
- expect(s.verifyCertFP('CN=alice').ok).toBe(false);
657
+ expect(s.verifyCertFP(certIdentityFromSubject('CN=alice')).ok).toBe(false);
296
658
  });
297
659
  });
298
660
 
@@ -5,6 +5,7 @@ import {
5
5
  frameToLines,
6
6
  isWithinWsByteBudget,
7
7
  sanitizeForTextMode,
8
+ splitFrameLines,
8
9
  stripTrailingCrLf,
9
10
  wsFrameModeFor,
10
11
  } from '../src/ws-framing';
@@ -147,6 +148,33 @@ describe('frameToLines — mode coverage', () => {
147
148
  }
148
149
  });
149
150
 
151
+ // ============================================================================
152
+ // frameToLines — bare CR pass-through (rejection is the parser's job)
153
+ // ============================================================================
154
+
155
+ describe('framing passes a bare CR through to the parser', () => {
156
+ // Framing only recognizes complete CR-LF / LF terminators. A bare CR has
157
+ // no LF partner, so no framing mode treats it as a boundary: the line is
158
+ // emitted intact and the shared parser is the single place that rejects
159
+ // it (loudly, with a parse error) rather than silently dropping frames.
160
+
161
+ it('spec-text keeps a bare mid-line CR inside the single line', () => {
162
+ expect(frameToLines('PRIVMSG #foo :a\rb', 'spec-text')).toEqual(['PRIVMSG #foo :a\rb']);
163
+ });
164
+
165
+ it('spec-binary keeps a bare mid-line CR inside the single line', () => {
166
+ expect(frameToLines('PRIVMSG #foo :a\rb', 'spec-binary')).toEqual(['PRIVMSG #foo :a\rb']);
167
+ });
168
+
169
+ it('legacy keeps a bare CR (no CR-LF boundary to split on)', () => {
170
+ expect(splitFrameLines('PRIVMSG #foo :a\rb')).toEqual(['PRIVMSG #foo :a\rb']);
171
+ });
172
+
173
+ it('stripTrailingCrLf leaves a bare mid-line CR untouched', () => {
174
+ expect(stripTrailingCrLf('PRIVMSG #foo :a\rb')).toBe('PRIVMSG #foo :a\rb');
175
+ });
176
+ });
177
+
150
178
  // ============================================================================
151
179
  // isWithinWsByteBudget — the IRCv3 WS message byte cap
152
180
  // ============================================================================
@@ -167,6 +195,23 @@ describe('isWithinWsByteBudget', () => {
167
195
  it('accepts a short frame', () => {
168
196
  expect(isWithinWsByteBudget('NICK bob')).toBe(true);
169
197
  });
198
+
199
+ it('rejects a multi-byte frame that fits 510 UTF-16 units but exceeds 510 bytes', () => {
200
+ // 300 'é' = 300 code units (inside the unit budget) but 600 UTF-8 bytes.
201
+ const frame = 'é'.repeat(300);
202
+ expect(frame.length).toBeLessThanOrEqual(MAX_WS_MESSAGE_BYTES);
203
+ expect(isWithinWsByteBudget(frame)).toBe(false);
204
+ });
205
+
206
+ it('accepts a multi-byte frame whose UTF-8 length is exactly 510 bytes', () => {
207
+ // 255 'é' = 255 code units, exactly 510 UTF-8 bytes. Byte accounting
208
+ // must not over-reject.
209
+ expect(isWithinWsByteBudget('é'.repeat(255))).toBe(true);
210
+ });
211
+
212
+ it('rejects a 511-é frame (1022 bytes) that once passed the unit cap', () => {
213
+ expect(isWithinWsByteBudget('é'.repeat(511))).toBe(false);
214
+ });
170
215
  });
171
216
 
172
217
  // ============================================================================
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverless-ircd/irc-server",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "private": true,
5
5
  "description": "Orchestration layer: IrcRuntime port, dispatch interpreter, ConnectionActor",
6
6
  "license": "BSD-3-Clause",