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.
- package/.github/workflows/ci.yml +28 -0
- package/.github/workflows/deploy-cf-tcp.yml +26 -2
- package/.github/workflows/deploy-cf.yml +26 -0
- package/CHANGELOG.md +289 -0
- package/README.md +153 -20
- package/apps/aws-stack/bin/aws.ts +36 -0
- package/apps/aws-stack/package.json +1 -1
- package/apps/aws-stack/src/aws-stack.ts +221 -15
- package/apps/aws-stack/tests/stack.test.ts +450 -16
- package/apps/cf-tcp-container/Dockerfile +37 -5
- package/apps/cf-tcp-container/package.json +7 -2
- package/apps/cf-tcp-container/src/config-loader.ts +113 -2
- package/apps/cf-tcp-container/src/container-server.ts +256 -79
- package/apps/cf-tcp-container/src/main.ts +22 -7
- package/apps/cf-tcp-container/src/proxy-protocol.ts +112 -0
- package/apps/cf-tcp-container/terraform/spectrum.tf +40 -11
- package/apps/cf-tcp-container/tests/config-loader.test.ts +170 -0
- package/apps/cf-tcp-container/tests/container-server-tls.test.ts +382 -0
- package/apps/cf-tcp-container/tests/container-server.test.ts +358 -31
- package/apps/cf-tcp-container/tests/dockerfile.test.ts +110 -0
- package/apps/cf-tcp-container/tests/proxy-protocol.test.ts +187 -0
- package/apps/cf-tcp-container/tests/spectrum-terraform.test.ts +135 -0
- package/apps/cf-tcp-container/tests/tls-e2e.test.ts +5 -1
- package/apps/cf-tcp-container/wrangler.toml +17 -4
- package/apps/cf-worker/package.json +2 -2
- package/apps/cf-worker/src/worker.ts +77 -5
- package/apps/cf-worker/tests/raw-modules.d.ts +11 -0
- package/apps/cf-worker/tests/smoke.test.ts +4 -0
- package/apps/cf-worker/tests/wrangler-config.test.ts +47 -0
- package/apps/cf-worker/tests/ws-admission.test.ts +112 -0
- package/apps/cf-worker/tests/ws-rate-limit.test.ts +133 -0
- package/apps/cf-worker/wrangler.test.toml +15 -1
- package/apps/cf-worker/wrangler.toml +86 -9
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/config-loader.ts +14 -2
- package/apps/local-cli/src/line-scanner.ts +26 -0
- package/apps/local-cli/src/server.ts +23 -2
- package/apps/local-cli/tests/line-scanner.test.ts +64 -0
- package/apps/local-cli/tests/tcp.test.ts +29 -0
- package/apps/web/package.json +1 -1
- package/docs/AWS-Deployment.md +123 -22
- package/docs/AWS-TCP-Deployment.md +37 -2
- package/docs/Chat-History.md +55 -0
- package/docs/Cloudflare-Deployment-Guide.md +9 -2
- package/docs/Cloudflare-TCP-Deployment.md +135 -52
- package/docs/SASL-EXTERNAL.md +175 -0
- package/package.json +3 -3
- package/packages/aws-adapter/package.json +1 -1
- package/packages/aws-adapter/src/admission.ts +28 -13
- package/packages/aws-adapter/src/aws-runtime.ts +30 -3
- package/packages/aws-adapter/src/cdk-table-defs.ts +34 -6
- package/packages/aws-adapter/src/config-loader.ts +134 -6
- package/packages/aws-adapter/src/dynamo-services-store.ts +12 -0
- package/packages/aws-adapter/src/handlers/connect.ts +47 -1
- package/packages/aws-adapter/src/handlers/default.ts +95 -6
- package/packages/aws-adapter/src/handlers/index.ts +31 -2
- package/packages/aws-adapter/src/handlers/nlb-stream.ts +132 -8
- package/packages/aws-adapter/src/ip-admission.ts +79 -0
- package/packages/aws-adapter/src/serialize.ts +8 -0
- package/packages/aws-adapter/src/tables.ts +9 -0
- package/packages/aws-adapter/tests/admission.test.ts +60 -2
- package/packages/aws-adapter/tests/aws-harness.ts +23 -1
- package/packages/aws-adapter/tests/aws-runtime.test.ts +64 -0
- package/packages/aws-adapter/tests/config-loader.test.ts +151 -0
- package/packages/aws-adapter/tests/connect.test.ts +199 -2
- package/packages/aws-adapter/tests/default-frame-limit.test.ts +231 -0
- package/packages/aws-adapter/tests/default-occ.test.ts +10 -3
- package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +123 -1
- package/packages/aws-adapter/tests/handlers.test.ts +57 -1
- package/packages/aws-adapter/tests/nlb-secure.test.ts +362 -0
- package/packages/aws-adapter/tests/nlb-stream.test.ts +628 -9
- package/packages/cf-adapter/package.json +1 -1
- package/packages/cf-adapter/src/cf-runtime.ts +48 -9
- package/packages/cf-adapter/src/config-loader.ts +133 -8
- package/packages/cf-adapter/src/connection-do.ts +154 -21
- package/packages/cf-adapter/src/counter-do.ts +142 -0
- package/packages/cf-adapter/src/d1-services-store.ts +47 -5
- package/packages/cf-adapter/src/env.ts +88 -0
- package/packages/cf-adapter/src/index.ts +17 -1
- package/packages/cf-adapter/src/rate-limit-do.ts +87 -0
- package/packages/cf-adapter/tests/cf-runtime.test.ts +104 -15
- package/packages/cf-adapter/tests/config-loader.test.ts +159 -0
- package/packages/cf-adapter/tests/connection-do-counter.test.ts +165 -0
- package/packages/cf-adapter/tests/connection-do-frame-limit.test.ts +177 -0
- package/packages/cf-adapter/tests/connection-do-pure.test.ts +74 -5
- package/packages/cf-adapter/tests/connection-do-ws-spec-contract.test.ts +7 -4
- package/packages/cf-adapter/tests/counter-do.test.ts +181 -0
- package/packages/cf-adapter/tests/d1-services-store.test.ts +192 -1
- package/packages/cf-adapter/tests/rate-limit-do.test.ts +160 -0
- package/packages/cf-adapter/tests/worker/main.ts +4 -0
- package/packages/cf-adapter/wrangler.test.toml +18 -1
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/in-memory-runtime/src/in-memory-runtime.ts +25 -0
- package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +74 -0
- package/packages/irc-core/package.json +1 -1
- package/packages/irc-core/src/caps/capabilities.ts +20 -10
- package/packages/irc-core/src/certfp.ts +178 -0
- package/packages/irc-core/src/commands/cap.ts +10 -2
- package/packages/irc-core/src/commands/chanserv.ts +117 -14
- package/packages/irc-core/src/commands/chathistory.ts +13 -5
- package/packages/irc-core/src/commands/hostserv.ts +84 -8
- package/packages/irc-core/src/commands/index.ts +2 -1
- package/packages/irc-core/src/commands/invite.ts +1 -7
- package/packages/irc-core/src/commands/join.ts +1 -16
- package/packages/irc-core/src/commands/kick.ts +1 -8
- package/packages/irc-core/src/commands/list.ts +1 -8
- package/packages/irc-core/src/commands/mode.ts +1 -8
- package/packages/irc-core/src/commands/multiline.ts +4 -10
- package/packages/irc-core/src/commands/names.ts +53 -13
- package/packages/irc-core/src/commands/nickserv.ts +40 -1
- package/packages/irc-core/src/commands/oper.ts +361 -8
- package/packages/irc-core/src/commands/part.ts +4 -10
- package/packages/irc-core/src/commands/privmsg.ts +8 -4
- package/packages/irc-core/src/commands/registration.ts +146 -2
- package/packages/irc-core/src/commands/sasl.ts +136 -19
- package/packages/irc-core/src/commands/topic.ts +10 -12
- package/packages/irc-core/src/commands/who.ts +1 -8
- package/packages/irc-core/src/config.ts +393 -20
- package/packages/irc-core/src/effects.ts +24 -0
- package/packages/irc-core/src/flood-control.ts +10 -10
- package/packages/irc-core/src/frame-rate-limit.ts +82 -0
- package/packages/irc-core/src/index.ts +8 -0
- package/packages/irc-core/src/oper-hashing.ts +43 -0
- package/packages/irc-core/src/oper-lockout.ts +87 -0
- package/packages/irc-core/src/ports.ts +395 -36
- package/packages/irc-core/src/protocol/bytes.ts +65 -0
- package/packages/irc-core/src/protocol/channel-name.ts +37 -0
- package/packages/irc-core/src/protocol/index.ts +12 -1
- package/packages/irc-core/src/protocol/outbound.ts +43 -10
- package/packages/irc-core/src/protocol/parser.ts +79 -10
- package/packages/irc-core/src/state/connection.ts +13 -0
- package/packages/irc-core/src/types.ts +228 -13
- package/packages/irc-core/src/ws-framing.ts +5 -4
- package/packages/irc-core/tests/bytes.test.ts +89 -0
- package/packages/irc-core/tests/certfp.test.ts +117 -0
- package/packages/irc-core/tests/commands/cap.test.ts +76 -2
- package/packages/irc-core/tests/commands/chanserv.test.ts +166 -0
- package/packages/irc-core/tests/commands/chathistory.test.ts +140 -0
- package/packages/irc-core/tests/commands/hostserv.test.ts +316 -0
- package/packages/irc-core/tests/commands/join.test.ts +78 -1
- package/packages/irc-core/tests/commands/names.test.ts +193 -0
- package/packages/irc-core/tests/commands/nickserv.test.ts +182 -2
- package/packages/irc-core/tests/commands/oper.test.ts +560 -2
- package/packages/irc-core/tests/commands/privmsg.test.ts +16 -0
- package/packages/irc-core/tests/commands/registration.test.ts +463 -1
- package/packages/irc-core/tests/commands/sasl.test.ts +596 -7
- package/packages/irc-core/tests/commands/topic.test.ts +137 -2
- package/packages/irc-core/tests/commands/unified-account.test.ts +2 -0
- package/packages/irc-core/tests/config.test.ts +534 -2
- package/packages/irc-core/tests/effects.test.ts +14 -0
- package/packages/irc-core/tests/flood-control.test.ts +29 -1
- package/packages/irc-core/tests/frame-rate-limit.test.ts +98 -0
- package/packages/irc-core/tests/oper-hashing.test.ts +60 -0
- package/packages/irc-core/tests/oper-lockout.test.ts +74 -0
- package/packages/irc-core/tests/outbound.test.ts +148 -0
- package/packages/irc-core/tests/parser.test.ts +287 -5
- package/packages/irc-core/tests/persistent-services-store.test.ts +141 -0
- package/packages/irc-core/tests/ports.test.ts +99 -7
- package/packages/irc-core/tests/services-store.test.ts +376 -14
- package/packages/irc-core/tests/ws-framing.test.ts +45 -0
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +123 -8
- package/packages/irc-server/src/dispatch.ts +1 -0
- package/packages/irc-server/src/index.ts +7 -0
- package/packages/irc-server/src/redact.ts +159 -0
- package/packages/irc-server/src/runtime.ts +14 -0
- package/packages/irc-server/src/transport.ts +28 -1
- package/packages/irc-server/tests/actor.test.ts +544 -7
- package/packages/irc-server/tests/dispatch.test.ts +31 -0
- package/packages/irc-server/tests/redact.test.ts +198 -0
- package/packages/irc-server/tests/runtime.test.ts +2 -0
- package/packages/irc-server/tests/transport.test.ts +66 -0
- package/packages/irc-test-support/package.json +1 -1
- package/packages/irc-test-support/src/in-memory-harness.ts +4 -0
- package/scripts/package.json +1 -1
- package/tools/ci-hardening/package.json +2 -2
- package/tools/ci-hardening/src/cf-deploy-cli.ts +3 -0
- package/tools/ci-hardening/src/cf-deploy.ts +118 -0
- package/tools/ci-hardening/src/deploy-hostname.ts +118 -0
- package/tools/ci-hardening/src/env-var-drift.ts +192 -0
- package/tools/ci-hardening/src/hostname-guard.ts +11 -0
- package/tools/ci-hardening/src/index.ts +17 -0
- package/tools/ci-hardening/tests/__wrangler_missing__.toml +2 -0
- package/tools/ci-hardening/tests/__wrangler_placeholder__.toml +3 -0
- package/tools/ci-hardening/tests/__wrangler_real__.toml +3 -0
- package/tools/ci-hardening/tests/cf-deploy.test.ts +200 -0
- package/tools/ci-hardening/tests/deploy-hostname.test.ts +348 -0
- package/tools/ci-hardening/tests/env-var-drift.test.ts +284 -0
- package/tools/ci-hardening/vitest.config.ts +5 -1
- package/tools/hash-oper-cred.ts +85 -0
- package/tools/load-test/package.json +1 -1
- 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
|
|
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
|
|
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):
|
|
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
|
|
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,
|
|
252
|
+
private readonly identities: ReadonlyMap<string, CertIdentity>;
|
|
216
253
|
|
|
217
|
-
constructor(entries: ReadonlyArray<{ connId: string; identity:
|
|
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):
|
|
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
|
|
642
|
-
*
|
|
643
|
-
*
|
|
644
|
-
*
|
|
645
|
-
*
|
|
646
|
-
*
|
|
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
|
|
661
|
-
* (CertFP)
|
|
662
|
-
*
|
|
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
|
-
*
|
|
875
|
-
*
|
|
876
|
-
*
|
|
877
|
-
*
|
|
878
|
-
* `
|
|
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
|
-
*
|
|
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(
|
|
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`).
|
|
890
|
-
*
|
|
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
|
-
*
|
|
896
|
-
*
|
|
897
|
-
*
|
|
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 &
|
|
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 (!
|
|
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
|
-
|
|
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
|
-
|
|
1567
|
-
|
|
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 {
|