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
@@ -7,6 +7,12 @@
7
7
  */
8
8
 
9
9
  import { caseFold } from './case-fold.js';
10
+ import {
11
+ CERT_FP_PREFIX,
12
+ type CertIdentity,
13
+ canonicalizeCertSubject,
14
+ certFingerprintId,
15
+ } from './certfp.js';
10
16
  import {
11
17
  type HashedAccountCredential,
12
18
  hashAccountCredential,
@@ -15,6 +21,8 @@ import {
15
21
  import type { ChannelState } from './state/channel.js';
16
22
  import type { ConnId, ConnectionState } from './state/connection.js';
17
23
 
24
+ export type { CertIdentity } from './certfp.js';
25
+
18
26
  /** Read-only wall clock, epoch milliseconds. */
19
27
  export interface Clock {
20
28
  now(): number;
@@ -132,6 +140,31 @@ export class SequentialIdFactory implements IdFactory {
132
140
  }
133
141
  }
134
142
 
143
+ /**
144
+ * Constant-time string equality.
145
+ *
146
+ * Walks both inputs position by position (UTF-16 code units, a bijection
147
+ * with UTF-8 bytes for equality purposes) out to the longer input's length,
148
+ * XOR-accumulating every difference. The amount of work — and therefore the
149
+ * wall-clock profile — does not depend on where the first mismatch sits, so
150
+ * a remote timing attacker cannot recover a matching-prefix length the way a
151
+ * short-circuiting `===` would reveal. `charCodeAt` past the end yields
152
+ * `NaN`, which XOR-folds to 0, so a length mismatch is caught purely by the
153
+ * length-difference seed in `diff`.
154
+ *
155
+ * Deliberately free of `node:crypto` (pure arithmetic) so the same code
156
+ * runs unmodified on Node and Cloudflare Workers. Used for secret
157
+ * comparisons such as the server-password gate at registration.
158
+ */
159
+ export function constantTimeEquals(a: string, b: string): boolean {
160
+ const maxLen = Math.max(a.length, b.length);
161
+ let diff = a.length ^ b.length;
162
+ for (let i = 0; i < maxLen; i++) {
163
+ diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
164
+ }
165
+ return diff === 0;
166
+ }
167
+
135
168
  /**
136
169
  * Port supplying the server's Message-of-the-Day text.
137
170
  *
@@ -187,9 +220,13 @@ export const EmptyMotdProvider: MotdProvider = new StaticMotdProvider([]);
187
220
  *
188
221
  * At the edge (CF API Shield mTLS, AWS API Gateway custom-domain mTLS) the
189
222
  * platform terminates TLS, validates the client certificate against the
190
- * configured trust store, and surfaces the certificate subject to the
223
+ * configured trust store, and surfaces the certificate identity to the
191
224
  * Worker/Lambda. The adapter captures this at connection-admission time
192
- * and stores it keyed by connection ID.
225
+ * and stores it keyed by connection ID as a {@link CertIdentity}: the raw
226
+ * subject string, its canonical DN, and — where the platform provides one
227
+ * (Cloudflare: `cf.tlsClientAuth.certFingerprintSHA256`) — the SHA-256
228
+ * fingerprint of the certificate DER, which is the preferred account
229
+ * binding. See `docs/SASL-EXTERNAL.md`.
193
230
  *
194
231
  * The method is **synchronous** — adapters pre-populate the identity at
195
232
  * admission time (before the first reducer runs), so the SASL EXTERNAL
@@ -199,26 +236,26 @@ export const EmptyMotdProvider: MotdProvider = new StaticMotdProvider([]);
199
236
  */
200
237
  export interface MtlsIdentityProvider {
201
238
  /**
202
- * @returns the verified client-cert subject for `connId`, or `undefined`
239
+ * @returns the verified client-cert identity for `connId`, or `undefined`
203
240
  * when no trusted certificate was presented.
204
241
  */
205
- getIdentity(connId: string): string | undefined;
242
+ getIdentity(connId: string): CertIdentity | undefined;
206
243
  }
207
244
 
208
245
  /**
209
246
  * Reference in-memory {@link MtlsIdentityProvider} backed by a static map
210
- * of connection IDs to certificate subjects. Useful for tests and the
247
+ * of connection IDs to certificate identities. Useful for tests and the
211
248
  * local-cli adapter; cloud adapters replace this with a lookup keyed by
212
249
  * the platform's connection identifier.
213
250
  */
214
251
  export class InMemoryMtlsIdentityProvider implements MtlsIdentityProvider {
215
- private readonly identities: ReadonlyMap<string, string>;
252
+ private readonly identities: ReadonlyMap<string, CertIdentity>;
216
253
 
217
- constructor(entries: ReadonlyArray<{ connId: string; identity: string }>) {
254
+ constructor(entries: ReadonlyArray<{ connId: string; identity: CertIdentity }>) {
218
255
  this.identities = new Map(entries.map((e) => [e.connId, e.identity]));
219
256
  }
220
257
 
221
- getIdentity(connId: string): string | undefined {
258
+ getIdentity(connId: string): CertIdentity | undefined {
222
259
  return this.identities.get(connId);
223
260
  }
224
261
  }
@@ -638,12 +675,21 @@ export type NickEnforcePolicy = 'none' | 'ghost' | 'kill';
638
675
  * the returned `nick` / `account` preserve the original spelling so the
639
676
  * wire renders the user's preferred casing.
640
677
  *
641
- * `certSubjects` is the list of mTLS client-certificate subjects (e.g.
642
- * `CN=alice`) attached to the account via {@link ServicesStore.addCertFP}.
643
- * SASL EXTERNAL resolves the verified edge subject against this list
644
- * (byte-exact comparison; subjects are NOT case-folded). Defaults to an
645
- * empty array on a fresh registration; an upcoming NickServ `CERT` command
646
- * surfaces the mutators to the user (Atheme parity).
678
+ * `certSubjects` is the list of mTLS client-certificate bindings attached
679
+ * to the account via {@link ServicesStore.addCertFP} /
680
+ * {@link ServicesStore.addCertFingerprint}. An entry is either a DER
681
+ * SHA-256 fingerprint binding (`fp:<hex>`, the preferred identifier where
682
+ * the platform surfaces a fingerprint) or a subject DN. SASL EXTERNAL
683
+ * resolves the verified edge identity against this list via
684
+ * {@link ServicesStore.verifyCertFP}: the fingerprint arm matches
685
+ * byte-exactly, DN entries compare on the canonical DN (values
686
+ * case-sensitive), and legacy verbatim subject spellings are rewritten to
687
+ * their canonical form on first use. Defaults to an empty array on a
688
+ * fresh registration; an upcoming NickServ `CERT` command (Atheme parity)
689
+ * will surface the mutators to the user. Until then bindings are attached
690
+ * out-of-band via {@link ServicesStore.addCertFP} /
691
+ * {@link ServicesStore.addCertFingerprint} (seed tooling or direct
692
+ * backend access) — the seam is the store API, not the wire.
647
693
  */
648
694
  export interface RegisteredNick {
649
695
  /** Display spelling stored at registration. */
@@ -657,9 +703,11 @@ export interface RegisteredNick {
657
703
  /** Per-account nick-enforcement policy. */
658
704
  enforce: NickEnforcePolicy;
659
705
  /**
660
- * Client-certificate subjects attached to this account for SASL EXTERNAL
661
- * (CertFP). Byte-exact comparisons; never case-folded. Empty on a fresh
662
- * registration; populated via {@link ServicesStore.addCertFP}.
706
+ * Client-certificate bindings attached to this account for SASL EXTERNAL
707
+ * (CertFP): `fp:<hex>` DER SHA-256 fingerprint bindings and/or subject
708
+ * DNs (compared via their canonical form; values never case-folded).
709
+ * Empty on a fresh registration; populated via
710
+ * {@link ServicesStore.addCertFP} / {@link ServicesStore.addCertFingerprint}.
663
711
  */
664
712
  certSubjects: string[];
665
713
  }
@@ -835,6 +883,11 @@ export interface ServicesStore {
835
883
  * `{ ok: true, account }` on a match (the canonical spelling), or
836
884
  * `{ ok: false, reason }` when the nick is unknown or the password is
837
885
  * wrong.
886
+ *
887
+ * Timing contract: implementations MUST cost the unknown-nick and
888
+ * wrong-password paths equally (one scrypt verify each — see
889
+ * {@link VERIFY_NICK_PRECOMPUTED_DUMMY}) so latency never reveals
890
+ * whether a nick is registered.
838
891
  */
839
892
  verifyNick(nick: string, password: string): ServicesAuthResult;
840
893
  /**
@@ -871,30 +924,90 @@ export interface ServicesStore {
871
924
  getNick(nick: string): RegisteredNick | undefined;
872
925
 
873
926
  /**
874
- * Verifies a client-certificate subject (CertFP) for SASL EXTERNAL.
875
- * Returns `{ ok: true, account }` for the account whose
876
- * {@link RegisteredNick.certSubjects} contains `subject` (byte-exact,
877
- * case-sensitive comparison cert subjects are NOT folded), or
878
- * `{ ok: false, reason }` when no registered nick carries `subject`.
927
+ * Records a failed `IDENTIFY` attempt against `nick`: appends the
928
+ * current clock timestamp to the sliding-window failure list and bumps
929
+ * the pending owner-notice counter. No-op when the nick is not
930
+ * registered. Called by the NickServ IDENTIFY reducer AFTER a failed
931
+ * `verifyNick` (frozen rejections never reach this method they are
932
+ * short-circuited before scrypt runs).
933
+ */
934
+ recordIdentifyFailure(nick: string): void;
935
+ /**
936
+ * Returns `true` iff `nick` is identify-frozen: at least
937
+ * `opts.maxFailures` failed identifies were recorded within the last
938
+ * `opts.windowMs` milliseconds. Prunes expired timestamps from the
939
+ * stored list in place (the sliding window), so the freeze lifts once
940
+ * the failures age out of the window. Returns `false` for an unknown
941
+ * nick. Called BEFORE `verifyNick` so a frozen attempt never runs
942
+ * scrypt.
943
+ */
944
+ isNickIdentifyFrozen(nick: string, opts: { maxFailures: number; windowMs: number }): boolean;
945
+ /**
946
+ * Clears the failed-identify state for `nick` (the sliding-window
947
+ * timestamps and the pending owner-notice counter) and returns the
948
+ * number of failed identifies that were queued for the owner NOTICE
949
+ * (`0` when none were pending, including for an unknown nick). Called
950
+ * by the NickServ IDENTIFY reducer on a SUCCESSFUL `verifyNick` — a
951
+ * successful login both resets the freeze counter and delivers the
952
+ * queued owner notice.
953
+ */
954
+ clearIdentifyFailures(nick: string): number;
955
+
956
+ /**
957
+ * Verifies a client-certificate identity (CertFP) for SASL EXTERNAL.
958
+ *
959
+ * Matching arms, in preference order:
960
+ * 1. **Fingerprint (primary):** when `identity.fingerprint` is present
961
+ * (Cloudflare surfaces the DER SHA-256), the normalised
962
+ * `fp:<hex>` form is matched byte-exactly against the account's
963
+ * {@link RegisteredNick.certSubjects} entries.
964
+ * 2. **Canonical DN (fallback):** each stored DN entry is canonicalised
965
+ * (lowercased attribute types, sorted RDNs/AVAs, collapsed
966
+ * whitespace — values stay case-sensitive) and compared to
967
+ * `identity.canonicalDn`. This is the only arm available on
968
+ * platforms that surface just the subject DN (AWS API Gateway).
969
+ *
970
+ * First-use migration: a stored DN entry kept in a legacy verbatim
971
+ * spelling is rewritten to its canonical form, and — when the platform
972
+ * supplied a fingerprint — the `fp:<hex>` binding is pinned alongside
973
+ * it, so subsequent verifies hit the fingerprint arm. Both rewrites
974
+ * notify {@link onCertBindingsChanged} (write-behind subclasses
975
+ * re-persist the nick row).
879
976
  *
880
977
  * The edge (CF API Shield / AWS APIGW mTLS) still resolves the verified
881
- * subject via {@link MtlsIdentityProvider}; only the subject→account
978
+ * identity via {@link MtlsIdentityProvider}; only the identity→account
882
979
  * lookup lives here.
980
+ *
981
+ * @returns `{ ok: true, account }` on a match, or `{ ok: false, reason }`
982
+ * when no registered nick carries a binding for the certificate.
883
983
  */
884
- verifyCertFP(subject: string): ServicesAuthResult;
984
+ verifyCertFP(identity: CertIdentity): ServicesAuthResult;
885
985
  /**
886
- * Attaches a client-certificate subject to `nick` for SASL EXTERNAL.
986
+ * Attaches a client-certificate subject DN to `nick` for SASL EXTERNAL.
887
987
  * No-op (and returns `false`) when the nick is not registered.
888
988
  * Byte-exact: a duplicate subject (case-sensitive) is a no-op (idempotent
889
- * returns `true`). Future NickServ `CERT ADD` will surface this.
890
- * Returns `true` when the subject was newly attached (or already
989
+ * returns `true`). Canonicalisation happens at verify time (with the
990
+ * first-use migration above). Future NickServ `CERT ADD` will surface
991
+ * this. Returns `true` when the subject was newly attached (or already
891
992
  * present), `false` when the nick is unknown.
892
993
  */
893
994
  addCertFP(nick: string, subject: string): boolean;
894
995
  /**
895
- * Detaches a client-certificate subject from `nick`. Returns `true` when
896
- * a subject was removed, `false` when the nick is unknown or the subject
897
- * was not attached. Future NickServ `CERT DEL` will surface this.
996
+ * Attaches a DER SHA-256 fingerprint binding (`fp:<hex>` the primary
997
+ * CertFP identifier where the platform surfaces a fingerprint) to
998
+ * `nick`. The fingerprint is normalised (lowercased, colon-free) before
999
+ * storing, so `AA:11` and `aa11` are the same binding. Idempotent.
1000
+ * Returns `true` when the binding was newly attached (or already
1001
+ * present), `false` when the nick is unknown.
1002
+ */
1003
+ addCertFingerprint(nick: string, fingerprint: string): boolean;
1004
+ /**
1005
+ * Detaches a client-certificate binding from `nick`. `subject` matches
1006
+ * the stored entry byte-exactly — pass either a stored DN entry or a
1007
+ * stored `fp:<hex>` entry as read from {@link RegisteredNick.certSubjects}.
1008
+ * Returns `true` when an entry was removed, `false` when the nick is
1009
+ * unknown or the entry was not attached. Future NickServ `CERT DEL` will
1010
+ * surface this.
898
1011
  */
899
1012
  removeCertFP(nick: string, subject: string): boolean;
900
1013
 
@@ -1122,6 +1235,29 @@ export interface ServicesStore {
1122
1235
  listJupes(): ReadonlyArray<JupeEntry>;
1123
1236
  }
1124
1237
 
1238
+ /**
1239
+ * Per-account failed-IDENTIFY throttle state persisted on the nick row.
1240
+ *
1241
+ * Shape decision: a small append-only array of recent failure timestamps
1242
+ * (`identifyFailures`, epoch-ms) plus a pending owner-notice counter. The
1243
+ * timestamp array makes the sliding-window freeze test trivially correct
1244
+ * (prune entries older than the configured window, then compare the
1245
+ * surviving count against the threshold) without a separate "window
1246
+ * start" bookkeeping field, and stays small in practice: once the
1247
+ * threshold is reached the account is frozen (no further failures are
1248
+ * recorded), so the array holds at most threshold + a handful of entries
1249
+ * at any time.
1250
+ */
1251
+ export interface IdentifyThrottleState {
1252
+ /** Epoch-ms timestamps of recent failed IDENTIFY attempts. */
1253
+ identifyFailures: number[];
1254
+ /**
1255
+ * Failed-identify count queued for the owner NOTICE delivered on the
1256
+ * next successful identify. Reset to 0 once the notice is delivered.
1257
+ */
1258
+ pendingIdentifyFailureNotice: number;
1259
+ }
1260
+
1125
1261
  /**
1126
1262
  * Snapshot row for a NickServ registration, persisted by adapter backends
1127
1263
  * (D1 / DynamoDB) and round-tripped through {@link ServicesSnapshot}.
@@ -1139,9 +1275,13 @@ export interface ServicesStore {
1139
1275
  * The credential never leaves the store via
1140
1276
  * {@link InMemoryServicesStore.getNick} (which returns a defensive copy
1141
1277
  * without it), but it MUST be present in the snapshot so the
1142
- * `verifyNick` round-trip keeps working across a reload.
1278
+ * `verifyNick` round-trip keeps working across a reload. The same holds
1279
+ * for the {@link IdentifyThrottleState} fields: the failed-identify
1280
+ * counter survives connections and deploys so a re-identify from a fresh
1281
+ * connection still sees the freeze.
1143
1282
  */
1144
- export type ServicesNickRow = RegisteredNick & { credential: HashedAccountCredential };
1283
+ export type ServicesNickRow = RegisteredNick &
1284
+ IdentifyThrottleState & { credential: HashedAccountCredential };
1145
1285
 
1146
1286
  /**
1147
1287
  * Snapshot row for a ChanServ access entry: `(channel, account, level)`.
@@ -1279,6 +1419,77 @@ type StoredAkill = AkillEntry & { maskKey: string };
1279
1419
  */
1280
1420
  type StoredJupe = JupeEntry & { nameKey: string };
1281
1421
 
1422
+ // ============================================================================
1423
+ // verifyNick timing equalisation — unknown-nick lookups must cost the same
1424
+ // scrypt work as a wrong-password verify so latency cannot enumerate
1425
+ // registered nicks. Mirrors the OPER_PRECOMPUTED_DUMMY_HASHED pattern in
1426
+ // commands/oper.ts (and the PASS-auth dummy verify in commands/account-auth.ts).
1427
+ // ============================================================================
1428
+
1429
+ /**
1430
+ * Fixed salt used to bake the precomputed dummy credential below. 16 bytes
1431
+ * of `0x42` so the resulting scrypt derivation is deterministic across Node
1432
+ * versions and test runs (the salt is never used to verify real traffic —
1433
+ * only the dummy entry).
1434
+ */
1435
+ const VERIFY_NICK_FIXTURE_SALT = new Uint8Array(16).fill(0x42);
1436
+
1437
+ /**
1438
+ * Nick baked into the precomputed dummy credential. Contains a NUL so it can
1439
+ * never be a registered nick (registration rejects control characters) and
1440
+ * the dummy verify never collides with a real credential.
1441
+ */
1442
+ const VERIFY_NICK_DUMMY_NICK = '\u0000nick-verify-timing-dummy';
1443
+
1444
+ /**
1445
+ * Password baked into the precomputed dummy credential. Contains a NUL so
1446
+ * the dummy verify always fails — its purpose is to spend the same scrypt
1447
+ * work as the known-nick-wrong-password path, NOT to authenticate.
1448
+ */
1449
+ const VERIFY_NICK_DUMMY_PASSWORD = '\u0000nick-verify-timing-dummy';
1450
+
1451
+ /**
1452
+ * Fixed random scrypt digest (precomputed once at module load from the
1453
+ * NUL-prefixed dummy pair above — never derived from any real credential)
1454
+ * used to equalise timing on the unknown-nick path of
1455
+ * {@link InMemoryServicesStore.verifyNick}. Every caller of `verifyNick`
1456
+ * (SASL PLAIN, PASS-based account auth, NickServ IDENTIFY/SETPASS) then
1457
+ * costs exactly one scrypt verify whether the nick is known or not, so a
1458
+ * remote attacker measuring latency cannot distinguish "nick exists, wrong
1459
+ * password" from "nick unknown".
1460
+ */
1461
+ export const VERIFY_NICK_PRECOMPUTED_DUMMY: HashedAccountCredential = hashAccountCredential(
1462
+ VERIFY_NICK_DUMMY_NICK,
1463
+ VERIFY_NICK_DUMMY_PASSWORD,
1464
+ { salt: VERIFY_NICK_FIXTURE_SALT },
1465
+ );
1466
+
1467
+ /**
1468
+ * Indirection over {@link verifyHashedPassword} so tests can swap in a stub
1469
+ * to assert call counts (timing-equalisation parity tests). Defaults to the
1470
+ * real primitive; `null` restores the default.
1471
+ */
1472
+ let verifyNickHashedPasswordFn: (password: string, entry: HashedAccountCredential) => boolean =
1473
+ verifyHashedPassword;
1474
+
1475
+ /**
1476
+ * Test seam: replaces the verify primitive with `fn` (or restores the
1477
+ * default when `fn === null`). Exported for tests; production callers MUST
1478
+ * NOT use.
1479
+ *
1480
+ * @internal
1481
+ */
1482
+ export function setVerifyNickHashedPassword(
1483
+ fn: ((password: string, entry: HashedAccountCredential) => boolean) | null,
1484
+ ): void {
1485
+ verifyNickHashedPasswordFn = fn ?? verifyHashedPassword;
1486
+ }
1487
+
1488
+ /** @internal test seam: clears any spy registered above. */
1489
+ export function resetVerifyNickTimingSpy(): void {
1490
+ verifyNickHashedPasswordFn = verifyHashedPassword;
1491
+ }
1492
+
1282
1493
  /**
1283
1494
  * Reference in-memory {@link ServicesStore} used by the local CLI,
1284
1495
  * integration tests, and any deployment that has not bound a distributed
@@ -1350,6 +1561,8 @@ export class InMemoryServicesStore implements ServicesStore {
1350
1561
  this.nicks.set(folded(row.nick), {
1351
1562
  ...row,
1352
1563
  certSubjects: [...(row.certSubjects ?? [])],
1564
+ identifyFailures: [...(row.identifyFailures ?? [])],
1565
+ pendingIdentifyFailureNotice: row.pendingIdentifyFailureNotice ?? 0,
1353
1566
  credential: { ...row.credential },
1354
1567
  });
1355
1568
  }
@@ -1426,6 +1639,7 @@ export class InMemoryServicesStore implements ServicesStore {
1426
1639
  nicks.push({
1427
1640
  ...rec,
1428
1641
  certSubjects: [...rec.certSubjects],
1642
+ identifyFailures: [...rec.identifyFailures],
1429
1643
  credential: { ...rec.credential },
1430
1644
  });
1431
1645
  }
@@ -1505,6 +1719,8 @@ export class InMemoryServicesStore implements ServicesStore {
1505
1719
  createdAt: now,
1506
1720
  enforce: 'none',
1507
1721
  certSubjects: [],
1722
+ identifyFailures: [],
1723
+ pendingIdentifyFailureNotice: 0,
1508
1724
  credential,
1509
1725
  });
1510
1726
  return { ok: true, account: nick };
@@ -1513,9 +1729,15 @@ export class InMemoryServicesStore implements ServicesStore {
1513
1729
  verifyNick(nick: string, password: string): ServicesAuthResult {
1514
1730
  const rec = this.nicks.get(folded(nick));
1515
1731
  if (rec === undefined) {
1732
+ // Equalise timing: unknown-nick must cost the same scrypt work as a
1733
+ // wrong password so callers (SASL PLAIN, PASS auth, NickServ
1734
+ // IDENTIFY/SETPASS) cannot leak nick existence via latency. The
1735
+ // dummy verify runs against a fixed precomputed digest — the result
1736
+ // is discarded, the cost is the point.
1737
+ verifyNickHashedPasswordFn(password, VERIFY_NICK_PRECOMPUTED_DUMMY);
1516
1738
  return { ok: false, reason: 'invalid credentials' };
1517
1739
  }
1518
- if (!verifyHashedPassword(password, rec.credential)) {
1740
+ if (!verifyNickHashedPasswordFn(password, rec.credential)) {
1519
1741
  return { ok: false, reason: 'invalid credentials' };
1520
1742
  }
1521
1743
  return { ok: true, account: rec.account };
@@ -1561,11 +1783,68 @@ export class InMemoryServicesStore implements ServicesStore {
1561
1783
  };
1562
1784
  }
1563
1785
 
1564
- verifyCertFP(subject: string): ServicesAuthResult {
1786
+ recordIdentifyFailure(nick: string): void {
1787
+ const rec = this.nicks.get(folded(nick));
1788
+ if (rec === undefined) return;
1789
+ rec.identifyFailures.push(this.clock.now());
1790
+ rec.pendingIdentifyFailureNotice += 1;
1791
+ }
1792
+
1793
+ isNickIdentifyFrozen(nick: string, opts: { maxFailures: number; windowMs: number }): boolean {
1794
+ const rec = this.nicks.get(folded(nick));
1795
+ if (rec === undefined) return false;
1796
+ // Sliding window: drop failures that have aged out (age >= windowMs),
1797
+ // then freeze iff the surviving count still meets the threshold. The
1798
+ // prune is in place, so the freeze lifts automatically as failures
1799
+ // expire even if no further attempts arrive.
1800
+ const cutoff = this.clock.now() - opts.windowMs;
1801
+ rec.identifyFailures = rec.identifyFailures.filter((t) => t > cutoff);
1802
+ return rec.identifyFailures.length >= opts.maxFailures;
1803
+ }
1804
+
1805
+ clearIdentifyFailures(nick: string): number {
1806
+ const rec = this.nicks.get(folded(nick));
1807
+ if (rec === undefined) return 0;
1808
+ const pending = rec.pendingIdentifyFailureNotice;
1809
+ rec.pendingIdentifyFailureNotice = 0;
1810
+ rec.identifyFailures = [];
1811
+ return pending;
1812
+ }
1813
+
1814
+ verifyCertFP(identity: CertIdentity): ServicesAuthResult {
1815
+ // Arm 1 — fingerprint (primary identifier where the platform surfaces
1816
+ // the DER SHA-256, e.g. Cloudflare's tlsClientAuth.certFingerprintSHA256).
1817
+ const fpId =
1818
+ identity.fingerprint !== undefined ? certFingerprintId(identity.fingerprint) : undefined;
1819
+ if (fpId !== undefined) {
1820
+ for (const rec of this.nicks.values()) {
1821
+ if (rec.certSubjects.includes(fpId)) {
1822
+ return { ok: true, account: rec.account };
1823
+ }
1824
+ }
1825
+ }
1826
+ // Arm 2 — canonical DN (the only arm on subject-DN-only platforms like
1827
+ // API Gateway). Stored DN entries are canonicalised at compare time so
1828
+ // legacy verbatim spellings still match; first use rewrites them (and
1829
+ // pins the fingerprint, when available).
1565
1830
  for (const rec of this.nicks.values()) {
1566
- if (rec.certSubjects.includes(subject)) {
1567
- return { ok: true, account: rec.account };
1831
+ const idx = rec.certSubjects.findIndex(
1832
+ (entry) =>
1833
+ !entry.startsWith(CERT_FP_PREFIX) &&
1834
+ canonicalizeCertSubject(entry) === identity.canonicalDn,
1835
+ );
1836
+ if (idx === -1) continue;
1837
+ let mutated = false;
1838
+ if (rec.certSubjects[idx] !== identity.canonicalDn) {
1839
+ rec.certSubjects[idx] = identity.canonicalDn;
1840
+ mutated = true;
1568
1841
  }
1842
+ if (fpId !== undefined && !rec.certSubjects.includes(fpId)) {
1843
+ rec.certSubjects.push(fpId);
1844
+ mutated = true;
1845
+ }
1846
+ if (mutated) this.onCertBindingsChanged(rec.nick);
1847
+ return { ok: true, account: rec.account };
1569
1848
  }
1570
1849
  return { ok: false, reason: 'untrusted certificate' };
1571
1850
  }
@@ -1578,6 +1857,15 @@ export class InMemoryServicesStore implements ServicesStore {
1578
1857
  return true;
1579
1858
  }
1580
1859
 
1860
+ addCertFingerprint(nick: string, fingerprint: string): boolean {
1861
+ const rec = this.nicks.get(folded(nick));
1862
+ if (rec === undefined) return false;
1863
+ const fpId = certFingerprintId(fingerprint);
1864
+ if (rec.certSubjects.includes(fpId)) return true;
1865
+ rec.certSubjects.push(fpId);
1866
+ return true;
1867
+ }
1868
+
1581
1869
  removeCertFP(nick: string, subject: string): boolean {
1582
1870
  const rec = this.nicks.get(folded(nick));
1583
1871
  if (rec === undefined) return false;
@@ -1587,6 +1875,17 @@ export class InMemoryServicesStore implements ServicesStore {
1587
1875
  return true;
1588
1876
  }
1589
1877
 
1878
+ /**
1879
+ * Hook fired when a `verifyCertFP` call rewrote the account's stored
1880
+ * cert bindings (legacy-spelling migration and/or fingerprint pinning).
1881
+ * No-op in the plain in-memory store; the write-behind
1882
+ * {@link PersistentServicesStore} subclass overrides it to re-persist
1883
+ * the nick row so the migration survives a backend reload.
1884
+ */
1885
+ protected onCertBindingsChanged(nick: string): void {
1886
+ void nick;
1887
+ }
1888
+
1590
1889
  /**
1591
1890
  * Returns the full stored nick row (including the scrypt-hashed
1592
1891
  * credential), or `undefined` when the nick is not registered. Defensive
@@ -1606,6 +1905,7 @@ export class InMemoryServicesStore implements ServicesStore {
1606
1905
  : {
1607
1906
  ...rec,
1608
1907
  certSubjects: [...rec.certSubjects],
1908
+ identifyFailures: [...rec.identifyFailures],
1609
1909
  credential: { ...rec.credential },
1610
1910
  };
1611
1911
  }
@@ -2090,6 +2390,38 @@ export abstract class PersistentServicesStore extends InMemoryServicesStore {
2090
2390
  this.enqueue({ kind: 'upsertNick', row });
2091
2391
  }
2092
2392
 
2393
+ override recordIdentifyFailure(nick: string): void {
2394
+ if (!this.isRegisteredNick(nick)) return;
2395
+ super.recordIdentifyFailure(nick);
2396
+ // Re-persist the full row so the failure timestamps + pending owner
2397
+ // notice survive a deploy: a re-identify from a fresh connection (or
2398
+ // store) must still see the freeze.
2399
+ const row = this.getStoredNick(nick) as ServicesNickRow;
2400
+ this.enqueue({ kind: 'upsertNick', row });
2401
+ }
2402
+
2403
+ override clearIdentifyFailures(nick: string): number {
2404
+ if (!this.isRegisteredNick(nick)) return 0;
2405
+ // Suppress the spurious write when there is no throttle state queued
2406
+ // (the common case on an unmolested account). The `isRegisteredNick`
2407
+ // guard above guarantees the stored row is defined.
2408
+ const before = this.getStoredNick(nick) as ServicesNickRow;
2409
+ if (before.identifyFailures.length === 0 && before.pendingIdentifyFailureNotice === 0) {
2410
+ return 0;
2411
+ }
2412
+ const pending = super.clearIdentifyFailures(nick);
2413
+ const row = this.getStoredNick(nick) as ServicesNickRow;
2414
+ this.enqueue({ kind: 'upsertNick', row });
2415
+ return pending;
2416
+ }
2417
+
2418
+ // NOTE: `isNickIdentifyFrozen` is intentionally NOT overridden. Its only
2419
+ // mutation is pruning timestamps that have aged out of the window — a
2420
+ // deterministic function of the stored timestamps and the clock, so a
2421
+ // reloaded backend row re-prunes to the same state on the next check.
2422
+ // Skipping its write keeps every read-only IDENTIFY attempt off the
2423
+ // write-behind queue.
2424
+
2093
2425
  override addCertFP(nick: string, subject: string): boolean {
2094
2426
  if (!this.isRegisteredNick(nick)) return false;
2095
2427
  // Suppress the spurious write on an idempotent re-add (the subject is
@@ -2107,6 +2439,22 @@ export abstract class PersistentServicesStore extends InMemoryServicesStore {
2107
2439
  return attached;
2108
2440
  }
2109
2441
 
2442
+ override addCertFingerprint(nick: string, fingerprint: string): boolean {
2443
+ if (!this.isRegisteredNick(nick)) return false;
2444
+ // Suppress the spurious write on an idempotent re-add (the normalised
2445
+ // fingerprint is already attached → super is a no-op). Mirrors the
2446
+ // addCertFP override above.
2447
+ const before = this.getNick(nick);
2448
+ const fpId = certFingerprintId(fingerprint);
2449
+ const alreadyPresent = before?.certSubjects.includes(fpId) === true;
2450
+ const attached = super.addCertFingerprint(nick, fingerprint);
2451
+ if (attached && !alreadyPresent) {
2452
+ const row = this.getStoredNick(nick) as ServicesNickRow;
2453
+ this.enqueue({ kind: 'upsertNick', row });
2454
+ }
2455
+ return attached;
2456
+ }
2457
+
2110
2458
  override removeCertFP(nick: string, subject: string): boolean {
2111
2459
  const removed = super.removeCertFP(nick, subject);
2112
2460
  if (removed) {
@@ -2116,6 +2464,17 @@ export abstract class PersistentServicesStore extends InMemoryServicesStore {
2116
2464
  return removed;
2117
2465
  }
2118
2466
 
2467
+ /**
2468
+ * Re-persists the nick row after a `verifyCertFP` first-use migration
2469
+ * (legacy DN spelling rewrite and/or fingerprint pinning) so the
2470
+ * upgraded binding survives a backend reload. The hook only fires for
2471
+ * a record `verifyCertFP` just iterated, so `getStoredNick` is defined.
2472
+ */
2473
+ protected override onCertBindingsChanged(nick: string): void {
2474
+ const row = this.getStoredNick(nick) as ServicesNickRow;
2475
+ this.enqueue({ kind: 'upsertNick', row });
2476
+ }
2477
+
2119
2478
  // ----- ChanServ -----
2120
2479
 
2121
2480
  override registerChannel(channel: string, founder: string): ChannelRegisterResult {