serverless-ircd 0.10.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +28 -0
- package/.github/workflows/deploy-cf-tcp.yml +26 -2
- package/.github/workflows/deploy-cf.yml +26 -0
- package/CHANGELOG.md +289 -0
- package/README.md +153 -20
- package/apps/aws-stack/bin/aws.ts +36 -0
- package/apps/aws-stack/package.json +1 -1
- package/apps/aws-stack/src/aws-stack.ts +221 -15
- package/apps/aws-stack/tests/stack.test.ts +450 -16
- package/apps/cf-tcp-container/Dockerfile +37 -5
- package/apps/cf-tcp-container/package.json +7 -2
- package/apps/cf-tcp-container/src/config-loader.ts +113 -2
- package/apps/cf-tcp-container/src/container-server.ts +256 -79
- package/apps/cf-tcp-container/src/main.ts +22 -7
- package/apps/cf-tcp-container/src/proxy-protocol.ts +112 -0
- package/apps/cf-tcp-container/terraform/spectrum.tf +40 -11
- package/apps/cf-tcp-container/tests/config-loader.test.ts +170 -0
- package/apps/cf-tcp-container/tests/container-server-tls.test.ts +382 -0
- package/apps/cf-tcp-container/tests/container-server.test.ts +358 -31
- package/apps/cf-tcp-container/tests/dockerfile.test.ts +110 -0
- package/apps/cf-tcp-container/tests/proxy-protocol.test.ts +187 -0
- package/apps/cf-tcp-container/tests/spectrum-terraform.test.ts +135 -0
- package/apps/cf-tcp-container/tests/tls-e2e.test.ts +5 -1
- package/apps/cf-tcp-container/wrangler.toml +17 -4
- package/apps/cf-worker/package.json +2 -2
- package/apps/cf-worker/src/worker.ts +77 -5
- package/apps/cf-worker/tests/raw-modules.d.ts +11 -0
- package/apps/cf-worker/tests/smoke.test.ts +4 -0
- package/apps/cf-worker/tests/wrangler-config.test.ts +47 -0
- package/apps/cf-worker/tests/ws-admission.test.ts +112 -0
- package/apps/cf-worker/tests/ws-rate-limit.test.ts +133 -0
- package/apps/cf-worker/wrangler.test.toml +15 -1
- package/apps/cf-worker/wrangler.toml +86 -9
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/config-loader.ts +14 -2
- package/apps/local-cli/src/line-scanner.ts +26 -0
- package/apps/local-cli/src/server.ts +23 -2
- package/apps/local-cli/tests/line-scanner.test.ts +64 -0
- package/apps/local-cli/tests/tcp.test.ts +29 -0
- package/apps/web/package.json +1 -1
- package/docs/AWS-Deployment.md +123 -22
- package/docs/AWS-TCP-Deployment.md +37 -2
- package/docs/Chat-History.md +55 -0
- package/docs/Cloudflare-Deployment-Guide.md +9 -2
- package/docs/Cloudflare-TCP-Deployment.md +135 -52
- package/docs/SASL-EXTERNAL.md +175 -0
- package/package.json +3 -3
- package/packages/aws-adapter/package.json +1 -1
- package/packages/aws-adapter/src/admission.ts +28 -13
- package/packages/aws-adapter/src/aws-runtime.ts +30 -3
- package/packages/aws-adapter/src/cdk-table-defs.ts +34 -6
- package/packages/aws-adapter/src/config-loader.ts +134 -6
- package/packages/aws-adapter/src/dynamo-services-store.ts +12 -0
- package/packages/aws-adapter/src/handlers/connect.ts +47 -1
- package/packages/aws-adapter/src/handlers/default.ts +95 -6
- package/packages/aws-adapter/src/handlers/index.ts +31 -2
- package/packages/aws-adapter/src/handlers/nlb-stream.ts +132 -8
- package/packages/aws-adapter/src/ip-admission.ts +79 -0
- package/packages/aws-adapter/src/serialize.ts +8 -0
- package/packages/aws-adapter/src/tables.ts +9 -0
- package/packages/aws-adapter/tests/admission.test.ts +60 -2
- package/packages/aws-adapter/tests/aws-harness.ts +23 -1
- package/packages/aws-adapter/tests/aws-runtime.test.ts +64 -0
- package/packages/aws-adapter/tests/config-loader.test.ts +151 -0
- package/packages/aws-adapter/tests/connect.test.ts +199 -2
- package/packages/aws-adapter/tests/default-frame-limit.test.ts +231 -0
- package/packages/aws-adapter/tests/default-occ.test.ts +10 -3
- package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +123 -1
- package/packages/aws-adapter/tests/handlers.test.ts +57 -1
- package/packages/aws-adapter/tests/nlb-secure.test.ts +362 -0
- package/packages/aws-adapter/tests/nlb-stream.test.ts +628 -9
- package/packages/cf-adapter/package.json +1 -1
- package/packages/cf-adapter/src/cf-runtime.ts +48 -9
- package/packages/cf-adapter/src/config-loader.ts +133 -8
- package/packages/cf-adapter/src/connection-do.ts +154 -21
- package/packages/cf-adapter/src/counter-do.ts +142 -0
- package/packages/cf-adapter/src/d1-services-store.ts +47 -5
- package/packages/cf-adapter/src/env.ts +88 -0
- package/packages/cf-adapter/src/index.ts +17 -1
- package/packages/cf-adapter/src/rate-limit-do.ts +87 -0
- package/packages/cf-adapter/tests/cf-runtime.test.ts +104 -15
- package/packages/cf-adapter/tests/config-loader.test.ts +159 -0
- package/packages/cf-adapter/tests/connection-do-counter.test.ts +165 -0
- package/packages/cf-adapter/tests/connection-do-frame-limit.test.ts +177 -0
- package/packages/cf-adapter/tests/connection-do-pure.test.ts +74 -5
- package/packages/cf-adapter/tests/connection-do-ws-spec-contract.test.ts +7 -4
- package/packages/cf-adapter/tests/counter-do.test.ts +181 -0
- package/packages/cf-adapter/tests/d1-services-store.test.ts +192 -1
- package/packages/cf-adapter/tests/rate-limit-do.test.ts +160 -0
- package/packages/cf-adapter/tests/worker/main.ts +4 -0
- package/packages/cf-adapter/wrangler.test.toml +18 -1
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/in-memory-runtime/src/in-memory-runtime.ts +25 -0
- package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +74 -0
- package/packages/irc-core/package.json +1 -1
- package/packages/irc-core/src/caps/capabilities.ts +20 -10
- package/packages/irc-core/src/certfp.ts +178 -0
- package/packages/irc-core/src/commands/cap.ts +10 -2
- package/packages/irc-core/src/commands/chanserv.ts +117 -14
- package/packages/irc-core/src/commands/chathistory.ts +13 -5
- package/packages/irc-core/src/commands/hostserv.ts +84 -8
- package/packages/irc-core/src/commands/index.ts +2 -1
- package/packages/irc-core/src/commands/invite.ts +1 -7
- package/packages/irc-core/src/commands/join.ts +1 -16
- package/packages/irc-core/src/commands/kick.ts +1 -8
- package/packages/irc-core/src/commands/list.ts +1 -8
- package/packages/irc-core/src/commands/mode.ts +1 -8
- package/packages/irc-core/src/commands/multiline.ts +4 -10
- package/packages/irc-core/src/commands/names.ts +53 -13
- package/packages/irc-core/src/commands/nickserv.ts +40 -1
- package/packages/irc-core/src/commands/oper.ts +361 -8
- package/packages/irc-core/src/commands/part.ts +4 -10
- package/packages/irc-core/src/commands/privmsg.ts +8 -4
- package/packages/irc-core/src/commands/registration.ts +146 -2
- package/packages/irc-core/src/commands/sasl.ts +136 -19
- package/packages/irc-core/src/commands/topic.ts +10 -12
- package/packages/irc-core/src/commands/who.ts +1 -8
- package/packages/irc-core/src/config.ts +393 -20
- package/packages/irc-core/src/effects.ts +24 -0
- package/packages/irc-core/src/flood-control.ts +10 -10
- package/packages/irc-core/src/frame-rate-limit.ts +82 -0
- package/packages/irc-core/src/index.ts +8 -0
- package/packages/irc-core/src/oper-hashing.ts +43 -0
- package/packages/irc-core/src/oper-lockout.ts +87 -0
- package/packages/irc-core/src/ports.ts +395 -36
- package/packages/irc-core/src/protocol/bytes.ts +65 -0
- package/packages/irc-core/src/protocol/channel-name.ts +37 -0
- package/packages/irc-core/src/protocol/index.ts +12 -1
- package/packages/irc-core/src/protocol/outbound.ts +43 -10
- package/packages/irc-core/src/protocol/parser.ts +79 -10
- package/packages/irc-core/src/state/connection.ts +13 -0
- package/packages/irc-core/src/types.ts +228 -13
- package/packages/irc-core/src/ws-framing.ts +5 -4
- package/packages/irc-core/tests/bytes.test.ts +89 -0
- package/packages/irc-core/tests/certfp.test.ts +117 -0
- package/packages/irc-core/tests/commands/cap.test.ts +76 -2
- package/packages/irc-core/tests/commands/chanserv.test.ts +166 -0
- package/packages/irc-core/tests/commands/chathistory.test.ts +140 -0
- package/packages/irc-core/tests/commands/hostserv.test.ts +316 -0
- package/packages/irc-core/tests/commands/join.test.ts +78 -1
- package/packages/irc-core/tests/commands/names.test.ts +193 -0
- package/packages/irc-core/tests/commands/nickserv.test.ts +182 -2
- package/packages/irc-core/tests/commands/oper.test.ts +560 -2
- package/packages/irc-core/tests/commands/privmsg.test.ts +16 -0
- package/packages/irc-core/tests/commands/registration.test.ts +463 -1
- package/packages/irc-core/tests/commands/sasl.test.ts +596 -7
- package/packages/irc-core/tests/commands/topic.test.ts +137 -2
- package/packages/irc-core/tests/commands/unified-account.test.ts +2 -0
- package/packages/irc-core/tests/config.test.ts +534 -2
- package/packages/irc-core/tests/effects.test.ts +14 -0
- package/packages/irc-core/tests/flood-control.test.ts +29 -1
- package/packages/irc-core/tests/frame-rate-limit.test.ts +98 -0
- package/packages/irc-core/tests/oper-hashing.test.ts +60 -0
- package/packages/irc-core/tests/oper-lockout.test.ts +74 -0
- package/packages/irc-core/tests/outbound.test.ts +148 -0
- package/packages/irc-core/tests/parser.test.ts +287 -5
- package/packages/irc-core/tests/persistent-services-store.test.ts +141 -0
- package/packages/irc-core/tests/ports.test.ts +99 -7
- package/packages/irc-core/tests/services-store.test.ts +376 -14
- package/packages/irc-core/tests/ws-framing.test.ts +45 -0
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +123 -8
- package/packages/irc-server/src/dispatch.ts +1 -0
- package/packages/irc-server/src/index.ts +7 -0
- package/packages/irc-server/src/redact.ts +159 -0
- package/packages/irc-server/src/runtime.ts +14 -0
- package/packages/irc-server/src/transport.ts +28 -1
- package/packages/irc-server/tests/actor.test.ts +544 -7
- package/packages/irc-server/tests/dispatch.test.ts +31 -0
- package/packages/irc-server/tests/redact.test.ts +198 -0
- package/packages/irc-server/tests/runtime.test.ts +2 -0
- package/packages/irc-server/tests/transport.test.ts +66 -0
- package/packages/irc-test-support/package.json +1 -1
- package/packages/irc-test-support/src/in-memory-harness.ts +4 -0
- package/scripts/package.json +1 -1
- package/tools/ci-hardening/package.json +2 -2
- package/tools/ci-hardening/src/cf-deploy-cli.ts +3 -0
- package/tools/ci-hardening/src/cf-deploy.ts +118 -0
- package/tools/ci-hardening/src/deploy-hostname.ts +118 -0
- package/tools/ci-hardening/src/env-var-drift.ts +192 -0
- package/tools/ci-hardening/src/hostname-guard.ts +11 -0
- package/tools/ci-hardening/src/index.ts +17 -0
- package/tools/ci-hardening/tests/__wrangler_missing__.toml +2 -0
- package/tools/ci-hardening/tests/__wrangler_placeholder__.toml +3 -0
- package/tools/ci-hardening/tests/__wrangler_real__.toml +3 -0
- package/tools/ci-hardening/tests/cf-deploy.test.ts +200 -0
- package/tools/ci-hardening/tests/deploy-hostname.test.ts +348 -0
- package/tools/ci-hardening/tests/env-var-drift.test.ts +284 -0
- package/tools/ci-hardening/vitest.config.ts +5 -1
- package/tools/hash-oper-cred.ts +85 -0
- package/tools/load-test/package.json +1 -1
- package/tools/tcp-ws-forwarder/package.json +1 -1
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
2
|
import type { IrcMessage } from '../src/protocol/messages';
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
IrcParseError,
|
|
5
|
+
MAX_INPUT_BYTES,
|
|
6
|
+
MAX_TAGS,
|
|
7
|
+
MAX_TAG_BYTES,
|
|
8
|
+
parse,
|
|
9
|
+
parseTags,
|
|
10
|
+
} from '../src/protocol/parser';
|
|
4
11
|
|
|
5
12
|
describe('parse — golden vectors', () => {
|
|
6
13
|
it('parses a simple command with a channel and trailing text', () => {
|
|
@@ -125,6 +132,79 @@ describe('parse — message-tags (IRCv3)', () => {
|
|
|
125
132
|
});
|
|
126
133
|
});
|
|
127
134
|
|
|
135
|
+
describe('parse — tag-map prototype hardening (magic tag keys)', () => {
|
|
136
|
+
// IRCv3 Message Tags: tag KEYS are attacker-controlled. On a map built
|
|
137
|
+
// from a plain object literal, magic keys hit `Object.prototype`
|
|
138
|
+
// accessors instead of creating own properties: `__proto__=x` invokes
|
|
139
|
+
// the prototype setter (a no-op for a string RHS — the tag is silently
|
|
140
|
+
// dropped), `toString` / `constructor` / `hasOwnProperty` become own
|
|
141
|
+
// data properties that shadow inherited methods, and even when absent,
|
|
142
|
+
// inherited members are reachable through tag lookups. The tag map must
|
|
143
|
+
// have a null prototype so every tag is an own data property and no
|
|
144
|
+
// inherited member leaks into the tag namespace.
|
|
145
|
+
|
|
146
|
+
it('keeps a `__proto__` tag as an own data property instead of invoking the setter', () => {
|
|
147
|
+
const tags = parse('@__proto__=x PRIVMSG #foo :hi').tags;
|
|
148
|
+
expect(Object.hasOwn(tags, '__proto__')).toBe(true);
|
|
149
|
+
expect(tags.__proto__).toBe('x');
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('keeps a valueless `__proto__` tag as an own property with empty-string value', () => {
|
|
153
|
+
const tags = parse('@__proto__;a=b PRIVMSG #foo :hi').tags;
|
|
154
|
+
expect(Object.hasOwn(tags, '__proto__')).toBe(true);
|
|
155
|
+
expect(tags.__proto__).toBe('');
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('builds the tag map with a null prototype', () => {
|
|
159
|
+
expect(Object.getPrototypeOf(parse('@a=b PRIVMSG #foo :hi').tags)).toBe(null);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('exposes no inherited `toString` / `constructor` / `hasOwnProperty` when no such tag exists', () => {
|
|
163
|
+
const tags = parse('@a=b PRIVMSG #foo :hi').tags;
|
|
164
|
+
expect(tags.toString).toBeUndefined();
|
|
165
|
+
expect(tags.constructor).toBeUndefined();
|
|
166
|
+
expect(tags.hasOwnProperty).toBeUndefined();
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('does not shadow `Object.prototype.toString` on any other object after `@toString=x`', () => {
|
|
170
|
+
const tags = parse('@toString=x PRIVMSG #foo :hi').tags;
|
|
171
|
+
expect(Object.hasOwn(tags, 'toString')).toBe(true);
|
|
172
|
+
expect(tags.toString).toBe('x');
|
|
173
|
+
const unrelated = {};
|
|
174
|
+
expect(unrelated.toString()).toBe('[object Object]');
|
|
175
|
+
expect(Object.keys(unrelated)).toEqual([]);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('round-trips every magic key as an own data property', () => {
|
|
179
|
+
const tags = parse(
|
|
180
|
+
'@__proto__=p;constructor=c;toString=t;hasOwnProperty=h PRIVMSG #foo :hi',
|
|
181
|
+
).tags;
|
|
182
|
+
expect(Object.hasOwn(tags, '__proto__')).toBe(true);
|
|
183
|
+
expect(tags.__proto__).toBe('p');
|
|
184
|
+
expect(Object.hasOwn(tags, 'constructor')).toBe(true);
|
|
185
|
+
expect(tags.constructor).toBe('c');
|
|
186
|
+
expect(Object.hasOwn(tags, 'toString')).toBe(true);
|
|
187
|
+
expect(tags.toString).toBe('t');
|
|
188
|
+
expect(Object.hasOwn(tags, 'hasOwnProperty')).toBe(true);
|
|
189
|
+
expect(tags.hasOwnProperty).toBe('h');
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('round-trips every magic key as a valueless tag', () => {
|
|
193
|
+
const tags = parse('@__proto__;constructor;toString;hasOwnProperty PRIVMSG #foo :hi').tags;
|
|
194
|
+
expect(tags.__proto__).toBe('');
|
|
195
|
+
expect(tags.constructor).toBe('');
|
|
196
|
+
expect(tags.toString).toBe('');
|
|
197
|
+
expect(tags.hasOwnProperty).toBe('');
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('leaves every `Object.prototype` member untouched after parsing magic keys', () => {
|
|
201
|
+
parse('@__proto__=p;constructor=c;toString=t;hasOwnProperty=h PRIVMSG #foo :hi');
|
|
202
|
+
expect(Object.prototype.toString).toBeTypeOf('function');
|
|
203
|
+
expect(Object.prototype.hasOwnProperty).toBeTypeOf('function');
|
|
204
|
+
expect(Object.getPrototypeOf({})).toBe(Object.prototype);
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
|
|
128
208
|
describe('parse — framing tolerance', () => {
|
|
129
209
|
it('strips a trailing CR LF', () => {
|
|
130
210
|
expect(parse('PING :tok\r\n').params).toEqual(['tok']);
|
|
@@ -134,14 +214,89 @@ describe('parse — framing tolerance', () => {
|
|
|
134
214
|
expect(parse('PING :tok\n').params).toEqual(['tok']);
|
|
135
215
|
});
|
|
136
216
|
|
|
137
|
-
it('preserves
|
|
138
|
-
// The CR/LF strip regex is anchored to the END of the line only;
|
|
139
|
-
// embedded in a trailing param
|
|
140
|
-
|
|
217
|
+
it('preserves an embedded LF in a trailing param (CR-only hardening)', () => {
|
|
218
|
+
// The CR/LF strip regex is anchored to the END of the line only; an LF
|
|
219
|
+
// embedded in a trailing param survives into the parsed value. Bare CR
|
|
220
|
+
// used to survive too — it is now rejected outright (see the bare-CR
|
|
221
|
+
// suite below); the hardening is deliberately CR-only.
|
|
141
222
|
expect(parse('PRIVMSG #foo :a\nb').params[1]).toBe('a\nb');
|
|
142
223
|
});
|
|
143
224
|
});
|
|
144
225
|
|
|
226
|
+
describe('parse — bare CR rejection', () => {
|
|
227
|
+
// After the trailing CR/LF strip, ANY remaining CR in the body is bare by
|
|
228
|
+
// definition: a mid-line CR-LF would have split the frame at the transport
|
|
229
|
+
// layer, so a surviving CR means frame control characters are being
|
|
230
|
+
// smuggled inside message content. Each case asserts the error class AND
|
|
231
|
+
// the diagnostic message so the guard is pinned against sibling throws.
|
|
232
|
+
|
|
233
|
+
it('rejects a bare CR inside a trailing param', () => {
|
|
234
|
+
expect(() => parse('PRIVMSG #foo :hello\rworld')).toThrow(IrcParseError);
|
|
235
|
+
expect(() => parse('PRIVMSG #foo :hello\rworld')).toThrow('bare CR');
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it('rejects a bare CR inside a middle param', () => {
|
|
239
|
+
expect(() => parse('PRIVMSG #fo\ro :hi')).toThrow(IrcParseError);
|
|
240
|
+
expect(() => parse('PRIVMSG #fo\ro :hi')).toThrow('bare CR');
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('rejects a bare CR that precedes a valid trailing CRLF terminator', () => {
|
|
244
|
+
expect(() => parse('PRIVMSG #foo :a\rb\r\n')).toThrow(IrcParseError);
|
|
245
|
+
expect(() => parse('PRIVMSG #foo :a\rb\r\n')).toThrow('bare CR');
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('rejects a bare CR in the tag section', () => {
|
|
249
|
+
expect(() => parse('@time=n\row PRIVMSG #foo :hi')).toThrow(IrcParseError);
|
|
250
|
+
expect(() => parse('@time=n\row PRIVMSG #foo :hi')).toThrow('bare CR');
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it('rejects a bare CR in the source prefix', () => {
|
|
254
|
+
expect(() => parse(':ni\rck!u@h PRIVMSG #foo :hi')).toThrow(IrcParseError);
|
|
255
|
+
expect(() => parse(':ni\rck!u@h PRIVMSG #foo :hi')).toThrow('bare CR');
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it('still accepts a line whose only CR is part of the trailing CRLF terminator', () => {
|
|
259
|
+
expect(parse('PING :tok\r\n').params).toEqual(['tok']);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it('treats a trailing bare CR (no LF) as a frame remnant and strips it', () => {
|
|
263
|
+
// The trailing strip removes the whole trailing CR/LF run, so a lone
|
|
264
|
+
// trailing CR is a terminator remnant (tolerated for sloppy framers),
|
|
265
|
+
// not body content — nothing bare survives the strip.
|
|
266
|
+
expect(parse('PING :tok\r').params).toEqual(['tok']);
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
describe('parse — tag-value control-character rejection (IRCv3 message-tags)', () => {
|
|
271
|
+
// The IRCv3 Message Tags spec forbids CR, LF and NUL in tag values — not
|
|
272
|
+
// just literally, but via their escape sequences too. The line-level bare
|
|
273
|
+
// CR guard cannot see these: the wire bytes are `\\r`, not CR, so the
|
|
274
|
+
// check must fire on the UNESCAPED value.
|
|
275
|
+
|
|
276
|
+
it('rejects a tag value whose escape sequence decodes to CR', () => {
|
|
277
|
+
expect(() => parse('@msg-id=x\\ry PRIVMSG #foo :hi')).toThrow(IrcParseError);
|
|
278
|
+
expect(() => parse('@msg-id=x\\ry PRIVMSG #foo :hi')).toThrow('tag value');
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it('rejects a tag value whose escape sequence decodes to LF', () => {
|
|
282
|
+
expect(() => parse('@msg-id=x\\ny PRIVMSG #foo :hi')).toThrow(IrcParseError);
|
|
283
|
+
expect(() => parse('@msg-id=x\\ny PRIVMSG #foo :hi')).toThrow('tag value');
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it('rejects a literal NUL in a tag value', () => {
|
|
287
|
+
expect(() => parse('@msg-id=x\0y PRIVMSG #foo :hi')).toThrow(IrcParseError);
|
|
288
|
+
expect(() => parse('@msg-id=x\0y PRIVMSG #foo :hi')).toThrow('tag value');
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it('still accepts tag values that use only the legal escapes', () => {
|
|
292
|
+
expect(parse('@a=x\\:y\\sz\\\\w PRIVMSG #foo :hi').tags).toEqual({ a: 'x;y z\\w' });
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it('still accepts a valueless tag (no value to validate)', () => {
|
|
296
|
+
expect(parse('@+away PRIVMSG #foo :hi').tags).toEqual({ '+away': '' });
|
|
297
|
+
});
|
|
298
|
+
});
|
|
299
|
+
|
|
145
300
|
describe('parse — malformed input', () => {
|
|
146
301
|
// Each case asserts both the error class AND the diagnostic message text.
|
|
147
302
|
// The message assertion is what pins the failure to the *correct* guard:
|
|
@@ -219,6 +374,23 @@ describe('parse — RFC 1459 §2.3 input line-length cap', () => {
|
|
|
219
374
|
expect(() => parse(line)).toThrow(IrcParseError);
|
|
220
375
|
});
|
|
221
376
|
|
|
377
|
+
it('rejects a multi-byte line that fits 512 UTF-16 units but exceeds 512 bytes', () => {
|
|
378
|
+
// "PRIVMSG #foo :" (14 ASCII) + 400 'é' = 414 code units, 814 UTF-8 bytes.
|
|
379
|
+
// The byte cap must see the 814, not the 414.
|
|
380
|
+
const line = `PRIVMSG #foo :${'é'.repeat(400)}`;
|
|
381
|
+
expect(line.length).toBeLessThanOrEqual(MAX_INPUT_BYTES);
|
|
382
|
+
expect(() => parse(line)).toThrow(IrcParseError);
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
it('accepts a multi-byte line whose UTF-8 length is exactly 512 bytes', () => {
|
|
386
|
+
// "PRIVMSG #foo :" (14 ASCII) + 249 'é' (2 bytes each) = 512 bytes in
|
|
387
|
+
// 263 code units. Byte accounting must not over-reject.
|
|
388
|
+
const body = 'é'.repeat(249);
|
|
389
|
+
const line = `PRIVMSG #foo :${body}`;
|
|
390
|
+
const parsed = parse(line);
|
|
391
|
+
expect(parsed.params).toEqual(['#foo', body]);
|
|
392
|
+
});
|
|
393
|
+
|
|
222
394
|
it('reports the actual byte cap in the error message for diagnostics', () => {
|
|
223
395
|
const body = 'x'.repeat(MAX_INPUT_BYTES);
|
|
224
396
|
try {
|
|
@@ -229,6 +401,16 @@ describe('parse — RFC 1459 §2.3 input line-length cap', () => {
|
|
|
229
401
|
expect((err as Error).message).toMatch(/512/u);
|
|
230
402
|
}
|
|
231
403
|
});
|
|
404
|
+
|
|
405
|
+
it('reports the UTF-8 byte count (not the code-unit count) in the error message', () => {
|
|
406
|
+
try {
|
|
407
|
+
parse(`PRIVMSG #foo :${'é'.repeat(400)}`);
|
|
408
|
+
expect.fail('expected parse to throw');
|
|
409
|
+
} catch (err) {
|
|
410
|
+
expect(err).toBeInstanceOf(IrcParseError);
|
|
411
|
+
expect((err as Error).message).toMatch(/814/u);
|
|
412
|
+
}
|
|
413
|
+
});
|
|
232
414
|
});
|
|
233
415
|
|
|
234
416
|
describe('parse — command token grammar (anchored regex)', () => {
|
|
@@ -312,3 +494,103 @@ describe('unescapeTagValue — boundary conditions', () => {
|
|
|
312
494
|
expect(parse('@a=x\\\\ PRIVMSG #foo :hi').tags.a).toBe('x\\');
|
|
313
495
|
});
|
|
314
496
|
});
|
|
497
|
+
|
|
498
|
+
describe('parse — IRCv3 tag count cap', () => {
|
|
499
|
+
// The IRCv3 Message Tags spec recommends at most 15 tags per message.
|
|
500
|
+
// Every tag is unescaped and (when message-tags is negotiated)
|
|
501
|
+
// re-serialized onto downstream lines, so tag count is an amplification
|
|
502
|
+
// lever, not just parse cost. These tests assert the error class AND the
|
|
503
|
+
// diagnostic message so the guard is pinned against sibling throws.
|
|
504
|
+
|
|
505
|
+
it('accepts a line with exactly 15 tags (at-limit)', () => {
|
|
506
|
+
const tags = Array.from({ length: MAX_TAGS }, (_, i) => `t${i}`);
|
|
507
|
+
const parsed = parse(`@${tags.join(';')} PRIVMSG #foo :hi`);
|
|
508
|
+
expect(Object.keys(parsed.tags)).toHaveLength(MAX_TAGS);
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
it('rejects a line with 16 tags', () => {
|
|
512
|
+
const tags = Array.from({ length: MAX_TAGS + 1 }, (_, i) => `t${i}`);
|
|
513
|
+
const line = `@${tags.join(';')} PRIVMSG #foo :hi`;
|
|
514
|
+
expect(() => parse(line)).toThrow(IrcParseError);
|
|
515
|
+
expect(() => parse(line)).toThrow('too many tags');
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
it('does not count empty tag entries toward the cap', () => {
|
|
519
|
+
// 15 real tags separated by doubled semicolons: the skipped empty
|
|
520
|
+
// entries are not tags, so the line stays at the limit.
|
|
521
|
+
const tags = Array.from({ length: MAX_TAGS }, (_, i) => `t${i}`);
|
|
522
|
+
const parsed = parse(`@${tags.join(';;')} PRIVMSG #foo :hi`);
|
|
523
|
+
expect(Object.keys(parsed.tags)).toHaveLength(MAX_TAGS);
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
it('reports the observed and maximum tag counts in the diagnostic', () => {
|
|
527
|
+
const tags = Array.from({ length: MAX_TAGS + 1 }, (_, i) => `t${i}`);
|
|
528
|
+
try {
|
|
529
|
+
parse(`@${tags.join(';')} PRIVMSG #foo :hi`);
|
|
530
|
+
expect.fail('expected parse to throw');
|
|
531
|
+
} catch (err) {
|
|
532
|
+
expect(err).toBeInstanceOf(IrcParseError);
|
|
533
|
+
expect((err as Error).message).toContain(`${MAX_TAGS + 1}`);
|
|
534
|
+
expect((err as Error).message).toContain(`${MAX_TAGS}`);
|
|
535
|
+
}
|
|
536
|
+
});
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
describe('parseTags — IRCv3 tag-section byte cap', () => {
|
|
540
|
+
// The IRCv3 Message Tags spec recommends a tag section of at most 8192
|
|
541
|
+
// bytes. Tested at the parseTags seam because the RFC 1459 512-byte line
|
|
542
|
+
// cap rejects any over-budget section before tags are reached — the
|
|
543
|
+
// guard holds the spec budget for parseTags callers independent of the
|
|
544
|
+
// line cap. Section here = tag data between '@' and its terminating
|
|
545
|
+
// space (both excluded).
|
|
546
|
+
|
|
547
|
+
it('accepts a tag section of exactly 8192 bytes (at-limit)', () => {
|
|
548
|
+
const section = `a=${'x'.repeat(MAX_TAG_BYTES - 2)}`;
|
|
549
|
+
const tags = parseTags(section, `@${section} PRIVMSG #foo :hi`);
|
|
550
|
+
expect(tags.a).toBe('x'.repeat(MAX_TAG_BYTES - 2));
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
it('rejects a tag section of 8193 bytes', () => {
|
|
554
|
+
const section = `a=${'x'.repeat(MAX_TAG_BYTES - 1)}`;
|
|
555
|
+
expect(() => parseTags(section, `@${section} PRIVMSG #foo :hi`)).toThrow(IrcParseError);
|
|
556
|
+
expect(() => parseTags(section, `@${section} PRIVMSG #foo :hi`)).toThrow('tag section exceeds');
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
it('measures UTF-8 bytes, not UTF-16 code units', () => {
|
|
560
|
+
// 4095 'é' after 'a=' is 4097 code units but exactly 8192 bytes →
|
|
561
|
+
// at-limit. One more 'é' crosses the byte budget while still being
|
|
562
|
+
// only ~4098 code units: byte accounting must be the binding measure.
|
|
563
|
+
const atLimit = `a=${'é'.repeat((MAX_TAG_BYTES - 2) / 2)}`;
|
|
564
|
+
expect(parseTags(atLimit, `@${atLimit} PRIVMSG #foo :hi`).a).toBe(
|
|
565
|
+
'é'.repeat((MAX_TAG_BYTES - 2) / 2),
|
|
566
|
+
);
|
|
567
|
+
const overLimit = `a=${'é'.repeat(MAX_TAG_BYTES / 2)}`;
|
|
568
|
+
expect(() => parseTags(overLimit, `@${overLimit} PRIVMSG #foo :hi`)).toThrow(
|
|
569
|
+
'tag section exceeds',
|
|
570
|
+
);
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
it('reports the budget and the observed byte count in the diagnostic', () => {
|
|
574
|
+
const section = `a=${'x'.repeat(MAX_TAG_BYTES - 1)}`;
|
|
575
|
+
try {
|
|
576
|
+
parseTags(section, `@${section} PRIVMSG #foo :hi`);
|
|
577
|
+
expect.fail('expected parseTags to throw');
|
|
578
|
+
} catch (err) {
|
|
579
|
+
expect(err).toBeInstanceOf(IrcParseError);
|
|
580
|
+
expect((err as Error).message).toContain(`${MAX_TAG_BYTES}`);
|
|
581
|
+
expect((err as Error).message).toContain(`${MAX_TAG_BYTES + 1}`);
|
|
582
|
+
}
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
it('checks the byte budget before the tag count', () => {
|
|
586
|
+
// A section violating BOTH caps reports the byte budget: the byte
|
|
587
|
+
// check is O(1) up front, the count check needs the split.
|
|
588
|
+
const entries = Array.from({ length: MAX_TAGS + 1 }, (_, i) => `t${i}=x`.padEnd(600, 'y'));
|
|
589
|
+
const section = entries.join(';');
|
|
590
|
+
expect(() => parseTags(section, `@${section} PRIVMSG #foo :hi`)).toThrow('tag section exceeds');
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
it('keeps the null-prototype tag map on the happy path', () => {
|
|
594
|
+
expect(Object.getPrototypeOf(parseTags('a=b', '@a=b PRIVMSG #foo :hi'))).toBe(null);
|
|
595
|
+
});
|
|
596
|
+
});
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import { describe, expect, it } from 'vitest';
|
|
13
|
+
import { certIdentityFromSubject } from '../src/certfp';
|
|
13
14
|
import {
|
|
14
15
|
FakeClock,
|
|
15
16
|
InMemoryServicesStore,
|
|
@@ -290,6 +291,67 @@ describe('PersistentServicesStore — per-mutator ops', () => {
|
|
|
290
291
|
expect(s.batches).toEqual([]);
|
|
291
292
|
});
|
|
292
293
|
|
|
294
|
+
it('recordIdentifyFailure → upsertNick with the recorded timestamps', async () => {
|
|
295
|
+
const clock = new FakeClock(NOW);
|
|
296
|
+
const s = new RecordingStore({ clock });
|
|
297
|
+
s.registerNick('alice', 'pw', 'a@x');
|
|
298
|
+
clock.advance(5);
|
|
299
|
+
s.recordIdentifyFailure('alice');
|
|
300
|
+
await s.flush();
|
|
301
|
+
const upserts = ops(s).filter((w) => w.kind === 'upsertNick');
|
|
302
|
+
// registerNick + recordIdentifyFailure → two upserts.
|
|
303
|
+
expect(upserts).toHaveLength(2);
|
|
304
|
+
const last = upserts[upserts.length - 1];
|
|
305
|
+
if (last?.kind === 'upsertNick') {
|
|
306
|
+
expect(last.row.identifyFailures).toEqual([NOW + 5]);
|
|
307
|
+
expect(last.row.pendingIdentifyFailureNotice).toBe(1);
|
|
308
|
+
}
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it('recordIdentifyFailure on an unregistered nick enqueues nothing', async () => {
|
|
312
|
+
const s = recordingStore();
|
|
313
|
+
s.recordIdentifyFailure('ghost');
|
|
314
|
+
await s.flush();
|
|
315
|
+
expect(s.batches).toEqual([]);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it('clearIdentifyFailures on an unregistered nick enqueues nothing and returns 0', async () => {
|
|
319
|
+
const s = recordingStore();
|
|
320
|
+
expect(s.clearIdentifyFailures('ghost')).toBe(0);
|
|
321
|
+
await s.flush();
|
|
322
|
+
expect(s.batches).toEqual([]);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it('clearIdentifyFailures → upsertNick resetting the throttle state', async () => {
|
|
326
|
+
const clock = new FakeClock(NOW);
|
|
327
|
+
const s = new RecordingStore({ clock });
|
|
328
|
+
s.registerNick('alice', 'pw', 'a@x');
|
|
329
|
+
s.recordIdentifyFailure('alice');
|
|
330
|
+
await s.flush();
|
|
331
|
+
expect(s.batches).toHaveLength(1); // register + record upserts
|
|
332
|
+
|
|
333
|
+
expect(s.clearIdentifyFailures('alice')).toBe(1);
|
|
334
|
+
await s.flush();
|
|
335
|
+
// The reset MUST produce its own write-behind batch.
|
|
336
|
+
expect(s.batches).toHaveLength(2);
|
|
337
|
+
const upserts = ops(s).filter((w) => w.kind === 'upsertNick');
|
|
338
|
+
const last = upserts[upserts.length - 1];
|
|
339
|
+
if (last?.kind === 'upsertNick') {
|
|
340
|
+
expect(last.row.identifyFailures).toEqual([]);
|
|
341
|
+
expect(last.row.pendingIdentifyFailureNotice).toBe(0);
|
|
342
|
+
}
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it('clearIdentifyFailures with nothing queued enqueues nothing', async () => {
|
|
346
|
+
const s = recordingStore();
|
|
347
|
+
s.registerNick('alice', 'pw', 'a@x');
|
|
348
|
+
await s.flush();
|
|
349
|
+
expect(s.batches).toHaveLength(1); // the registerNick upsert only
|
|
350
|
+
expect(s.clearIdentifyFailures('alice')).toBe(0);
|
|
351
|
+
await s.flush();
|
|
352
|
+
expect(s.batches).toHaveLength(1);
|
|
353
|
+
});
|
|
354
|
+
|
|
293
355
|
it('addCertFP → upsertNick when a new subject is attached', async () => {
|
|
294
356
|
const s = recordingStore();
|
|
295
357
|
s.registerNick('alice', 'pw', 'a@x');
|
|
@@ -621,6 +683,85 @@ describe('PersistentServicesStore — per-mutator ops', () => {
|
|
|
621
683
|
});
|
|
622
684
|
});
|
|
623
685
|
|
|
686
|
+
// ============================================================================
|
|
687
|
+
// CertFP — canonical-DN / fingerprint write-behind
|
|
688
|
+
// ============================================================================
|
|
689
|
+
|
|
690
|
+
describe('PersistentServicesStore — CertFP ops (verifyCertFP migration / addCertFingerprint)', () => {
|
|
691
|
+
function lastNickRow(s: RecordingStore): ServicesNickRow | undefined {
|
|
692
|
+
const upserts = s.batches.flat().filter((w) => w.kind === 'upsertNick');
|
|
693
|
+
const last = upserts[upserts.length - 1];
|
|
694
|
+
return last?.kind === 'upsertNick' ? last.row : undefined;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
it('verifyCertFP legacy-spelling migration re-persists the nick row', async () => {
|
|
698
|
+
const s = recordingStore();
|
|
699
|
+
s.registerNick('alice', 'pw', 'a@x');
|
|
700
|
+
s.addCertFP('alice', 'CN=alice , O=Example Inc');
|
|
701
|
+
await s.flush(); // drain the register/add writes
|
|
702
|
+
s.batches.length = 0;
|
|
703
|
+
|
|
704
|
+
const out = s.verifyCertFP(certIdentityFromSubject('O=Example Inc,CN=alice'));
|
|
705
|
+
expect(out.ok).toBe(true);
|
|
706
|
+
await s.flush();
|
|
707
|
+
expect(lastNickRow(s)?.certSubjects).toEqual(['cn=alice,o=Example Inc']);
|
|
708
|
+
});
|
|
709
|
+
|
|
710
|
+
it('verifyCertFP fingerprint pinning re-persists the nick row', async () => {
|
|
711
|
+
const s = recordingStore();
|
|
712
|
+
s.registerNick('alice', 'pw', 'a@x');
|
|
713
|
+
s.addCertFP('alice', 'CN=alice');
|
|
714
|
+
await s.flush();
|
|
715
|
+
s.batches.length = 0;
|
|
716
|
+
|
|
717
|
+
const out = s.verifyCertFP(certIdentityFromSubject('CN=alice', 'aa112233'));
|
|
718
|
+
expect(out.ok).toBe(true);
|
|
719
|
+
await s.flush();
|
|
720
|
+
expect(lastNickRow(s)?.certSubjects).toEqual(['cn=alice', 'fp:aa112233']);
|
|
721
|
+
});
|
|
722
|
+
|
|
723
|
+
it('verifyCertFP enqueues nothing when no binding changed (fingerprint arm)', async () => {
|
|
724
|
+
const s = recordingStore();
|
|
725
|
+
s.registerNick('alice', 'pw', 'a@x');
|
|
726
|
+
s.addCertFingerprint('alice', 'aa112233');
|
|
727
|
+
await s.flush();
|
|
728
|
+
s.batches.length = 0;
|
|
729
|
+
|
|
730
|
+
const out = s.verifyCertFP(certIdentityFromSubject('CN=alice', 'aa112233'));
|
|
731
|
+
expect(out.ok).toBe(true);
|
|
732
|
+
await s.flush();
|
|
733
|
+
expect(s.batches).toEqual([]);
|
|
734
|
+
});
|
|
735
|
+
|
|
736
|
+
it('verifyCertFP enqueues nothing on an unchanged canonical DN arm', async () => {
|
|
737
|
+
const s = recordingStore();
|
|
738
|
+
s.registerNick('alice', 'pw', 'a@x');
|
|
739
|
+
s.addCertFP('alice', 'cn=alice');
|
|
740
|
+
await s.flush();
|
|
741
|
+
s.batches.length = 0;
|
|
742
|
+
|
|
743
|
+
const out = s.verifyCertFP(certIdentityFromSubject('CN=alice'));
|
|
744
|
+
expect(out.ok).toBe(true);
|
|
745
|
+
await s.flush();
|
|
746
|
+
expect(s.batches).toEqual([]);
|
|
747
|
+
});
|
|
748
|
+
|
|
749
|
+
it('addCertFingerprint re-persists the nick row once per new binding', async () => {
|
|
750
|
+
const s = recordingStore();
|
|
751
|
+
s.registerNick('alice', 'pw', 'a@x');
|
|
752
|
+
await s.flush();
|
|
753
|
+
s.batches.length = 0;
|
|
754
|
+
|
|
755
|
+
expect(s.addCertFingerprint('alice', 'AA:11')).toBe(true);
|
|
756
|
+
s.addCertFingerprint('alice', 'aa11'); // normalised duplicate — no write
|
|
757
|
+
expect(s.addCertFingerprint('ghost', 'aa11')).toBe(false); // unknown nick
|
|
758
|
+
await s.flush();
|
|
759
|
+
const upserts = s.batches.flat().filter((w) => w.kind === 'upsertNick');
|
|
760
|
+
expect(upserts).toHaveLength(1);
|
|
761
|
+
expect(lastNickRow(s)?.certSubjects).toEqual(['fp:aa11']);
|
|
762
|
+
});
|
|
763
|
+
});
|
|
764
|
+
|
|
624
765
|
// ============================================================================
|
|
625
766
|
// helpers
|
|
626
767
|
// ============================================================================
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest';
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { certIdentityFromSubject } from '../src/certfp';
|
|
2
3
|
import {
|
|
3
4
|
CapturingLogger,
|
|
4
5
|
type Clock,
|
|
@@ -14,6 +15,7 @@ import {
|
|
|
14
15
|
SequentialIdFactory,
|
|
15
16
|
SystemClock,
|
|
16
17
|
UuidIdFactory,
|
|
18
|
+
constantTimeEquals,
|
|
17
19
|
} from '../src/ports';
|
|
18
20
|
|
|
19
21
|
describe('SystemClock', () => {
|
|
@@ -436,17 +438,22 @@ const _assertLogRecord: LogRecord = {
|
|
|
436
438
|
void _assertLogRecord;
|
|
437
439
|
|
|
438
440
|
describe('InMemoryMtlsIdentityProvider', () => {
|
|
439
|
-
it('returns the certificate
|
|
441
|
+
it('returns the certificate identity for a known connection id', () => {
|
|
440
442
|
const provider = new InMemoryMtlsIdentityProvider([
|
|
441
|
-
{ connId: 'c1', identity: 'CN=alice' },
|
|
442
|
-
{ connId: 'c2', identity: 'CN=bob' },
|
|
443
|
+
{ connId: 'c1', identity: certIdentityFromSubject('CN=alice') },
|
|
444
|
+
{ connId: 'c2', identity: certIdentityFromSubject('CN=bob', 'ff11') },
|
|
443
445
|
]);
|
|
444
|
-
expect(provider.getIdentity('c1')).
|
|
445
|
-
|
|
446
|
+
expect(provider.getIdentity('c1')).toEqual({
|
|
447
|
+
subject: 'CN=alice',
|
|
448
|
+
canonicalDn: 'cn=alice',
|
|
449
|
+
});
|
|
450
|
+
expect(provider.getIdentity('c2')?.fingerprint).toBe('ff11');
|
|
446
451
|
});
|
|
447
452
|
|
|
448
453
|
it('returns undefined for an unknown connection id', () => {
|
|
449
|
-
const provider = new InMemoryMtlsIdentityProvider([
|
|
454
|
+
const provider = new InMemoryMtlsIdentityProvider([
|
|
455
|
+
{ connId: 'c1', identity: certIdentityFromSubject('CN=alice') },
|
|
456
|
+
]);
|
|
450
457
|
expect(provider.getIdentity('nope')).toBeUndefined();
|
|
451
458
|
});
|
|
452
459
|
|
|
@@ -455,3 +462,88 @@ describe('InMemoryMtlsIdentityProvider', () => {
|
|
|
455
462
|
expect(provider.getIdentity('c1')).toBeUndefined();
|
|
456
463
|
});
|
|
457
464
|
});
|
|
465
|
+
|
|
466
|
+
// ============================================================================
|
|
467
|
+
// constantTimeEquals — constant-time string equality
|
|
468
|
+
// ============================================================================
|
|
469
|
+
|
|
470
|
+
describe('constantTimeEquals', () => {
|
|
471
|
+
it('returns true for equal strings', () => {
|
|
472
|
+
expect(constantTimeEquals('s3cret', 's3cret')).toBe(true);
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
it('returns true for two empty strings', () => {
|
|
476
|
+
expect(constantTimeEquals('', '')).toBe(true);
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
it('returns false when the first character differs', () => {
|
|
480
|
+
expect(constantTimeEquals('X3cret', 's3cret')).toBe(false);
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
it('returns false when the last character differs', () => {
|
|
484
|
+
expect(constantTimeEquals('s3creX', 's3cret')).toBe(false);
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
it('returns false when the attempt is a proper prefix of the secret', () => {
|
|
488
|
+
expect(constantTimeEquals('s3cr', 's3cret')).toBe(false);
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
it('returns false when the attempt is longer than the secret', () => {
|
|
492
|
+
expect(constantTimeEquals('s3cret-extra', 's3cret')).toBe(false);
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
it('returns false for an empty attempt against a non-empty secret', () => {
|
|
496
|
+
expect(constantTimeEquals('', 's3cret')).toBe(false);
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
it('returns false for a non-empty attempt against an empty secret', () => {
|
|
500
|
+
expect(constantTimeEquals('s3cret', '')).toBe(false);
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
it('returns true for equal multi-byte strings', () => {
|
|
504
|
+
expect(constantTimeEquals('pässwörd', 'pässwörd')).toBe(true);
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
it('returns false for unequal multi-byte strings', () => {
|
|
508
|
+
expect(constantTimeEquals('pässwörd', 'password')).toBe(false);
|
|
509
|
+
});
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
// Deterministic timing-bucket proof: instead of measuring wall-clock time the
|
|
513
|
+
// per-position read primitive (`charCodeAt`) is stubbed with a counter. A
|
|
514
|
+
// constant-time compare reads every position of both inputs up to the longer
|
|
515
|
+
// length, so the read count is identical across equal inputs, first-char
|
|
516
|
+
// mismatches, last-char mismatches, and length mismatches of the same
|
|
517
|
+
// magnitude — no distinguishable work bucket exists.
|
|
518
|
+
describe('constantTimeEquals — timing buckets (deterministic read counts)', () => {
|
|
519
|
+
function countReads(a: string, b: string): number {
|
|
520
|
+
const spy = vi.spyOn(String.prototype, 'charCodeAt');
|
|
521
|
+
try {
|
|
522
|
+
constantTimeEquals(a, b);
|
|
523
|
+
return spy.mock.calls.length;
|
|
524
|
+
} finally {
|
|
525
|
+
spy.mockRestore();
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
it('reads every position of both inputs for same-length secrets (2 × length)', () => {
|
|
530
|
+
expect(countReads('s3cret', 's3cret')).toBe(12);
|
|
531
|
+
expect(countReads('X3cret', 's3cret')).toBe(12);
|
|
532
|
+
expect(countReads('s3creX', 's3cret')).toBe(12);
|
|
533
|
+
expect(countReads('s3c!et', 's3cret')).toBe(12);
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
it('reads the longer length even when the attempt is a length-mismatched prefix', () => {
|
|
537
|
+
expect(countReads('s3cr', 's3cret')).toBe(12);
|
|
538
|
+
expect(countReads('', 's3cret')).toBe(12);
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
it('reads the attempt length when the attempt is longer than the secret', () => {
|
|
542
|
+
expect(countReads('s3cret-longer', 's3cret')).toBe(26);
|
|
543
|
+
expect(countReads('X3cret-longer', 's3cret')).toBe(26);
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
it('spends zero reads on two empty strings', () => {
|
|
547
|
+
expect(countReads('', '')).toBe(0);
|
|
548
|
+
});
|
|
549
|
+
});
|