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
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest';
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { CERT_FP_PREFIX, certIdentityFromSubject } from '../src/certfp';
|
|
3
|
+
import type { HashedAccountCredential } from '../src/credential-hashing';
|
|
2
4
|
import {
|
|
3
5
|
type ChannelLevelOp,
|
|
4
6
|
type ChannelRegisterResult,
|
|
@@ -11,9 +13,15 @@ import {
|
|
|
11
13
|
type ReadMarkerEntry,
|
|
12
14
|
type RegisteredNick,
|
|
13
15
|
type ServicesAuthResult,
|
|
16
|
+
type ServicesSnapshot,
|
|
14
17
|
type ServicesStore,
|
|
18
|
+
VERIFY_NICK_PRECOMPUTED_DUMMY,
|
|
19
|
+
resetVerifyNickTimingSpy,
|
|
20
|
+
setVerifyNickHashedPassword,
|
|
15
21
|
} from '../src/ports';
|
|
16
22
|
|
|
23
|
+
type VerifyHashedPasswordFn = (password: string, entry: HashedAccountCredential) => boolean;
|
|
24
|
+
|
|
17
25
|
const NOW = 1_700_000_000_000;
|
|
18
26
|
|
|
19
27
|
function store(): InMemoryServicesStore {
|
|
@@ -116,6 +124,540 @@ describe('InMemoryServicesStore — NickServ IDENTIFY (verifyNick)', () => {
|
|
|
116
124
|
});
|
|
117
125
|
});
|
|
118
126
|
|
|
127
|
+
// ============================================================================
|
|
128
|
+
// verifyNick timing equalisation — the unknown-nick miss must perform the
|
|
129
|
+
// same scrypt work as a wrong-password verify so callers (SASL PLAIN, PASS
|
|
130
|
+
// auth, NickServ IDENTIFY/SETPASS) cannot enumerate registered nicks by
|
|
131
|
+
// measuring latency.
|
|
132
|
+
// ============================================================================
|
|
133
|
+
|
|
134
|
+
describe('InMemoryServicesStore — verifyNick timing equalisation', () => {
|
|
135
|
+
afterEach(() => {
|
|
136
|
+
resetVerifyNickTimingSpy();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('runs exactly one dummy scrypt verify on an unknown nick — no O(1) short-circuit', () => {
|
|
140
|
+
const s = store();
|
|
141
|
+
const spy = vi.fn<VerifyHashedPasswordFn>(() => false);
|
|
142
|
+
setVerifyNickHashedPassword(spy);
|
|
143
|
+
try {
|
|
144
|
+
expect(s.verifyNick('ghost', 'hunter2').ok).toBe(false);
|
|
145
|
+
// Critical: a single dummy verify runs even though the nick is unknown,
|
|
146
|
+
// equalising wall-clock cost with the wrong-password path.
|
|
147
|
+
expect(spy).toHaveBeenCalledTimes(1);
|
|
148
|
+
// The dummy verify is fed the candidate password and runs against the
|
|
149
|
+
// fixed precomputed entry (never a real credential), exactly as a real
|
|
150
|
+
// verify would be — the cost is the point, the result is discarded.
|
|
151
|
+
expect(spy.mock.calls[0]?.[0]).toBe('hunter2');
|
|
152
|
+
expect(spy.mock.calls[0]?.[1]).toBe(VERIFY_NICK_PRECOMPUTED_DUMMY);
|
|
153
|
+
} finally {
|
|
154
|
+
setVerifyNickHashedPassword(null);
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('runs exactly one scrypt verify against the stored credential on a wrong password', () => {
|
|
159
|
+
const s = store();
|
|
160
|
+
s.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
161
|
+
const spy = vi.fn<VerifyHashedPasswordFn>(() => false);
|
|
162
|
+
setVerifyNickHashedPassword(spy);
|
|
163
|
+
try {
|
|
164
|
+
expect(s.verifyNick('alice', 'wrong').ok).toBe(false);
|
|
165
|
+
expect(spy).toHaveBeenCalledTimes(1);
|
|
166
|
+
expect(spy.mock.calls[0]?.[0]).toBe('wrong');
|
|
167
|
+
expect(spy.mock.calls[0]?.[1]?.account).toBe('alice');
|
|
168
|
+
} finally {
|
|
169
|
+
setVerifyNickHashedPassword(null);
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('costs the same single verify on the unknown-nick and wrong-password paths', () => {
|
|
174
|
+
const s = store();
|
|
175
|
+
s.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
176
|
+
const unknownNick = vi.fn<VerifyHashedPasswordFn>(() => false);
|
|
177
|
+
setVerifyNickHashedPassword(unknownNick);
|
|
178
|
+
try {
|
|
179
|
+
s.verifyNick('ghost', 'hunter2');
|
|
180
|
+
} finally {
|
|
181
|
+
setVerifyNickHashedPassword(null);
|
|
182
|
+
}
|
|
183
|
+
const wrongPassword = vi.fn<VerifyHashedPasswordFn>(() => false);
|
|
184
|
+
setVerifyNickHashedPassword(wrongPassword);
|
|
185
|
+
try {
|
|
186
|
+
s.verifyNick('alice', 'wrong');
|
|
187
|
+
} finally {
|
|
188
|
+
setVerifyNickHashedPassword(null);
|
|
189
|
+
}
|
|
190
|
+
expect(unknownNick).toHaveBeenCalledTimes(1);
|
|
191
|
+
expect(wrongPassword).toHaveBeenCalledTimes(1);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('still returns the identical failure result on the unknown-nick path', () => {
|
|
195
|
+
const s = store();
|
|
196
|
+
const spy = vi.fn<VerifyHashedPasswordFn>(() => false);
|
|
197
|
+
setVerifyNickHashedPassword(spy);
|
|
198
|
+
try {
|
|
199
|
+
expect(s.verifyNick('ghost', 'hunter2')).toEqual<ServicesAuthResult>({
|
|
200
|
+
ok: false,
|
|
201
|
+
reason: 'invalid credentials',
|
|
202
|
+
});
|
|
203
|
+
} finally {
|
|
204
|
+
setVerifyNickHashedPassword(null);
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('verifies a correct password through the same seam with no dummy call', () => {
|
|
209
|
+
const s = store();
|
|
210
|
+
s.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
211
|
+
const spy = vi.fn<VerifyHashedPasswordFn>((_pw, entry) => entry.account === 'alice');
|
|
212
|
+
setVerifyNickHashedPassword(spy);
|
|
213
|
+
try {
|
|
214
|
+
expect(s.verifyNick('alice', 'hunter2').ok).toBe(true);
|
|
215
|
+
expect(spy).toHaveBeenCalledTimes(1);
|
|
216
|
+
} finally {
|
|
217
|
+
setVerifyNickHashedPassword(null);
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
// ============================================================================
|
|
223
|
+
// NickServ CertFP — hydrate round-trip (legacy snapshot without certSubjects)
|
|
224
|
+
// ============================================================================
|
|
225
|
+
|
|
226
|
+
describe('InMemoryServicesStore — CertFP snapshot hydration', () => {
|
|
227
|
+
it('defaults certSubjects to [] when a legacy snapshot row omits the field', () => {
|
|
228
|
+
// Simulate a pre-certSubjects snapshot: the row is missing the field
|
|
229
|
+
// entirely (as if loaded from JSON written before the column existed).
|
|
230
|
+
const legacySnap: ServicesSnapshot = {
|
|
231
|
+
nicks: [
|
|
232
|
+
{
|
|
233
|
+
nick: 'alice',
|
|
234
|
+
account: 'alice',
|
|
235
|
+
email: 'a@x',
|
|
236
|
+
createdAt: NOW,
|
|
237
|
+
enforce: 'none',
|
|
238
|
+
certSubjects: undefined as unknown as string[],
|
|
239
|
+
identifyFailures: [],
|
|
240
|
+
pendingIdentifyFailureNotice: 0,
|
|
241
|
+
credential: { account: 'alice', algorithm: 'scrypt', salt: 's', hash: 'aGVsbG8=' },
|
|
242
|
+
},
|
|
243
|
+
],
|
|
244
|
+
channels: [],
|
|
245
|
+
access: [],
|
|
246
|
+
levels: [],
|
|
247
|
+
vhosts: [],
|
|
248
|
+
pendingVhosts: [],
|
|
249
|
+
memos: [],
|
|
250
|
+
readMarkers: [],
|
|
251
|
+
akills: [],
|
|
252
|
+
jupes: [],
|
|
253
|
+
};
|
|
254
|
+
const s = new InMemoryServicesStore({ clock: new FakeClock(NOW), snapshot: legacySnap });
|
|
255
|
+
const rec = s.getNick('alice');
|
|
256
|
+
expect(rec).toBeDefined();
|
|
257
|
+
if (rec !== undefined) {
|
|
258
|
+
expect(rec.certSubjects).toEqual([]);
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
// ============================================================================
|
|
264
|
+
// NickServ — failed-IDENTIFY throttle (record / frozen / clear)
|
|
265
|
+
// ============================================================================
|
|
266
|
+
|
|
267
|
+
describe('InMemoryServicesStore — identify-failure throttle', () => {
|
|
268
|
+
const WINDOW = { maxFailures: 3, windowMs: 300_000 };
|
|
269
|
+
|
|
270
|
+
it('records failures and freezes once the threshold is met inside the window', () => {
|
|
271
|
+
const clock = new FakeClock(NOW);
|
|
272
|
+
const s = new InMemoryServicesStore({ clock });
|
|
273
|
+
s.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
274
|
+
|
|
275
|
+
s.recordIdentifyFailure('alice');
|
|
276
|
+
s.recordIdentifyFailure('alice');
|
|
277
|
+
expect(s.isNickIdentifyFrozen('alice', WINDOW)).toBe(false);
|
|
278
|
+
s.recordIdentifyFailure('alice');
|
|
279
|
+
expect(s.isNickIdentifyFrozen('alice', WINDOW)).toBe(true);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it('prunes failures that aged out of the window (freeze lifts)', () => {
|
|
283
|
+
const clock = new FakeClock(NOW);
|
|
284
|
+
const s = new InMemoryServicesStore({ clock });
|
|
285
|
+
s.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
286
|
+
|
|
287
|
+
s.recordIdentifyFailure('alice'); // at NOW
|
|
288
|
+
clock.advance(400_000);
|
|
289
|
+
s.recordIdentifyFailure('alice'); // at NOW + 400_000 (first failure is 400s old)
|
|
290
|
+
s.recordIdentifyFailure('alice');
|
|
291
|
+
// Only the two recent failures survive the prune → below threshold.
|
|
292
|
+
expect(s.isNickIdentifyFrozen('alice', WINDOW)).toBe(false);
|
|
293
|
+
|
|
294
|
+
clock.advance(1);
|
|
295
|
+
s.recordIdentifyFailure('alice'); // third failure inside the window
|
|
296
|
+
expect(s.isNickIdentifyFrozen('alice', WINDOW)).toBe(true);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it('clearIdentifyFailures returns the queued notice count and resets the throttle', () => {
|
|
300
|
+
const clock = new FakeClock(NOW);
|
|
301
|
+
const s = new InMemoryServicesStore({ clock });
|
|
302
|
+
s.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
303
|
+
|
|
304
|
+
s.recordIdentifyFailure('alice');
|
|
305
|
+
s.recordIdentifyFailure('alice');
|
|
306
|
+
expect(s.clearIdentifyFailures('alice')).toBe(2);
|
|
307
|
+
|
|
308
|
+
// Both the window and the pending notice are reset.
|
|
309
|
+
expect(s.isNickIdentifyFrozen('alice', { maxFailures: 1, windowMs: 300_000 })).toBe(false);
|
|
310
|
+
expect(s.clearIdentifyFailures('alice')).toBe(0);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it('is a no-op for an unknown nick (record / frozen / clear)', () => {
|
|
314
|
+
const clock = new FakeClock(NOW);
|
|
315
|
+
const s = new InMemoryServicesStore({ clock });
|
|
316
|
+
s.recordIdentifyFailure('ghost');
|
|
317
|
+
expect(s.isNickIdentifyFrozen('ghost', WINDOW)).toBe(false);
|
|
318
|
+
expect(s.clearIdentifyFailures('ghost')).toBe(0);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it('round-trips the throttle state through a snapshot reload', () => {
|
|
322
|
+
const clock = new FakeClock(NOW);
|
|
323
|
+
const original = new InMemoryServicesStore({ clock });
|
|
324
|
+
original.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
325
|
+
original.recordIdentifyFailure('alice');
|
|
326
|
+
clock.advance(1);
|
|
327
|
+
original.recordIdentifyFailure('alice');
|
|
328
|
+
|
|
329
|
+
const reloaded = new InMemoryServicesStore({
|
|
330
|
+
clock: new FakeClock(NOW + 1),
|
|
331
|
+
snapshot: original.snapshot(),
|
|
332
|
+
});
|
|
333
|
+
// A re-identify from a fresh connection (fresh store) still sees the
|
|
334
|
+
// recorded failures and the queued owner notice.
|
|
335
|
+
expect(reloaded.isNickIdentifyFrozen('alice', { maxFailures: 2, windowMs: 300_000 })).toBe(
|
|
336
|
+
true,
|
|
337
|
+
);
|
|
338
|
+
expect(reloaded.clearIdentifyFailures('alice')).toBe(2);
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it('defaults the throttle fields when a legacy snapshot row omits them', () => {
|
|
342
|
+
const legacySnap: ServicesSnapshot = {
|
|
343
|
+
nicks: [
|
|
344
|
+
{
|
|
345
|
+
nick: 'alice',
|
|
346
|
+
account: 'alice',
|
|
347
|
+
email: 'a@x',
|
|
348
|
+
createdAt: NOW,
|
|
349
|
+
enforce: 'none',
|
|
350
|
+
certSubjects: [],
|
|
351
|
+
identifyFailures: undefined as unknown as number[],
|
|
352
|
+
pendingIdentifyFailureNotice: undefined as unknown as number,
|
|
353
|
+
credential: { account: 'alice', algorithm: 'scrypt', salt: 's', hash: 'aGVsbG8=' },
|
|
354
|
+
},
|
|
355
|
+
],
|
|
356
|
+
channels: [],
|
|
357
|
+
access: [],
|
|
358
|
+
levels: [],
|
|
359
|
+
vhosts: [],
|
|
360
|
+
pendingVhosts: [],
|
|
361
|
+
memos: [],
|
|
362
|
+
readMarkers: [],
|
|
363
|
+
akills: [],
|
|
364
|
+
jupes: [],
|
|
365
|
+
};
|
|
366
|
+
const s = new InMemoryServicesStore({ clock: new FakeClock(NOW), snapshot: legacySnap });
|
|
367
|
+
expect(s.isNickIdentifyFrozen('alice', { maxFailures: 1, windowMs: 300_000 })).toBe(false);
|
|
368
|
+
expect(s.clearIdentifyFailures('alice')).toBe(0);
|
|
369
|
+
// Mutations after a legacy hydrate behave like a fresh registration.
|
|
370
|
+
s.recordIdentifyFailure('alice');
|
|
371
|
+
expect(s.isNickIdentifyFrozen('alice', { maxFailures: 1, windowMs: 300_000 })).toBe(true);
|
|
372
|
+
});
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
// ============================================================================
|
|
376
|
+
// NickServ CertFP — SASL EXTERNAL surface (verifyCertFP / addCertFP /
|
|
377
|
+
// addCertFingerprint / removeCertFP)
|
|
378
|
+
// ============================================================================
|
|
379
|
+
|
|
380
|
+
describe('InMemoryServicesStore — NickServ CertFP (verifyCertFP)', () => {
|
|
381
|
+
it('returns ok with the account name for a known subject', () => {
|
|
382
|
+
const s = store();
|
|
383
|
+
s.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
384
|
+
s.addCertFP('alice', 'CN=alice');
|
|
385
|
+
expect(s.verifyCertFP(certIdentityFromSubject('CN=alice'))).toEqual<ServicesAuthResult>({
|
|
386
|
+
ok: true,
|
|
387
|
+
account: 'alice',
|
|
388
|
+
});
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
it('returns ok:false for an unknown subject', () => {
|
|
392
|
+
const s = store();
|
|
393
|
+
s.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
394
|
+
s.addCertFP('alice', 'CN=alice');
|
|
395
|
+
const out = s.verifyCertFP(certIdentityFromSubject('CN=evil'));
|
|
396
|
+
expect(out.ok).toBe(false);
|
|
397
|
+
if (out.ok) return;
|
|
398
|
+
expect(out.reason.length).toBeGreaterThan(0);
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
it('returns ok:false when no nick has any cert subject registered', () => {
|
|
402
|
+
const s = store();
|
|
403
|
+
s.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
404
|
+
expect(s.verifyCertFP(certIdentityFromSubject('CN=alice')).ok).toBe(false);
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
it('matches the subject value case-sensitively (values are NOT folded)', () => {
|
|
408
|
+
const s = store();
|
|
409
|
+
s.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
410
|
+
s.addCertFP('alice', 'CN=Alice');
|
|
411
|
+
expect(s.verifyCertFP(certIdentityFromSubject('CN=alice')).ok).toBe(false);
|
|
412
|
+
expect(s.verifyCertFP(certIdentityFromSubject('CN=Alice')).ok).toBe(true);
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
it('resolves the account that owns the subject (multiple accounts)', () => {
|
|
416
|
+
const s = store();
|
|
417
|
+
s.registerNick('alice', 'pw', 'a@e');
|
|
418
|
+
s.registerNick('bob', 'pw', 'b@e');
|
|
419
|
+
s.addCertFP('bob', 'CN=bob');
|
|
420
|
+
expect(s.verifyCertFP(certIdentityFromSubject('CN=bob'))).toEqual<ServicesAuthResult>({
|
|
421
|
+
ok: true,
|
|
422
|
+
account: 'bob',
|
|
423
|
+
});
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
it('exposes the registered subjects on the RegisteredNick record', () => {
|
|
427
|
+
const s = store();
|
|
428
|
+
s.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
429
|
+
s.addCertFP('alice', 'CN=alice');
|
|
430
|
+
s.addCertFP('alice', 'CN=alice-2');
|
|
431
|
+
const rec = s.getNick('alice');
|
|
432
|
+
expect(rec).toBeDefined();
|
|
433
|
+
if (rec === undefined) return;
|
|
434
|
+
expect(rec.certSubjects).toEqual(['CN=alice', 'CN=alice-2']);
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
it('defaults certSubjects to an empty array on a fresh registration', () => {
|
|
438
|
+
const s = store();
|
|
439
|
+
s.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
440
|
+
const rec = s.getNick('alice');
|
|
441
|
+
expect(rec).toBeDefined();
|
|
442
|
+
if (rec === undefined) return;
|
|
443
|
+
expect(rec.certSubjects).toEqual([]);
|
|
444
|
+
});
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
describe('InMemoryServicesStore — NickServ CertFP canonical-DN verify', () => {
|
|
448
|
+
it('verifies a semantically-equal DN spelling (whitespace + ordering differ)', () => {
|
|
449
|
+
const s = store();
|
|
450
|
+
s.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
451
|
+
s.addCertFP('alice', 'CN=alice , O = Example Inc');
|
|
452
|
+
const presented = certIdentityFromSubject('O=Example Inc,CN=alice');
|
|
453
|
+
expect(s.verifyCertFP(presented)).toEqual<ServicesAuthResult>({
|
|
454
|
+
ok: true,
|
|
455
|
+
account: 'alice',
|
|
456
|
+
});
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
it('migrates a legacy verbatim binding to the canonical form on first use', () => {
|
|
460
|
+
const s = store();
|
|
461
|
+
s.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
462
|
+
s.addCertFP('alice', 'CN=alice , O=Example Inc');
|
|
463
|
+
s.verifyCertFP(certIdentityFromSubject('O=Example Inc,CN=alice'));
|
|
464
|
+
expect(s.getNick('alice')?.certSubjects).toEqual(['cn=alice,o=Example Inc']);
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
it('leaves an already-canonical binding untouched on verify', () => {
|
|
468
|
+
const s = store();
|
|
469
|
+
s.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
470
|
+
s.addCertFP('alice', 'cn=alice');
|
|
471
|
+
s.verifyCertFP(certIdentityFromSubject('CN=alice'));
|
|
472
|
+
expect(s.getNick('alice')?.certSubjects).toEqual(['cn=alice']);
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
it('rejects when the presented DN differs beyond spacing/ordering/type case', () => {
|
|
476
|
+
const s = store();
|
|
477
|
+
s.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
478
|
+
s.addCertFP('alice', 'CN=alice,O=Example Inc');
|
|
479
|
+
expect(s.verifyCertFP(certIdentityFromSubject('CN=alice,O=Evil Inc')).ok).toBe(false);
|
|
480
|
+
});
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
describe('InMemoryServicesStore — NickServ CertFP fingerprint verify', () => {
|
|
484
|
+
it('verifies via a fingerprint binding alone (subject spelling irrelevant)', () => {
|
|
485
|
+
const s = store();
|
|
486
|
+
s.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
487
|
+
s.addCertFingerprint('alice', 'aa112233445566778899aabbccddeeff');
|
|
488
|
+
expect(
|
|
489
|
+
s.verifyCertFP(certIdentityFromSubject('CN=whatever', 'aa112233445566778899aabbccddeeff')),
|
|
490
|
+
).toEqual<ServicesAuthResult>({ ok: true, account: 'alice' });
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
it('prefers the fingerprint binding over a competing DN binding', () => {
|
|
494
|
+
const s = store();
|
|
495
|
+
s.registerNick('alice', 'pw', 'a@e');
|
|
496
|
+
s.registerNick('bob', 'pw', 'b@e');
|
|
497
|
+
// bob is bound by the cert subject DN; alice is bound by the cert DER
|
|
498
|
+
// fingerprint. Both would match the presented cert — the fingerprint
|
|
499
|
+
// binding must win.
|
|
500
|
+
s.addCertFP('bob', 'CN=shared,O=TwoCerts');
|
|
501
|
+
s.addCertFingerprint('alice', 'aa112233445566778899aabbccddeeff');
|
|
502
|
+
expect(
|
|
503
|
+
s.verifyCertFP(
|
|
504
|
+
certIdentityFromSubject('CN=shared,O=TwoCerts', 'aa112233445566778899aabbccddeeff'),
|
|
505
|
+
),
|
|
506
|
+
).toEqual<ServicesAuthResult>({ ok: true, account: 'alice' });
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
it('normalises fingerprint spelling (colons, case) before comparing', () => {
|
|
510
|
+
const s = store();
|
|
511
|
+
s.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
512
|
+
s.addCertFingerprint('alice', 'AA:11:22:33');
|
|
513
|
+
const out = s.verifyCertFP(certIdentityFromSubject('CN=alice', 'aa112233'));
|
|
514
|
+
expect(out.ok).toBe(true);
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
it('pins the platform fingerprint onto a DN-bound account on first verify', () => {
|
|
518
|
+
const s = store();
|
|
519
|
+
s.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
520
|
+
s.addCertFP('alice', 'CN=alice');
|
|
521
|
+
s.verifyCertFP(certIdentityFromSubject('CN=alice', 'aa112233'));
|
|
522
|
+
expect(s.getNick('alice')?.certSubjects).toContain(`${CERT_FP_PREFIX}aa112233`);
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
it('migrates a legacy binding and pins the fingerprint in one first use', () => {
|
|
526
|
+
const s = store();
|
|
527
|
+
s.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
528
|
+
s.addCertFP('alice', 'CN=alice , O=Example Inc');
|
|
529
|
+
s.verifyCertFP(certIdentityFromSubject('O=Example Inc,CN=alice', 'aa112233'));
|
|
530
|
+
expect(s.getNick('alice')?.certSubjects).toEqual([
|
|
531
|
+
'cn=alice,o=Example Inc',
|
|
532
|
+
`${CERT_FP_PREFIX}aa112233`,
|
|
533
|
+
]);
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
it('does not pin a fingerprint twice on repeated verifies', () => {
|
|
537
|
+
const s = store();
|
|
538
|
+
s.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
539
|
+
s.addCertFP('alice', 'CN=alice');
|
|
540
|
+
s.verifyCertFP(certIdentityFromSubject('CN=alice', 'aa112233'));
|
|
541
|
+
s.verifyCertFP(certIdentityFromSubject('CN=alice', 'aa112233'));
|
|
542
|
+
const fps = s.getNick('alice')?.certSubjects.filter((e) => e.startsWith(CERT_FP_PREFIX));
|
|
543
|
+
expect(fps).toEqual([`${CERT_FP_PREFIX}aa112233`]);
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
it('rejects an unknown fingerprint and unknown DN (unknown cert)', () => {
|
|
547
|
+
const s = store();
|
|
548
|
+
s.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
549
|
+
s.addCertFP('alice', 'CN=alice');
|
|
550
|
+
s.addCertFingerprint('alice', 'aa112233');
|
|
551
|
+
expect(s.verifyCertFP(certIdentityFromSubject('CN=evil', 'ffffffffffffffff')).ok).toBe(false);
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
it('falls back to the DN arm when the fingerprint matches nothing', () => {
|
|
555
|
+
const s = store();
|
|
556
|
+
s.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
557
|
+
s.addCertFP('alice', 'CN=alice');
|
|
558
|
+
// Platform-supplied fingerprint is unknown to the store; the DN binding
|
|
559
|
+
// still authorises the cert.
|
|
560
|
+
expect(s.verifyCertFP(certIdentityFromSubject('CN=alice', 'ffffffffffffffff')).ok).toBe(true);
|
|
561
|
+
});
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
describe('InMemoryServicesStore — NickServ CertFP mutators (addCertFP / addCertFingerprint / removeCertFP)', () => {
|
|
565
|
+
it('addCertFP attaches a subject to a registered nick', () => {
|
|
566
|
+
const s = store();
|
|
567
|
+
s.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
568
|
+
s.addCertFP('alice', 'CN=alice');
|
|
569
|
+
expect(s.getNick('alice')?.certSubjects).toEqual(['CN=alice']);
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
it('addCertFP is a no-op on an unregistered nick', () => {
|
|
573
|
+
const s = store();
|
|
574
|
+
s.addCertFP('ghost', 'CN=ghost');
|
|
575
|
+
expect(s.verifyCertFP(certIdentityFromSubject('CN=ghost')).ok).toBe(false);
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
it('addCertFP does not duplicate an already-attached subject', () => {
|
|
579
|
+
const s = store();
|
|
580
|
+
s.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
581
|
+
s.addCertFP('alice', 'CN=alice');
|
|
582
|
+
s.addCertFP('alice', 'CN=alice');
|
|
583
|
+
expect(s.getNick('alice')?.certSubjects).toEqual(['CN=alice']);
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
it('addCertFP folds the nick case-insensitively', () => {
|
|
587
|
+
const s = store();
|
|
588
|
+
s.registerNick('Alice', 'hunter2', 'alice@example.com');
|
|
589
|
+
s.addCertFP('alice', 'CN=alice');
|
|
590
|
+
expect(s.verifyCertFP(certIdentityFromSubject('CN=alice'))).toEqual<ServicesAuthResult>({
|
|
591
|
+
ok: true,
|
|
592
|
+
account: 'Alice',
|
|
593
|
+
});
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
it('addCertFingerprint attaches a fingerprint binding to a registered nick', () => {
|
|
597
|
+
const s = store();
|
|
598
|
+
s.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
599
|
+
expect(s.addCertFingerprint('alice', 'AA:11')).toBe(true);
|
|
600
|
+
expect(s.getNick('alice')?.certSubjects).toEqual([`${CERT_FP_PREFIX}aa11`]);
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
it('addCertFingerprint does not duplicate an equivalent fingerprint', () => {
|
|
604
|
+
const s = store();
|
|
605
|
+
s.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
606
|
+
s.addCertFingerprint('alice', 'aa11');
|
|
607
|
+
s.addCertFingerprint('alice', 'AA:11');
|
|
608
|
+
expect(s.getNick('alice')?.certSubjects).toEqual([`${CERT_FP_PREFIX}aa11`]);
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
it('addCertFingerprint is a no-op on an unregistered nick', () => {
|
|
612
|
+
const s = store();
|
|
613
|
+
expect(s.addCertFingerprint('ghost', 'aa11')).toBe(false);
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
it('removeCertFP detaches a subject from a registered nick', () => {
|
|
617
|
+
const s = store();
|
|
618
|
+
s.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
619
|
+
s.addCertFP('alice', 'CN=alice');
|
|
620
|
+
expect(s.removeCertFP('alice', 'CN=alice')).toBe(true);
|
|
621
|
+
expect(s.getNick('alice')?.certSubjects).toEqual([]);
|
|
622
|
+
expect(s.verifyCertFP(certIdentityFromSubject('CN=alice')).ok).toBe(false);
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
it('removeCertFP detaches a stored fingerprint binding entry', () => {
|
|
626
|
+
const s = store();
|
|
627
|
+
s.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
628
|
+
s.addCertFingerprint('alice', 'aa11');
|
|
629
|
+
expect(s.removeCertFP('alice', `${CERT_FP_PREFIX}aa11`)).toBe(true);
|
|
630
|
+
expect(s.getNick('alice')?.certSubjects).toEqual([]);
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
it('removeCertFP returns false when the subject is not attached', () => {
|
|
634
|
+
const s = store();
|
|
635
|
+
s.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
636
|
+
expect(s.removeCertFP('alice', 'CN=alice')).toBe(false);
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
it('removeCertFP returns false on an unregistered nick', () => {
|
|
640
|
+
const s = store();
|
|
641
|
+
expect(s.removeCertFP('ghost', 'CN=ghost')).toBe(false);
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
it('removeCertFP folds the nick case-insensitively but matches the subject byte-exactly', () => {
|
|
645
|
+
const s = store();
|
|
646
|
+
s.registerNick('Alice', 'hunter2', 'alice@example.com');
|
|
647
|
+
s.addCertFP('alice', 'CN=Alice');
|
|
648
|
+
expect(s.removeCertFP('ALICE', 'CN=alice')).toBe(false);
|
|
649
|
+
expect(s.removeCertFP('ALICE', 'CN=Alice')).toBe(true);
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
it('dropNick removes the attached cert subjects along with the registration', () => {
|
|
653
|
+
const s = store();
|
|
654
|
+
s.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
655
|
+
s.addCertFP('alice', 'CN=alice');
|
|
656
|
+
s.dropNick('alice');
|
|
657
|
+
expect(s.verifyCertFP(certIdentityFromSubject('CN=alice')).ok).toBe(false);
|
|
658
|
+
});
|
|
659
|
+
});
|
|
660
|
+
|
|
119
661
|
describe('InMemoryServicesStore — NickServ DROP (dropNick)', () => {
|
|
120
662
|
it('returns true and removes the registration for a known nick', () => {
|
|
121
663
|
const s = store();
|
|
@@ -175,6 +717,29 @@ describe('InMemoryServicesStore — NickServ SET ENFORCE (setNickEnforce)', () =
|
|
|
175
717
|
});
|
|
176
718
|
});
|
|
177
719
|
|
|
720
|
+
describe('InMemoryServicesStore — NickServ SET PASSWORD (setNickPassword)', () => {
|
|
721
|
+
it('overwrites the credential so the new password verifies and the old does not', () => {
|
|
722
|
+
const s = store();
|
|
723
|
+
s.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
724
|
+
s.setNickPassword('alice', 'newpassword');
|
|
725
|
+
expect(s.verifyNick('alice', 'newpassword').ok).toBe(true);
|
|
726
|
+
expect(s.verifyNick('alice', 'hunter2').ok).toBe(false);
|
|
727
|
+
});
|
|
728
|
+
|
|
729
|
+
it('folds the nick case-insensitively', () => {
|
|
730
|
+
const s = store();
|
|
731
|
+
s.registerNick('Alice', 'hunter2', 'alice@example.com');
|
|
732
|
+
s.setNickPassword('alice', 'newpassword');
|
|
733
|
+
expect(s.verifyNick('ALICE', 'newpassword').ok).toBe(true);
|
|
734
|
+
});
|
|
735
|
+
|
|
736
|
+
it('is a no-op on an unregistered nick (the original credential, if any, is untouched)', () => {
|
|
737
|
+
const s = store();
|
|
738
|
+
s.setNickPassword('ghost', 'newpassword');
|
|
739
|
+
expect(s.isRegisteredNick('ghost')).toBe(false);
|
|
740
|
+
});
|
|
741
|
+
});
|
|
742
|
+
|
|
178
743
|
describe('InMemoryServicesStore — NickServ INFO (getNick)', () => {
|
|
179
744
|
it('returns the full record for a registered nick', () => {
|
|
180
745
|
const s = store();
|
|
@@ -186,6 +751,7 @@ describe('InMemoryServicesStore — NickServ INFO (getNick)', () => {
|
|
|
186
751
|
email: 'alice@example.com',
|
|
187
752
|
createdAt: NOW,
|
|
188
753
|
enforce: 'none',
|
|
754
|
+
certSubjects: [],
|
|
189
755
|
});
|
|
190
756
|
});
|
|
191
757
|
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
frameToLines,
|
|
6
6
|
isWithinWsByteBudget,
|
|
7
7
|
sanitizeForTextMode,
|
|
8
|
+
splitFrameLines,
|
|
8
9
|
stripTrailingCrLf,
|
|
9
10
|
wsFrameModeFor,
|
|
10
11
|
} from '../src/ws-framing';
|
|
@@ -147,6 +148,33 @@ describe('frameToLines — mode coverage', () => {
|
|
|
147
148
|
}
|
|
148
149
|
});
|
|
149
150
|
|
|
151
|
+
// ============================================================================
|
|
152
|
+
// frameToLines — bare CR pass-through (rejection is the parser's job)
|
|
153
|
+
// ============================================================================
|
|
154
|
+
|
|
155
|
+
describe('framing passes a bare CR through to the parser', () => {
|
|
156
|
+
// Framing only recognizes complete CR-LF / LF terminators. A bare CR has
|
|
157
|
+
// no LF partner, so no framing mode treats it as a boundary: the line is
|
|
158
|
+
// emitted intact and the shared parser is the single place that rejects
|
|
159
|
+
// it (loudly, with a parse error) rather than silently dropping frames.
|
|
160
|
+
|
|
161
|
+
it('spec-text keeps a bare mid-line CR inside the single line', () => {
|
|
162
|
+
expect(frameToLines('PRIVMSG #foo :a\rb', 'spec-text')).toEqual(['PRIVMSG #foo :a\rb']);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('spec-binary keeps a bare mid-line CR inside the single line', () => {
|
|
166
|
+
expect(frameToLines('PRIVMSG #foo :a\rb', 'spec-binary')).toEqual(['PRIVMSG #foo :a\rb']);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('legacy keeps a bare CR (no CR-LF boundary to split on)', () => {
|
|
170
|
+
expect(splitFrameLines('PRIVMSG #foo :a\rb')).toEqual(['PRIVMSG #foo :a\rb']);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('stripTrailingCrLf leaves a bare mid-line CR untouched', () => {
|
|
174
|
+
expect(stripTrailingCrLf('PRIVMSG #foo :a\rb')).toBe('PRIVMSG #foo :a\rb');
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
150
178
|
// ============================================================================
|
|
151
179
|
// isWithinWsByteBudget — the IRCv3 WS message byte cap
|
|
152
180
|
// ============================================================================
|
|
@@ -167,6 +195,23 @@ describe('isWithinWsByteBudget', () => {
|
|
|
167
195
|
it('accepts a short frame', () => {
|
|
168
196
|
expect(isWithinWsByteBudget('NICK bob')).toBe(true);
|
|
169
197
|
});
|
|
198
|
+
|
|
199
|
+
it('rejects a multi-byte frame that fits 510 UTF-16 units but exceeds 510 bytes', () => {
|
|
200
|
+
// 300 'é' = 300 code units (inside the unit budget) but 600 UTF-8 bytes.
|
|
201
|
+
const frame = 'é'.repeat(300);
|
|
202
|
+
expect(frame.length).toBeLessThanOrEqual(MAX_WS_MESSAGE_BYTES);
|
|
203
|
+
expect(isWithinWsByteBudget(frame)).toBe(false);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('accepts a multi-byte frame whose UTF-8 length is exactly 510 bytes', () => {
|
|
207
|
+
// 255 'é' = 255 code units, exactly 510 UTF-8 bytes. Byte accounting
|
|
208
|
+
// must not over-reject.
|
|
209
|
+
expect(isWithinWsByteBudget('é'.repeat(255))).toBe(true);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('rejects a 511-é frame (1022 bytes) that once passed the unit cap', () => {
|
|
213
|
+
expect(isWithinWsByteBudget('é'.repeat(511))).toBe(false);
|
|
214
|
+
});
|
|
170
215
|
});
|
|
171
216
|
|
|
172
217
|
// ============================================================================
|
|
@@ -8,7 +8,12 @@ export default defineConfig({
|
|
|
8
8
|
provider: 'v8',
|
|
9
9
|
all: false,
|
|
10
10
|
include: ['src/**/*.ts'],
|
|
11
|
-
|
|
11
|
+
// `index.ts` files are barrel re-exports (no runtime logic);
|
|
12
|
+
// `messages.ts` is type-only (`interface` declarations that
|
|
13
|
+
// TypeScript erases to an empty module). v8 scores both 0%
|
|
14
|
+
// because they contain no executable statements — excluding
|
|
15
|
+
// them keeps the 100% threshold honest about real coverage.
|
|
16
|
+
exclude: ['src/**/index.ts', 'src/**/messages.ts'],
|
|
12
17
|
reporter: ['text', 'html', 'json-summary'],
|
|
13
18
|
thresholds: {
|
|
14
19
|
lines: 100,
|