serverless-ircd 0.9.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +28 -0
- package/.github/workflows/deploy-aws.yml +156 -32
- package/.github/workflows/deploy-cf-tcp.yml +35 -9
- package/.github/workflows/deploy-cf.yml +40 -14
- package/CHANGELOG.md +594 -0
- package/README.md +286 -60
- package/apps/aws-stack/README.md +3 -5
- package/apps/aws-stack/bin/aws.ts +118 -9
- package/apps/aws-stack/cdk.json +0 -3
- package/apps/aws-stack/package.json +3 -4
- package/apps/aws-stack/src/aws-stack.ts +398 -67
- package/apps/aws-stack/src/static-site.ts +323 -0
- package/apps/aws-stack/tests/smoke-helpers.test.ts +1 -1
- package/apps/aws-stack/tests/stack.test.ts +714 -105
- package/apps/aws-stack/tests/static-site.test.ts +491 -0
- package/apps/aws-stack/tests/synth-no-bundle.test.ts +0 -1
- package/apps/cf-tcp-container/Dockerfile +37 -5
- package/apps/cf-tcp-container/package.json +7 -3
- package/apps/cf-tcp-container/src/config-loader.ts +113 -2
- package/apps/cf-tcp-container/src/container-server.ts +267 -87
- 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 +18 -14
- package/apps/cf-worker/package.json +3 -4
- 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 +95 -77
- 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 +44 -19
- package/apps/local-cli/tests/line-scanner.test.ts +64 -0
- package/apps/local-cli/tests/tcp.test.ts +29 -0
- package/apps/web/landing/favicon.ico +0 -0
- package/apps/web/landing/index.html +1 -0
- package/apps/web/package.json +2 -2
- package/apps/web/scripts/build.mjs +66 -4
- package/apps/web/src/build-env.ts +125 -4
- package/apps/web/src/config-schema.ts +20 -6
- package/apps/web/static/{config.staging.json → config.prod-aws.json} +3 -2
- package/apps/web/tests/build-env.test.ts +210 -9
- package/apps/web/tests/build-smoke.test.ts +2 -2
- package/apps/web/tests/config-schema.test.ts +149 -25
- package/docs/AWS-Deployment.md +793 -118
- package/docs/AWS-TCP-Deployment.md +57 -47
- package/docs/Chat-History.md +55 -0
- package/docs/Cloudflare-Deployment-Guide.md +95 -114
- package/docs/Cloudflare-TCP-Deployment.md +160 -101
- package/docs/Release-Process.md +27 -23
- package/docs/SASL-EXTERNAL.md +175 -0
- package/docs/Services.md +69 -22
- package/docs/WebClientGuide.md +35 -26
- package/package.json +7 -10
- 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 +39 -16
- package/packages/aws-adapter/src/config-loader.ts +153 -8
- package/packages/aws-adapter/src/dynamo-services-store.ts +19 -0
- package/packages/aws-adapter/src/handlers/connect.ts +73 -1
- package/packages/aws-adapter/src/handlers/default.ts +279 -123
- package/packages/aws-adapter/src/handlers/index.ts +98 -25
- package/packages/aws-adapter/src/handlers/nlb-stream.ts +135 -14
- package/packages/aws-adapter/src/index.ts +5 -7
- package/packages/aws-adapter/src/ip-admission.ts +79 -0
- package/packages/aws-adapter/src/origin-allowlist.ts +94 -0
- package/packages/aws-adapter/src/serialize.ts +23 -0
- package/packages/aws-adapter/src/tables.ts +11 -12
- package/packages/aws-adapter/tests/admission.test.ts +60 -2
- package/packages/aws-adapter/tests/aws-harness.ts +23 -2
- package/packages/aws-adapter/tests/aws-runtime.test.ts +64 -0
- package/packages/aws-adapter/tests/config-loader.test.ts +217 -0
- package/packages/aws-adapter/tests/connect.test.ts +323 -3
- package/packages/aws-adapter/tests/default-frame-limit.test.ts +231 -0
- package/packages/aws-adapter/tests/default-occ.test.ts +226 -0
- package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +134 -1
- package/packages/aws-adapter/tests/handlers.test.ts +174 -12
- package/packages/aws-adapter/tests/migrate-accounts-to-services.test.ts +164 -0
- package/packages/aws-adapter/tests/nlb-secure.test.ts +362 -0
- package/packages/aws-adapter/tests/nlb-stream.test.ts +628 -9
- package/packages/aws-adapter/tests/origin-allowlist.test.ts +110 -0
- package/packages/aws-adapter/tests/ping-checker.test.ts +0 -1
- package/packages/aws-adapter/tests/stats.test.ts +0 -3
- package/packages/aws-adapter/tests/sweeper.test.ts +0 -1
- package/packages/aws-adapter/tests/tables.test.ts +1 -8
- package/packages/aws-adapter/tests/transactions.test.ts +0 -1
- package/packages/cf-adapter/package.json +1 -5
- package/packages/cf-adapter/src/cf-runtime.ts +100 -10
- package/packages/cf-adapter/src/channel-do.ts +13 -3
- package/packages/cf-adapter/src/config-loader.ts +133 -8
- package/packages/cf-adapter/src/connection-do.ts +406 -116
- package/packages/cf-adapter/src/counter-do.ts +142 -0
- package/packages/cf-adapter/src/d1-services-store.ts +105 -26
- package/packages/cf-adapter/src/env.ts +99 -10
- package/packages/cf-adapter/src/index.ts +17 -7
- package/packages/cf-adapter/src/rate-limit-do.ts +87 -0
- package/packages/cf-adapter/tests/cf-runtime.test.ts +205 -16
- package/packages/cf-adapter/tests/channel-do.test.ts +118 -1
- 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-coverage.test.ts +460 -0
- package/packages/cf-adapter/tests/connection-do-frame-limit.test.ts +177 -0
- package/packages/cf-adapter/tests/connection-do-pure.test.ts +164 -54
- package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +62 -38
- 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 +245 -3
- package/packages/cf-adapter/tests/rate-limit-do.test.ts +160 -0
- package/packages/cf-adapter/tests/serialize.test.ts +25 -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/account-migration.ts +140 -0
- 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/account-auth.ts +16 -19
- 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/memoserv.ts +1 -1
- 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 +161 -11
- 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 +148 -4
- package/packages/irc-core/src/commands/sasl.ts +154 -46
- 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 +424 -25
- package/packages/irc-core/src/credential-hashing.ts +11 -54
- 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 +9 -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 +529 -190
- 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 +266 -23
- package/packages/irc-core/src/ws-framing.ts +5 -4
- package/packages/irc-core/tests/account-migration.test.ts +133 -0
- 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/markread.test.ts +54 -0
- package/packages/irc-core/tests/commands/memoserv.test.ts +19 -0
- package/packages/irc-core/tests/commands/names.test.ts +193 -0
- package/packages/irc-core/tests/commands/nickserv.test.ts +419 -3
- package/packages/irc-core/tests/commands/oper.test.ts +574 -1
- package/packages/irc-core/tests/commands/privmsg.test.ts +16 -0
- package/packages/irc-core/tests/commands/registration.test.ts +602 -133
- package/packages/irc-core/tests/commands/sasl.test.ts +742 -172
- package/packages/irc-core/tests/commands/topic.test.ts +137 -2
- package/packages/irc-core/tests/commands/unified-account.test.ts +104 -84
- package/packages/irc-core/tests/config.test.ts +534 -2
- package/packages/irc-core/tests/credential-hashing.test.ts +0 -78
- 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/message-store.test.ts +5 -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 +212 -12
- package/packages/irc-core/tests/ports.test.ts +170 -7
- package/packages/irc-core/tests/services-store.test.ts +567 -1
- package/packages/irc-core/tests/ws-framing.test.ts +45 -0
- package/packages/irc-core/vitest.config.ts +6 -1
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +123 -22
- 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 +563 -54
- 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/pnpm-workspace.yaml +1 -0
- package/scripts/__tests__/deploy-web-aws.test.ts +491 -0
- package/scripts/deploy-web-aws.mjs +290 -0
- package/scripts/package.json +23 -0
- package/scripts/tsconfig.test.json +12 -0
- package/scripts/vitest.config.ts +19 -0
- 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 +19 -0
- package/tools/ci-hardening/src/validate.ts +57 -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-aws-oidc.test.ts +96 -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/tests/validate.test.ts +42 -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/migrate-accounts-to-services.ts +270 -0
- package/tools/package.json +2 -1
- package/tools/seed-aws-accounts.ts +35 -10
- package/tools/seed-cf-accounts.ts +42 -9
- package/tools/tcp-ws-forwarder/package.json +1 -1
- package/packages/aws-adapter/src/account-store.ts +0 -121
- package/packages/aws-adapter/src/dynamo-account-store.ts +0 -95
- package/packages/aws-adapter/tests/account-store-dynamo.test.ts +0 -223
- package/packages/aws-adapter/tests/account-store.test.ts +0 -276
- package/packages/cf-adapter/src/d1-account-store.ts +0 -198
- package/packages/cf-adapter/tests/d1-account-store.test.ts +0 -274
- package/packages/irc-core/tests/account-store.test.ts +0 -131
|
@@ -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;
|
|
@@ -63,10 +71,11 @@ export interface IdFactory {
|
|
|
63
71
|
}
|
|
64
72
|
|
|
65
73
|
/**
|
|
66
|
-
* Production id factory. Generates UUIDv4-shaped strings
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
74
|
+
* Production id factory. Generates UUIDv4-shaped strings from a CSPRNG
|
|
75
|
+
* (`globalThis.crypto.getRandomValues`), which is available on Node 19+
|
|
76
|
+
* and Cloudflare Workers as a global. The randomness is cryptographic-
|
|
77
|
+
* grade, so generated ids are suitable as unforgeable msgids / session
|
|
78
|
+
* ids as well as opaque batch references.
|
|
70
79
|
*/
|
|
71
80
|
export class UuidIdFactory implements IdFactory {
|
|
72
81
|
batchId(): string {
|
|
@@ -83,22 +92,29 @@ export class UuidIdFactory implements IdFactory {
|
|
|
83
92
|
}
|
|
84
93
|
}
|
|
85
94
|
|
|
86
|
-
// RFC 4122 v4 shape (random). Six fixed bits identify the version/variant;
|
|
87
|
-
// the remaining 122 bits come from Math.random. Good enough for non-security
|
|
88
|
-
// identifiers like BATCH ref ids and msgid tags.
|
|
89
95
|
const HEX = '0123456789abcdef';
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* RFC 4122 v4 (random) UUID. Six fixed bits identify the version/variant;
|
|
99
|
+
* the remaining 122 bits are sourced from `globalThis.crypto.getRandomValues`
|
|
100
|
+
* as a single 16-byte buffer (one syscall per id). The global is used — not
|
|
101
|
+
* `node:crypto` — so the same code runs unmodified on Node and Cloudflare
|
|
102
|
+
* Workers.
|
|
103
|
+
*/
|
|
90
104
|
function uuidV4(): string {
|
|
105
|
+
const bytes = globalThis.crypto.getRandomValues(new Uint8Array(16));
|
|
91
106
|
let out = '';
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
107
|
+
let i = 0;
|
|
108
|
+
for (let b of bytes) {
|
|
109
|
+
// Version nibble (bits 48-51 of the 128-bit id) = 0b0100 (v4).
|
|
110
|
+
if (i === 6) b = (b & 0x0f) | 0x40;
|
|
111
|
+
// Variant nibble (bits 64-65) = 0b10 (RFC 4122 / Leach-Salz).
|
|
112
|
+
else if (i === 8) b = (b & 0x3f) | 0x80;
|
|
113
|
+
// Dashes sit at byte offsets 4, 6, 8, 10 between the 4-2-2-2-6 byte groups.
|
|
114
|
+
if (i === 4 || i === 6 || i === 8 || i === 10) out += '-';
|
|
115
|
+
out += HEX.charAt(b >> 4);
|
|
116
|
+
out += HEX.charAt(b & 0x0f);
|
|
117
|
+
i++;
|
|
102
118
|
}
|
|
103
119
|
return out;
|
|
104
120
|
}
|
|
@@ -124,6 +140,31 @@ export class SequentialIdFactory implements IdFactory {
|
|
|
124
140
|
}
|
|
125
141
|
}
|
|
126
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
|
+
|
|
127
168
|
/**
|
|
128
169
|
* Port supplying the server's Message-of-the-Day text.
|
|
129
170
|
*
|
|
@@ -169,121 +210,6 @@ export class StaticMotdProvider implements MotdProvider {
|
|
|
169
210
|
/** Convenience: a {@link MotdProvider} that always reports "no MOTD". */
|
|
170
211
|
export const EmptyMotdProvider: MotdProvider = new StaticMotdProvider([]);
|
|
171
212
|
|
|
172
|
-
/**
|
|
173
|
-
* Parsed SASL credentials handed to an {@link AccountStore}.
|
|
174
|
-
*
|
|
175
|
-
* `PLAIN` is the structured form produced by base64-decoding and splitting
|
|
176
|
-
* the `\0authzid\0authcid\0passwd` payload. `RAW` carries the decoded bytes
|
|
177
|
-
* for any other mechanism the server wants to forward verbatim.
|
|
178
|
-
*/
|
|
179
|
-
export type SaslPayload =
|
|
180
|
-
| { kind: 'PLAIN'; username: string; password: string }
|
|
181
|
-
| { kind: 'RAW'; data: string }
|
|
182
|
-
| { kind: 'EXTERNAL'; identity: string };
|
|
183
|
-
|
|
184
|
-
/**
|
|
185
|
-
* Outcome of an {@link AccountStore.verify} call: either the canonical
|
|
186
|
-
* account name to record on the connection, or a human-readable reason
|
|
187
|
-
* for the failure that surfaces as `904 ERR_SASLFAIL`.
|
|
188
|
-
*/
|
|
189
|
-
export type SaslResult = { ok: true; account: string } | { ok: false; reason: string };
|
|
190
|
-
|
|
191
|
-
/**
|
|
192
|
-
* Port that verifies SASL credentials against the deployment's account
|
|
193
|
-
* backend.
|
|
194
|
-
*
|
|
195
|
-
* The method is **synchronous** on purpose: reducers must stay pure and
|
|
196
|
-
* cannot await. Adapters that need an async lookup (D1, DynamoDB, Secrets
|
|
197
|
-
* Manager) pre-load the credentials they need into a synchronously-readable
|
|
198
|
-
* store (mirroring the {@link MotdProvider} pattern). For Phase 1 the
|
|
199
|
-
* in-memory reference adapter and tests inject a fake directly.
|
|
200
|
-
*/
|
|
201
|
-
export interface AccountStore {
|
|
202
|
-
verify(mech: string, payload: SaslPayload): SaslResult;
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
/**
|
|
206
|
-
* One SASL PLAIN credential entry seeded into an {@link InMemoryAccountStore}.
|
|
207
|
-
* The `username` is also the canonical account name recorded on the
|
|
208
|
-
* connection for `extended-join` / `account-tag`.
|
|
209
|
-
*/
|
|
210
|
-
export interface SaslAccountCredential {
|
|
211
|
-
username: string;
|
|
212
|
-
password: string;
|
|
213
|
-
/**
|
|
214
|
-
* Optional mTLS client-certificate subject (e.g. `CN=alice` or a
|
|
215
|
-
* fingerprint hash). When set, SASL `EXTERNAL` authentications that
|
|
216
|
-
* present this subject are mapped to {@link username} as the account.
|
|
217
|
-
*/
|
|
218
|
-
certSubject?: string;
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
/**
|
|
222
|
-
* Reference in-memory {@link AccountStore} backed by a static list of PLAIN
|
|
223
|
-
* credentials seeded at construction.
|
|
224
|
-
*
|
|
225
|
-
* Mirrors the {@link InMemoryMessageStore} / {@link StaticMotdProvider}
|
|
226
|
-
* pattern: the port is synchronous, so adapters that need an async backend
|
|
227
|
-
* (D1, DynamoDB, Secrets Manager) pre-load their credentials into this store
|
|
228
|
-
* at boot. All three adapters (local-cli, CF, AWS) use this as the minimum
|
|
229
|
-
* viable `AccountStore`; a persistent variant (DynamoDB `Accounts` table, D1)
|
|
230
|
-
* is a documented follow-up that swaps in by replacing the construction call.
|
|
231
|
-
*
|
|
232
|
-
* Password comparison is constant-time to avoid the early-return timing
|
|
233
|
-
* oracle a naive `===` would expose. Only `PLAIN` is verified; any other
|
|
234
|
-
* mechanism returns failure (matching the reducer's mech gating).
|
|
235
|
-
*/
|
|
236
|
-
export class InMemoryAccountStore implements AccountStore {
|
|
237
|
-
private readonly creds: ReadonlyMap<string, string>;
|
|
238
|
-
private readonly certMap: ReadonlyMap<string, string>;
|
|
239
|
-
|
|
240
|
-
constructor(credentials: ReadonlyArray<SaslAccountCredential>) {
|
|
241
|
-
this.creds = new Map(credentials.map((c) => [c.username, c.password]));
|
|
242
|
-
this.certMap = new Map(
|
|
243
|
-
credentials
|
|
244
|
-
.filter((c) => c.certSubject !== undefined)
|
|
245
|
-
.map((c) => [c.certSubject as string, c.username]),
|
|
246
|
-
);
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
verify(mech: string, payload: SaslPayload): SaslResult {
|
|
250
|
-
const m = mech.toUpperCase();
|
|
251
|
-
if (m === 'PLAIN' && payload.kind === 'PLAIN') {
|
|
252
|
-
const expected = this.creds.get(payload.username);
|
|
253
|
-
if (expected === undefined) {
|
|
254
|
-
return { ok: false, reason: 'invalid credentials' };
|
|
255
|
-
}
|
|
256
|
-
if (!constantTimeEquals(payload.password, expected)) {
|
|
257
|
-
return { ok: false, reason: 'invalid credentials' };
|
|
258
|
-
}
|
|
259
|
-
return { ok: true, account: payload.username };
|
|
260
|
-
}
|
|
261
|
-
if (m === 'EXTERNAL' && payload.kind === 'EXTERNAL') {
|
|
262
|
-
const account = this.certMap.get(payload.identity);
|
|
263
|
-
if (account === undefined) {
|
|
264
|
-
return { ok: false, reason: 'untrusted certificate' };
|
|
265
|
-
}
|
|
266
|
-
return { ok: true, account };
|
|
267
|
-
}
|
|
268
|
-
return { ok: false, reason: `unsupported mechanism: ${mech}` };
|
|
269
|
-
}
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
/**
|
|
273
|
-
* Constant-time string equality. Compares every byte regardless of early
|
|
274
|
-
* mismatches so a remote attacker cannot short-circuit the comparison via a
|
|
275
|
-
* timing side-channel. Returns `false` when the lengths differ (the length
|
|
276
|
-
* is not considered secret for SASL PLAIN passwords).
|
|
277
|
-
*/
|
|
278
|
-
function constantTimeEquals(a: string, b: string): boolean {
|
|
279
|
-
if (a.length !== b.length) return false;
|
|
280
|
-
let diff = 0;
|
|
281
|
-
for (let i = 0; i < a.length; i++) {
|
|
282
|
-
diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
283
|
-
}
|
|
284
|
-
return diff === 0;
|
|
285
|
-
}
|
|
286
|
-
|
|
287
213
|
// ============================================================================
|
|
288
214
|
// mTLS identity (SASL EXTERNAL)
|
|
289
215
|
// ============================================================================
|
|
@@ -294,9 +220,13 @@ function constantTimeEquals(a: string, b: string): boolean {
|
|
|
294
220
|
*
|
|
295
221
|
* At the edge (CF API Shield mTLS, AWS API Gateway custom-domain mTLS) the
|
|
296
222
|
* platform terminates TLS, validates the client certificate against the
|
|
297
|
-
* configured trust store, and surfaces the certificate
|
|
223
|
+
* configured trust store, and surfaces the certificate identity to the
|
|
298
224
|
* Worker/Lambda. The adapter captures this at connection-admission time
|
|
299
|
-
* 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`.
|
|
300
230
|
*
|
|
301
231
|
* The method is **synchronous** — adapters pre-populate the identity at
|
|
302
232
|
* admission time (before the first reducer runs), so the SASL EXTERNAL
|
|
@@ -306,26 +236,26 @@ function constantTimeEquals(a: string, b: string): boolean {
|
|
|
306
236
|
*/
|
|
307
237
|
export interface MtlsIdentityProvider {
|
|
308
238
|
/**
|
|
309
|
-
* @returns the verified client-cert
|
|
239
|
+
* @returns the verified client-cert identity for `connId`, or `undefined`
|
|
310
240
|
* when no trusted certificate was presented.
|
|
311
241
|
*/
|
|
312
|
-
getIdentity(connId: string):
|
|
242
|
+
getIdentity(connId: string): CertIdentity | undefined;
|
|
313
243
|
}
|
|
314
244
|
|
|
315
245
|
/**
|
|
316
246
|
* Reference in-memory {@link MtlsIdentityProvider} backed by a static map
|
|
317
|
-
* of connection IDs to certificate
|
|
247
|
+
* of connection IDs to certificate identities. Useful for tests and the
|
|
318
248
|
* local-cli adapter; cloud adapters replace this with a lookup keyed by
|
|
319
249
|
* the platform's connection identifier.
|
|
320
250
|
*/
|
|
321
251
|
export class InMemoryMtlsIdentityProvider implements MtlsIdentityProvider {
|
|
322
|
-
private readonly identities: ReadonlyMap<string,
|
|
252
|
+
private readonly identities: ReadonlyMap<string, CertIdentity>;
|
|
323
253
|
|
|
324
|
-
constructor(entries: ReadonlyArray<{ connId: string; identity:
|
|
254
|
+
constructor(entries: ReadonlyArray<{ connId: string; identity: CertIdentity }>) {
|
|
325
255
|
this.identities = new Map(entries.map((e) => [e.connId, e.identity]));
|
|
326
256
|
}
|
|
327
257
|
|
|
328
|
-
getIdentity(connId: string):
|
|
258
|
+
getIdentity(connId: string): CertIdentity | undefined {
|
|
329
259
|
return this.identities.get(connId);
|
|
330
260
|
}
|
|
331
261
|
}
|
|
@@ -395,7 +325,7 @@ export interface TargetEntry {
|
|
|
395
325
|
/**
|
|
396
326
|
* Persistence port for IRCv3 `draft/chathistory` playback.
|
|
397
327
|
*
|
|
398
|
-
* Mirrors the {@link MotdProvider}
|
|
328
|
+
* Mirrors the {@link MotdProvider} pattern: the port
|
|
399
329
|
* is **synchronous** (reducers must stay pure and free of IO) and injected
|
|
400
330
|
* via {@link Ctx}. Adapters that need async storage (D1, R2, DynamoDB)
|
|
401
331
|
* pre-load the relevant slice into a synchronously-readable store; Phase 1
|
|
@@ -744,6 +674,22 @@ export type NickEnforcePolicy = 'none' | 'ghost' | 'kill';
|
|
|
744
674
|
* lands). Lookups against the store are case-insensitive (rfc1459-folded);
|
|
745
675
|
* the returned `nick` / `account` preserve the original spelling so the
|
|
746
676
|
* wire renders the user's preferred casing.
|
|
677
|
+
*
|
|
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.
|
|
747
693
|
*/
|
|
748
694
|
export interface RegisteredNick {
|
|
749
695
|
/** Display spelling stored at registration. */
|
|
@@ -756,6 +702,14 @@ export interface RegisteredNick {
|
|
|
756
702
|
createdAt: number;
|
|
757
703
|
/** Per-account nick-enforcement policy. */
|
|
758
704
|
enforce: NickEnforcePolicy;
|
|
705
|
+
/**
|
|
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}.
|
|
711
|
+
*/
|
|
712
|
+
certSubjects: string[];
|
|
759
713
|
}
|
|
760
714
|
|
|
761
715
|
/**
|
|
@@ -929,6 +883,11 @@ export interface ServicesStore {
|
|
|
929
883
|
* `{ ok: true, account }` on a match (the canonical spelling), or
|
|
930
884
|
* `{ ok: false, reason }` when the nick is unknown or the password is
|
|
931
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.
|
|
932
891
|
*/
|
|
933
892
|
verifyNick(nick: string, password: string): ServicesAuthResult;
|
|
934
893
|
/**
|
|
@@ -943,6 +902,14 @@ export interface ServicesStore {
|
|
|
943
902
|
* not registered (the policy stays {@link NickEnforcePolicy.None}).
|
|
944
903
|
*/
|
|
945
904
|
setNickEnforce(nick: string, policy: NickEnforcePolicy): void;
|
|
905
|
+
/**
|
|
906
|
+
* Overwrites the stored credential for `nick` with a fresh scrypt hash of
|
|
907
|
+
* `newPassword` (the plaintext is NEVER persisted). Used by NickServ
|
|
908
|
+
* `SET PASSWORD`. No-op when the nick is not registered. Write-behind
|
|
909
|
+
* persistent subclasses re-persist the full nick row (hashed credential
|
|
910
|
+
* included) so a backend reload keeps `verifyNick` working.
|
|
911
|
+
*/
|
|
912
|
+
setNickPassword(nick: string, newPassword: string): void;
|
|
946
913
|
/**
|
|
947
914
|
* Returns the nick-enforcement policy for `nick`, or
|
|
948
915
|
* {@link NickEnforcePolicy.None} when the nick is not registered.
|
|
@@ -956,6 +923,94 @@ export interface ServicesStore {
|
|
|
956
923
|
*/
|
|
957
924
|
getNick(nick: string): RegisteredNick | undefined;
|
|
958
925
|
|
|
926
|
+
/**
|
|
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).
|
|
976
|
+
*
|
|
977
|
+
* The edge (CF API Shield / AWS APIGW mTLS) still resolves the verified
|
|
978
|
+
* identity via {@link MtlsIdentityProvider}; only the identity→account
|
|
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.
|
|
983
|
+
*/
|
|
984
|
+
verifyCertFP(identity: CertIdentity): ServicesAuthResult;
|
|
985
|
+
/**
|
|
986
|
+
* Attaches a client-certificate subject DN to `nick` for SASL EXTERNAL.
|
|
987
|
+
* No-op (and returns `false`) when the nick is not registered.
|
|
988
|
+
* Byte-exact: a duplicate subject (case-sensitive) is a no-op (idempotent
|
|
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
|
|
992
|
+
* present), `false` when the nick is unknown.
|
|
993
|
+
*/
|
|
994
|
+
addCertFP(nick: string, subject: string): boolean;
|
|
995
|
+
/**
|
|
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.
|
|
1011
|
+
*/
|
|
1012
|
+
removeCertFP(nick: string, subject: string): boolean;
|
|
1013
|
+
|
|
959
1014
|
// ----- ChanServ -----
|
|
960
1015
|
|
|
961
1016
|
/**
|
|
@@ -1180,6 +1235,29 @@ export interface ServicesStore {
|
|
|
1180
1235
|
listJupes(): ReadonlyArray<JupeEntry>;
|
|
1181
1236
|
}
|
|
1182
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
|
+
|
|
1183
1261
|
/**
|
|
1184
1262
|
* Snapshot row for a NickServ registration, persisted by adapter backends
|
|
1185
1263
|
* (D1 / DynamoDB) and round-tripped through {@link ServicesSnapshot}.
|
|
@@ -1197,9 +1275,13 @@ export interface ServicesStore {
|
|
|
1197
1275
|
* The credential never leaves the store via
|
|
1198
1276
|
* {@link InMemoryServicesStore.getNick} (which returns a defensive copy
|
|
1199
1277
|
* without it), but it MUST be present in the snapshot so the
|
|
1200
|
-
* `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.
|
|
1201
1282
|
*/
|
|
1202
|
-
export type ServicesNickRow = RegisteredNick &
|
|
1283
|
+
export type ServicesNickRow = RegisteredNick &
|
|
1284
|
+
IdentifyThrottleState & { credential: HashedAccountCredential };
|
|
1203
1285
|
|
|
1204
1286
|
/**
|
|
1205
1287
|
* Snapshot row for a ChanServ access entry: `(channel, account, level)`.
|
|
@@ -1337,6 +1419,77 @@ type StoredAkill = AkillEntry & { maskKey: string };
|
|
|
1337
1419
|
*/
|
|
1338
1420
|
type StoredJupe = JupeEntry & { nameKey: string };
|
|
1339
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
|
+
|
|
1340
1493
|
/**
|
|
1341
1494
|
* Reference in-memory {@link ServicesStore} used by the local CLI,
|
|
1342
1495
|
* integration tests, and any deployment that has not bound a distributed
|
|
@@ -1344,7 +1497,7 @@ type StoredJupe = JupeEntry & { nameKey: string };
|
|
|
1344
1497
|
*
|
|
1345
1498
|
* Backed by plain `Map`s keyed by rfc1459-folded nick / account / channel.
|
|
1346
1499
|
* Password verification uses the same scrypt path as the SASL
|
|
1347
|
-
* {@link
|
|
1500
|
+
* {@link verifyHashedPassword} so a NickServ
|
|
1348
1501
|
* registration IS a SASL account — both stores carry credentials in the
|
|
1349
1502
|
* same hashed shape, and SASL PLAIN's services fallback verifies against
|
|
1350
1503
|
* a row created by `NickServ REGISTER` without a second registration.
|
|
@@ -1405,7 +1558,13 @@ export class InMemoryServicesStore implements ServicesStore {
|
|
|
1405
1558
|
*/
|
|
1406
1559
|
protected hydrate(snap: ServicesSnapshot): void {
|
|
1407
1560
|
for (const row of snap.nicks) {
|
|
1408
|
-
this.nicks.set(folded(row.nick), {
|
|
1561
|
+
this.nicks.set(folded(row.nick), {
|
|
1562
|
+
...row,
|
|
1563
|
+
certSubjects: [...(row.certSubjects ?? [])],
|
|
1564
|
+
identifyFailures: [...(row.identifyFailures ?? [])],
|
|
1565
|
+
pendingIdentifyFailureNotice: row.pendingIdentifyFailureNotice ?? 0,
|
|
1566
|
+
credential: { ...row.credential },
|
|
1567
|
+
});
|
|
1409
1568
|
}
|
|
1410
1569
|
for (const row of snap.channels) {
|
|
1411
1570
|
const stored: RegisteredChannel = { ...row };
|
|
@@ -1477,7 +1636,12 @@ export class InMemoryServicesStore implements ServicesStore {
|
|
|
1477
1636
|
snapshot(): ServicesSnapshot {
|
|
1478
1637
|
const nicks: ServicesNickRow[] = [];
|
|
1479
1638
|
for (const rec of this.nicks.values()) {
|
|
1480
|
-
nicks.push({
|
|
1639
|
+
nicks.push({
|
|
1640
|
+
...rec,
|
|
1641
|
+
certSubjects: [...rec.certSubjects],
|
|
1642
|
+
identifyFailures: [...rec.identifyFailures],
|
|
1643
|
+
credential: { ...rec.credential },
|
|
1644
|
+
});
|
|
1481
1645
|
}
|
|
1482
1646
|
const channels: RegisteredChannel[] = [];
|
|
1483
1647
|
for (const rec of this.channels.values()) {
|
|
@@ -1554,6 +1718,9 @@ export class InMemoryServicesStore implements ServicesStore {
|
|
|
1554
1718
|
email,
|
|
1555
1719
|
createdAt: now,
|
|
1556
1720
|
enforce: 'none',
|
|
1721
|
+
certSubjects: [],
|
|
1722
|
+
identifyFailures: [],
|
|
1723
|
+
pendingIdentifyFailureNotice: 0,
|
|
1557
1724
|
credential,
|
|
1558
1725
|
});
|
|
1559
1726
|
return { ok: true, account: nick };
|
|
@@ -1562,9 +1729,15 @@ export class InMemoryServicesStore implements ServicesStore {
|
|
|
1562
1729
|
verifyNick(nick: string, password: string): ServicesAuthResult {
|
|
1563
1730
|
const rec = this.nicks.get(folded(nick));
|
|
1564
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);
|
|
1565
1738
|
return { ok: false, reason: 'invalid credentials' };
|
|
1566
1739
|
}
|
|
1567
|
-
if (!
|
|
1740
|
+
if (!verifyNickHashedPasswordFn(password, rec.credential)) {
|
|
1568
1741
|
return { ok: false, reason: 'invalid credentials' };
|
|
1569
1742
|
}
|
|
1570
1743
|
return { ok: true, account: rec.account };
|
|
@@ -1585,6 +1758,12 @@ export class InMemoryServicesStore implements ServicesStore {
|
|
|
1585
1758
|
rec.enforce = policy;
|
|
1586
1759
|
}
|
|
1587
1760
|
|
|
1761
|
+
setNickPassword(nick: string, newPassword: string): void {
|
|
1762
|
+
const rec = this.nicks.get(folded(nick));
|
|
1763
|
+
if (rec === undefined) return;
|
|
1764
|
+
rec.credential = hashAccountCredential(rec.account, newPassword);
|
|
1765
|
+
}
|
|
1766
|
+
|
|
1588
1767
|
getNickEnforce(nick: string): NickEnforcePolicy {
|
|
1589
1768
|
return this.nicks.get(folded(nick))?.enforce ?? 'none';
|
|
1590
1769
|
}
|
|
@@ -1600,46 +1779,113 @@ export class InMemoryServicesStore implements ServicesStore {
|
|
|
1600
1779
|
email: rec.email,
|
|
1601
1780
|
createdAt: rec.createdAt,
|
|
1602
1781
|
enforce: rec.enforce,
|
|
1782
|
+
certSubjects: [...rec.certSubjects],
|
|
1603
1783
|
};
|
|
1604
1784
|
}
|
|
1605
1785
|
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
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).
|
|
1830
|
+
for (const rec of this.nicks.values()) {
|
|
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;
|
|
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 };
|
|
1848
|
+
}
|
|
1849
|
+
return { ok: false, reason: 'untrusted certificate' };
|
|
1850
|
+
}
|
|
1851
|
+
|
|
1852
|
+
addCertFP(nick: string, subject: string): boolean {
|
|
1853
|
+
const rec = this.nicks.get(folded(nick));
|
|
1854
|
+
if (rec === undefined) return false;
|
|
1855
|
+
if (rec.certSubjects.includes(subject)) return true;
|
|
1856
|
+
rec.certSubjects.push(subject);
|
|
1857
|
+
return true;
|
|
1858
|
+
}
|
|
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
|
+
|
|
1869
|
+
removeCertFP(nick: string, subject: string): boolean {
|
|
1870
|
+
const rec = this.nicks.get(folded(nick));
|
|
1871
|
+
if (rec === undefined) return false;
|
|
1872
|
+
const idx = rec.certSubjects.indexOf(subject);
|
|
1873
|
+
if (idx === -1) return false;
|
|
1874
|
+
rec.certSubjects.splice(idx, 1);
|
|
1640
1875
|
return true;
|
|
1641
1876
|
}
|
|
1642
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
|
+
|
|
1643
1889
|
/**
|
|
1644
1890
|
* Returns the full stored nick row (including the scrypt-hashed
|
|
1645
1891
|
* credential), or `undefined` when the nick is not registered. Defensive
|
|
@@ -1654,7 +1900,14 @@ export class InMemoryServicesStore implements ServicesStore {
|
|
|
1654
1900
|
*/
|
|
1655
1901
|
protected getStoredNick(nick: string): ServicesNickRow | undefined {
|
|
1656
1902
|
const rec = this.nicks.get(folded(nick));
|
|
1657
|
-
return rec === undefined
|
|
1903
|
+
return rec === undefined
|
|
1904
|
+
? undefined
|
|
1905
|
+
: {
|
|
1906
|
+
...rec,
|
|
1907
|
+
certSubjects: [...rec.certSubjects],
|
|
1908
|
+
identifyFailures: [...rec.identifyFailures],
|
|
1909
|
+
credential: { ...rec.credential },
|
|
1910
|
+
};
|
|
1658
1911
|
}
|
|
1659
1912
|
|
|
1660
1913
|
// ----- ChanServ -----
|
|
@@ -2127,13 +2380,99 @@ export abstract class PersistentServicesStore extends InMemoryServicesStore {
|
|
|
2127
2380
|
this.enqueue({ kind: 'upsertNick', row });
|
|
2128
2381
|
}
|
|
2129
2382
|
|
|
2130
|
-
override
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
|
|
2383
|
+
override setNickPassword(nick: string, newPassword: string): void {
|
|
2384
|
+
if (!this.isRegisteredNick(nick)) return;
|
|
2385
|
+
super.setNickPassword(nick, newPassword);
|
|
2386
|
+
// The `isRegisteredNick` guard above guarantees the nick is present, so
|
|
2387
|
+
// `getStoredNick` is defined — read it back to capture the freshly-hashed
|
|
2388
|
+
// credential so a backend reload keeps `verifyNick` working.
|
|
2389
|
+
const row = this.getStoredNick(nick) as ServicesNickRow;
|
|
2390
|
+
this.enqueue({ kind: 'upsertNick', row });
|
|
2391
|
+
}
|
|
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
|
+
|
|
2425
|
+
override addCertFP(nick: string, subject: string): boolean {
|
|
2426
|
+
if (!this.isRegisteredNick(nick)) return false;
|
|
2427
|
+
// Suppress the spurious write on an idempotent re-add (the subject is
|
|
2428
|
+
// already attached → super is a no-op). `getNick` returns a defensive
|
|
2429
|
+
// copy, so this check reflects the pre-call state. The
|
|
2430
|
+
// `isRegisteredNick` guard above guarantees the nick is present, so
|
|
2431
|
+
// `getNick` returns a defined record.
|
|
2432
|
+
const before = this.getNick(nick);
|
|
2433
|
+
const alreadyPresent = before?.certSubjects.includes(subject) === true;
|
|
2434
|
+
const attached = super.addCertFP(nick, subject);
|
|
2435
|
+
if (attached && !alreadyPresent) {
|
|
2436
|
+
const row = this.getStoredNick(nick) as ServicesNickRow;
|
|
2437
|
+
this.enqueue({ kind: 'upsertNick', row });
|
|
2438
|
+
}
|
|
2439
|
+
return attached;
|
|
2440
|
+
}
|
|
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 });
|
|
2135
2454
|
}
|
|
2136
|
-
return
|
|
2455
|
+
return attached;
|
|
2456
|
+
}
|
|
2457
|
+
|
|
2458
|
+
override removeCertFP(nick: string, subject: string): boolean {
|
|
2459
|
+
const removed = super.removeCertFP(nick, subject);
|
|
2460
|
+
if (removed) {
|
|
2461
|
+
const row = this.getStoredNick(nick) as ServicesNickRow;
|
|
2462
|
+
this.enqueue({ kind: 'upsertNick', row });
|
|
2463
|
+
}
|
|
2464
|
+
return removed;
|
|
2465
|
+
}
|
|
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 });
|
|
2137
2476
|
}
|
|
2138
2477
|
|
|
2139
2478
|
// ----- ChanServ -----
|
|
@@ -2454,7 +2793,7 @@ export interface MonitorPush {
|
|
|
2454
2793
|
* (nick → set of watcher connIds) and the **online set** the actor consults
|
|
2455
2794
|
* when synchronously resolving `MONITOR +` / `MONITOR S`.
|
|
2456
2795
|
*
|
|
2457
|
-
* Mirrors the {@link MessageStore}
|
|
2796
|
+
* Mirrors the {@link MessageStore} pattern: the port
|
|
2458
2797
|
* is **synchronous** (reducers must stay pure and free of IO). The actor
|
|
2459
2798
|
* layer drains pending pushes after each command via {@link drainPushes};
|
|
2460
2799
|
* adapters that need to fan notifications out across processes (CF
|
|
@@ -2681,7 +3020,7 @@ export interface ServerStatsSnapshot {
|
|
|
2681
3020
|
/**
|
|
2682
3021
|
* Port that aggregates network-wide statistics for `LUSERS` and `STATS`.
|
|
2683
3022
|
*
|
|
2684
|
-
* Unlike the synchronous {@link MotdProvider}
|
|
3023
|
+
* Unlike the synchronous {@link MotdProvider} ports
|
|
2685
3024
|
* (which reducers consume directly via {@link Ctx}), this port is
|
|
2686
3025
|
* **asynchronous** (`Promise`-returning) because the aggregation crosses
|
|
2687
3026
|
* every connection and channel in the deployment — it cannot be pre-loaded
|