diff --git a/.changeset/cookie-rfc-conformance-and-hardening.md b/.changeset/cookie-rfc-conformance-and-hardening.md new file mode 100644 index 0000000..d3b5581 --- /dev/null +++ b/.changeset/cookie-rfc-conformance-and-hardening.md @@ -0,0 +1,90 @@ +--- +"@zipbul/cookie": major +--- + +First release of `@zipbul/cookie`. RFC 6265 / RFC 6265bis-22 compliant cookie parser, serializer, signer, and AEAD encryptor for Bun. Aggregates four unreleased commits (`d941150` → `9c4835c`) plus the conformance/hardening pass below. + +## Initial feature set (commits d941150, b8fb79a, 8d2053e) + +- HMAC signing with `sha256` / `sha384` / `sha512`; rotation via `secrets: string[]`. +- AES-256-GCM encryption via Web Crypto; rotation via `encryptionSecret: string | string[]`. +- Cookie prefix validation: `__Secure-`, `__Host-`. +- Server-level cookie defaults (`httpOnly`, `secure`, `sameSite`, `path`, `domain`, `maxAge`, `expires`, `partitioned`) with per-cookie overrides via `createCookie()`. +- `secure: 'auto'` resolution against per-request `SerializeContext.isSecure`. +- `SameSite=None` requires `Secure` (RFC 6265bis §5.7). +- Partitioned (CHIPS) requires `Secure`; domain/path header-injection defense (`;`, `\r`, `\n`). +- 400-day Max-Age cap (RFC 6265bis §5.5). +- Cookie-name RFC 9110 token validation (`%` excluded for Bun.CookieMap interop). +- 4096-byte serialized cookie cap. +- `CookieJar`: request-scoped container with `get()` (Result-typed), `set()`, `delete()`, `has()`, `getRaw()`, `getSetCookieHeaders()`. Auto sign + encrypt on outbound, auto decrypt + unsign on inbound. +- Removed legacy `parse()` / `parseOne()` (replaced by `CookieJar` + Bun.CookieMap). +- Zero `node:crypto` dependency. + +## Cross-name replay defense + integer/integrity hardening (commit 9c4835c) + +- HMAC signs `cookie.name + 0x00 + cookie.value` — closes cross-name signature replay. +- AES-GCM uses `cookie.name` as AAD (NIST SP 800-38D §5.2.1) — closes cross-name ciphertext replay. +- Signing/encryption secrets enforced ≥ 32 characters (NIST SP 800-132). +- `secrets` array supports rotation: encrypt/sign uses first key, verify/decrypt iterates all (constant-time, no early-exit — position-based timing oracle measured 4.76× → 1.03×). +- `Max-Age` must be a finite integer (RFC 6265 §5.2.2): rejects `NaN`, `Infinity`, decimals at both `createCookie()` and `serialize()`. +- `CookieJar` error step distinguishes `unsign` failure (`SignatureVerificationFailed`) from `decrypt` failure (`DecryptionFailed`). + +## RFC / browser conformance — defects fixed (15) + +- `secure: 'auto'` no longer overrides explicit `secure` values in either direction (caller intent preserved via internal metadata map). +- `serialize()` now applies configured `httpOnly` / `path` / `sameSite` / `partitioned` defaults via `createCookie()`. +- `Expires` attribute capped at 400 days (RFC 6265bis §5.5), not just `Max-Age`. +- `__Host-` prefix validation rejects empty-string `Domain` (defense-in-depth). +- `createCookie` strips `null` attribute values (previously passed through). +- `Domain` validated against RFC 1034/1123 LDH (rejects `a..b.com`, `-bad.com`, etc.). +- Cookie size limit corrected: name+value ≤ 4096 octets (RFC 6265bis §5.6) — header total no longer rejected as a side-effect. +- Per-attribute 1024-octet cap added (RFC 6265bis §5.6). +- Prefix detection is now case-insensitive (`__host-`, `__SECURE-` are detected and validated). +- Default public-suffix check (single-label domains rejected) plus `publicSuffixCheck` hook for full PSL integration. +- `createCookie` performs eager validation (Max-Age, Expires, Domain, Path, size) — no longer deferred to `serialize()`. +- Secret entropy verified (≥ 8 distinct characters); error message now matches what the code enforces. +- `prefixValidation` default flipped to `true`. +- `wrapBunError` falls back to a dedicated `CookieParserError` reason instead of misclassifying as `InvalidCookieName`. +- `CookieJar.delete()` overrides `sameSite=none` / `secure='auto'` defaults locally so deletion always serializes regardless of request context. + +## Cryptographic hardening (8) + +- Keys derived via HKDF-SHA256/384/512 (RFC 5869, NIST SP 800-108) with package-specific salt and info parameters. +- 4-byte KID prefix embedded in HMAC signatures and AES-GCM ciphertexts; verification is strict KID match (no fallback). +- `onEncrypt({ keyIndex, counter })` hook for AES-GCM IV usage tracking (NIST SP 800-38D §8.3). +- `__Http-` / `__Host-Http-` prefix support (HttpOnly enforcement). +- `Priority=Low|Medium|High` attribute support. +- `CookieJar.getSetCookieHeaders()` parallelizes sign/encrypt across cookies (`Promise.all`). +- Per-instance HKDF-derived key cache. +- `test/{conformance,security,fuzz}/` suites tracked in source control. + +## Public API + +- `CookieParser.create(options?)` — entry point; returns parser with `createCookie`, `serialize`, `sign`, `unsign`, `encrypt`, `decrypt`, `validatePrefix`, `isSigningConfigured`, `isEncryptionConfigured`. +- `CookieJar` — request-scoped container. +- `CookieError` (with `reason: CookieErrorReason`). +- Types: `CookieParserOptions`, `CookieAttributes`, `SerializeContext`, `CookiePriority`, `SigningAlgorithm`, `CookieErrorReason`. + +## Standards alignment + +- RFC 6265 / RFC 6265bis-22 server-side normatives +- RFC 1034 / RFC 1123 (Domain LDH) +- RFC 6265bis §4.1.3.1/2, §5.7 (`__Secure-` / `__Host-` / `__Http-`) +- RFC 6265bis §5.5 (400-day cap on Expires and Max-Age) +- RFC 6265bis §5.6 (4096 / 1024 octet caps) +- CHIPS (Partitioned + Secure) +- NIST SP 800-38D (AES-256-GCM: 12-byte IV, 128-bit tag, AAD bound to cookie name) +- RFC 5869 / NIST SP 800-108 (HKDF) +- FIPS 198-1 (HMAC + constant-time verify) +- RFC 9110 §5.6.2 (cookie-name token grammar) + +## Notes on `CookieErrorReason` + +Reason codes are kebab-case strings (`'invalid-cookie-name'`, `'host-prefix-forbids-domain'`, etc.). Consumers matching by string value should use the `CookieErrorReason` enum. + +## Test coverage + +- 330 tests / 510 assertions / 99.49% line coverage / 99.62% function coverage +- Conformance: RFC 6265bis §§4.1.1, 4.1.2.1, 4.1.2.2, 4.1.2.7, 4.1.3.1, 4.1.3.2, 5.5, 5.6, 5.7; CHIPS; NIST SP 800-38D; FIPS 198-1; RFC 9110 §5.6.2 +- Security: header injection, cross-name signature/ciphertext replay, algorithm confusion, ciphertext truncation/tampering, signature malformation, prototype pollution, weak secrets, oversized payloads, control characters in name, percent in name, error-type leakage +- Fuzz: 200-run fast-check property tests for sign/encrypt roundtrip, IV randomness, name-binding, AAD-binding, tampering rejection, name validation, jar roundtrip, 4096-octet boundary, key rotation invariants diff --git a/bun.lock b/bun.lock index 52f4386..1094a3c 100644 --- a/bun.lock +++ b/bun.lock @@ -29,9 +29,16 @@ "@zipbul/http-adapter": "^0.1.0", }, }, + "packages/cookie": { + "name": "@zipbul/cookie", + "version": "0.0.0", + "dependencies": { + "@zipbul/result": "workspace:*", + }, + }, "packages/cors": { "name": "@zipbul/cors", - "version": "0.1.3", + "version": "0.1.4", "dependencies": { "@zipbul/result": "workspace:*", "@zipbul/shared": "workspace:*", @@ -40,7 +47,7 @@ }, "packages/multipart": { "name": "@zipbul/multipart", - "version": "0.1.0", + "version": "0.1.1", "dependencies": { "@zipbul/result": "workspace:*", "@zipbul/shared": "workspace:*", @@ -51,7 +58,7 @@ }, "packages/query-parser": { "name": "@zipbul/query-parser", - "version": "0.2.3", + "version": "0.2.4", "dependencies": { "@zipbul/result": "workspace:*", }, @@ -63,7 +70,7 @@ }, "packages/rate-limiter": { "name": "@zipbul/rate-limiter", - "version": "0.2.3", + "version": "0.2.4", "dependencies": { "@zipbul/result": "workspace:*", }, @@ -73,11 +80,11 @@ }, "packages/result": { "name": "@zipbul/result", - "version": "0.1.7", + "version": "1.0.0", }, "packages/router": { "name": "@zipbul/router", - "version": "0.2.2", + "version": "0.2.3", "dependencies": { "@zipbul/result": "workspace:*", "@zipbul/shared": "workspace:*", @@ -191,6 +198,8 @@ "@zipbul/compression": ["@zipbul/compression@workspace:packages/compression"], + "@zipbul/cookie": ["@zipbul/cookie@workspace:packages/cookie"], + "@zipbul/core": ["@zipbul/core@0.1.1", "", { "dependencies": { "@zipbul/baker": "^0.1.0", "@zipbul/common": "0.1.1", "@zipbul/logger": "0.1.1", "dotenv": "^16.4.5", "exponential-backoff": "^3.1.2" } }, "sha512-D1zBxaNB0YDDn2sgPMxsB3Zz5SxH780zTAbURDFKxKeLWXxJIsGusHwYoMRzszDTBi4OpRLLaDqyENQ4dNHCPw=="], "@zipbul/cors": ["@zipbul/cors@workspace:packages/cors"], diff --git a/packages/cookie/.npmignore b/packages/cookie/.npmignore new file mode 100644 index 0000000..6544567 --- /dev/null +++ b/packages/cookie/.npmignore @@ -0,0 +1,21 @@ +# Sources (dist/ is the only published artifact via "files") +src/ +index.ts + +# Tests +test/ +*.spec.ts +*.test.ts + +# Config +tsconfig.json +tsconfig.build.json +bunfig.toml + +# Dev +coverage/ +bench/ +docker-compose.yml + +# Docs (README/LICENSE/CHANGELOG auto-included by npm) +README.ko.md diff --git a/packages/cookie/TODO.md b/packages/cookie/TODO.md new file mode 100644 index 0000000..88a644b --- /dev/null +++ b/packages/cookie/TODO.md @@ -0,0 +1,32 @@ +# @zipbul/cookie TODO + +## 완료 + +- [x] `cloneCookieWithValue`에서 `||` → `??` 변경 (`maxAge: 0`, `path: ""`, `expires: 0` 등 falsy 유의미 값 소실 버그) +- [x] `options.ts` 추출 — `resolveCookieParserOptions()` + `validateCookieParserOptions()` (Result 패턴) +- [x] `types.ts` 추가 — `ResolvedCookieParserOptions`, `ResolvedCookieDefaults`, `SigningAlgorithm` +- [x] `@zipbul/result` 의존성 추가 +- [x] `create()` 리팩터 — resolve → validate → `if (isErr) throw` 패턴 +- [x] CookieParserOptions 확장 — 쿠키 기본 속성 (httpOnly, secure, sameSite, path, domain, maxAge, expires, partitioned) +- [x] `createCookie()` 메서드 — 파서 기본값 병합 + 개별 쿠키 override +- [x] 서명 알고리즘 설정 — algorithm 옵션 (`sha256` | `sha384` | `sha512`) +- [x] Prefix 자동 검증 — prefixValidation 옵션 +- [x] secure auto-detect — `secure: 'auto'` + `SerializeContext` +- [x] `serialize()` 확장 — nullable defaults 적용, auto-secure 해소, auto-prefix 검증 +- [x] RFC 6265 / RFC 6265bis-22 / RFC 1034·1123 / NIST SP 800-38D·800-108 / RFC 5869 / FIPS 198-1 / CHIPS 표준 부합 +- [x] HKDF-SHA256/384/512 키 도출 + 4-byte KID 박힘 (sign + encrypt 모두 strict 매칭) +- [x] `__Http-` / `__Host-Http-` prefix, `Priority=` attribute, `onEncrypt` IV 카운터 hook +- [x] `CookieJar.getSetCookieHeaders()` 병렬화 +- [x] `test/{conformance,security,fuzz}/` git 트래킹 (330 테스트 / 라인 커버리지 99.49%) + +## 계획 + +- 현재 cookie 파서 스코프 내 미해결 항목 없음. + +## 의도적으로 스코프 밖 + +이 패키지가 책임지지 않는 항목 — 별도 패키지에서 처리: + +- 세션 저장소, CSRF 토큰, observability/audit hook 통합 → 미들웨어 패키지 +- 전체 PSL 데이터 자동 동기화 → `psl` / `tldts` 등을 `publicSuffixCheck` 옵션에 주입 +- DBSC (Device-Bound Session Credentials) → 표준 미확정. cookie 파서가 아닌 인증 프로토콜 영역 diff --git a/packages/cookie/bunfig.toml b/packages/cookie/bunfig.toml new file mode 100644 index 0000000..938ad47 --- /dev/null +++ b/packages/cookie/bunfig.toml @@ -0,0 +1,13 @@ +[test] +onlyFailures = true +coverage = true +coverageReporter = ["text", "lcov"] +coverageThreshold = 0.95 +coveragePathIgnorePatterns = [ + "node_modules/**", + "dist/**", + "../result/**" +] + +[test.reporter] +dots = true diff --git a/packages/cookie/index.ts b/packages/cookie/index.ts new file mode 100644 index 0000000..b01c286 --- /dev/null +++ b/packages/cookie/index.ts @@ -0,0 +1,6 @@ +export { CookieParser } from './src/cookie-parser'; +export { CookieJar } from './src/cookie-jar'; +export { CookieError } from './src/interfaces'; +export type { CookieAttributes, CookieParserOptions, SerializeContext } from './src/interfaces'; +export { CookieErrorReason } from './src/enums'; +export type { SigningAlgorithm } from './src/types'; diff --git a/packages/cookie/package.json b/packages/cookie/package.json new file mode 100644 index 0000000..86540bd --- /dev/null +++ b/packages/cookie/package.json @@ -0,0 +1,31 @@ +{ + "name": "@zipbul/cookie", + "version": "0.0.0", + "type": "module", + "module": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "sideEffects": false, + "publishConfig": { + "provenance": true + }, + "scripts": { + "build": "bun build index.ts --outdir dist --target bun --format esm --packages external --production && tsc -p tsconfig.build.json", + "test": "bun test", + "coverage": "bun test --coverage" + }, + "dependencies": { + "@zipbul/result": "workspace:*" + }, + "engines": { + "bun": ">=1.3.0" + } +} diff --git a/packages/cookie/src/cookie-jar.spec.ts b/packages/cookie/src/cookie-jar.spec.ts new file mode 100644 index 0000000..ca1ff43 --- /dev/null +++ b/packages/cookie/src/cookie-jar.spec.ts @@ -0,0 +1,316 @@ +import { describe, expect, it } from 'bun:test'; +import { isErr } from '@zipbul/result'; +import type { Err } from '@zipbul/result'; + +import { CookieErrorReason } from './enums'; +import { CookieError, type CookieErrorData } from './interfaces'; +import { CookieParser } from './cookie-parser'; +import { CookieJar } from './cookie-jar'; + +describe('CookieJar', () => { + describe('has', () => { + it('should return true when cookie exists', () => { + const parser = CookieParser.create(); + const jar = new CookieJar(parser, 'session=abc; token=xyz'); + expect(jar.has('session')).toBe(true); + expect(jar.has('token')).toBe(true); + }); + + it('should return false when cookie does not exist', () => { + const parser = CookieParser.create(); + const jar = new CookieJar(parser, 'session=abc'); + expect(jar.has('missing')).toBe(false); + }); + + it('should return false for empty cookie header', () => { + const parser = CookieParser.create(); + const jar = new CookieJar(parser, ''); + expect(jar.has('session')).toBe(false); + }); + }); + + describe('getRaw', () => { + it('should return raw value without processing', () => { + const parser = CookieParser.create({ secrets: ['gHBB3MwkPytgNA9vApSMJRDqJIPMNXgLrHUKSJZy1Kg'], encryptionSecret: '9v7BAwKpXHWZnoKZIHV2XWch22HvF8bleOM6t4nc-A4' }); + const jar = new CookieJar(parser, 'session=raw-value; _ga=GA1.2.123'); + expect(jar.getRaw('session')).toBe('raw-value'); + expect(jar.getRaw('_ga')).toBe('GA1.2.123'); + }); + + it('should return undefined when cookie does not exist', () => { + const parser = CookieParser.create(); + const jar = new CookieJar(parser, ''); + expect(jar.getRaw('missing')).toBeUndefined(); + }); + }); + + describe('get', () => { + it('should return null when cookie does not exist', async () => { + const parser = CookieParser.create(); + const jar = new CookieJar(parser, ''); + const result = await jar.get('missing'); + expect(result).toBeNull(); + }); + + it('should return plain value when no signing or encryption configured', async () => { + const parser = CookieParser.create(); + const jar = new CookieJar(parser, 'session=hello'); + const result = await jar.get('session'); + expect(result).toBe('hello'); + }); + + it('should auto-unsign when signing configured', async () => { + const parser = CookieParser.create({ secrets: ['gHBB3MwkPytgNA9vApSMJRDqJIPMNXgLrHUKSJZy1Kg'] }); + const signed = parser.sign(new (await import('bun')).Cookie('session', 'data')); + const jar = new CookieJar(parser, `session=${signed.value}`); + const result = await jar.get('session'); + expect(result).toBe('data'); + }); + + it('should auto-decrypt when encryption configured', async () => { + const parser = CookieParser.create({ encryptionSecret: '9v7BAwKpXHWZnoKZIHV2XWch22HvF8bleOM6t4nc-A4' }); + const { Cookie } = await import('bun'); + const encrypted = await parser.encrypt(new Cookie('session', 'secret')); + const jar = new CookieJar(parser, `session=${encrypted.value}`); + const result = await jar.get('session'); + expect(result).toBe('secret'); + }); + + it('should auto-decrypt then auto-unsign when both configured', async () => { + const parser = CookieParser.create({ secrets: ['gHBB3MwkPytgNA9vApSMJRDqJIPMNXgLrHUKSJZy1Kg'], encryptionSecret: '9v7BAwKpXHWZnoKZIHV2XWch22HvF8bleOM6t4nc-A4' }); + const { Cookie } = await import('bun'); + const cookie = new Cookie('session', 'user:42'); + const signed = parser.sign(cookie); + const encrypted = await parser.encrypt(signed); + const jar = new CookieJar(parser, `session=${encrypted.value}`); + const result = await jar.get('session'); + expect(result).toBe('user:42'); + }); + + it('should return Err when signature verification fails', async () => { + const parser = CookieParser.create({ secrets: ['gHBB3MwkPytgNA9vApSMJRDqJIPMNXgLrHUKSJZy1Kg'] }); + const jar = new CookieJar(parser, 'session=tampered.invalidsig'); + const result = await jar.get('session'); + expect(isErr(result)).toBe(true); + expect((result as Err).data.reason).toBe( + CookieErrorReason.SignatureVerificationFailed, + ); + }); + + it('should return Err when decryption fails', async () => { + const parser = CookieParser.create({ encryptionSecret: '9v7BAwKpXHWZnoKZIHV2XWch22HvF8bleOM6t4nc-A4' }); + const jar = new CookieJar(parser, 'session=notvalidciphertext_padded_enough_xxxxxxxx'); + const result = await jar.get('session'); + expect(isErr(result)).toBe(true); + }); + + it('should return null for empty cookie header', async () => { + const parser = CookieParser.create({ secrets: ['gHBB3MwkPytgNA9vApSMJRDqJIPMNXgLrHUKSJZy1Kg'] }); + const jar = new CookieJar(parser, ''); + const result = await jar.get('session'); + expect(result).toBeNull(); + }); + + it('should handle multiple cookies and return correct one', async () => { + const parser = CookieParser.create({ secrets: ['gHBB3MwkPytgNA9vApSMJRDqJIPMNXgLrHUKSJZy1Kg'] }); + const { Cookie } = await import('bun'); + const signedA = parser.sign(new Cookie('a', 'val-a')); + const signedB = parser.sign(new Cookie('b', 'val-b')); + const jar = new CookieJar(parser, `a=${signedA.value}; b=${signedB.value}`); + expect(await jar.get('a')).toBe('val-a'); + expect(await jar.get('b')).toBe('val-b'); + expect(await jar.get('c')).toBeNull(); + }); + }); + + describe('set', () => { + it('should queue cookie for outbound', async () => { + const parser = CookieParser.create({ secrets: ['gHBB3MwkPytgNA9vApSMJRDqJIPMNXgLrHUKSJZy1Kg'] }); + const jar = new CookieJar(parser, ''); + jar.set('session', 'user:42'); + const headers = await jar.getSetCookieHeaders(); + expect(headers).toHaveLength(1); + expect(headers[0]).toContain('session='); + }); + + it('should apply parser defaults to set cookie', async () => { + const parser = CookieParser.create({ + secrets: ['gHBB3MwkPytgNA9vApSMJRDqJIPMNXgLrHUKSJZy1Kg'], + httpOnly: true, + secure: true, + path: '/', + }); + const jar = new CookieJar(parser, ''); + jar.set('session', 'data'); + const headers = await jar.getSetCookieHeaders(); + expect(headers[0]).toContain('HttpOnly'); + expect(headers[0]).toContain('Secure'); + expect(headers[0]).toContain('Path=/'); + }); + + it('should allow per-cookie attribute overrides', async () => { + const parser = CookieParser.create({ secrets: ['gHBB3MwkPytgNA9vApSMJRDqJIPMNXgLrHUKSJZy1Kg'], path: '/' }); + const jar = new CookieJar(parser, ''); + jar.set('token', 'jwt', { path: '/api' }); + const headers = await jar.getSetCookieHeaders(); + expect(headers[0]).toContain('Path=/api'); + }); + + it('should throw InvalidCookieName for invalid name', () => { + const parser = CookieParser.create(); + const jar = new CookieJar(parser, ''); + let caught: unknown; + try { + jar.set('bad name', 'v'); + } catch (e) { + caught = e; + } + expect(caught).toBeInstanceOf(CookieError); + expect((caught as CookieError).reason).toBe(CookieErrorReason.InvalidCookieName); + }); + + it('should overwrite previously set cookie with same name', async () => { + const parser = CookieParser.create(); + const jar = new CookieJar(parser, ''); + jar.set('session', 'first'); + jar.set('session', 'second'); + const headers = await jar.getSetCookieHeaders(); + expect(headers).toHaveLength(1); + expect(headers[0]).toContain('session=second'); + }); + }); + + describe('delete', () => { + it('should queue deletion cookie with maxAge 0', async () => { + const parser = CookieParser.create(); + const jar = new CookieJar(parser, ''); + jar.delete('session'); + const headers = await jar.getSetCookieHeaders(); + expect(headers).toHaveLength(1); + expect(headers[0]).toContain('session='); + expect(headers[0]).toContain('Max-Age=0'); + }); + + it('should not sign or encrypt deletion cookies', async () => { + const parser = CookieParser.create({ secrets: ['gHBB3MwkPytgNA9vApSMJRDqJIPMNXgLrHUKSJZy1Kg'], encryptionSecret: '9v7BAwKpXHWZnoKZIHV2XWch22HvF8bleOM6t4nc-A4' }); + const jar = new CookieJar(parser, ''); + jar.delete('session'); + const headers = await jar.getSetCookieHeaders(); + expect(headers).toHaveLength(1); + expect(headers[0]).toContain('session=;'); + }); + + it('should override previously set cookie with deletion', async () => { + const parser = CookieParser.create(); + const jar = new CookieJar(parser, ''); + jar.set('session', 'data'); + jar.delete('session'); + const headers = await jar.getSetCookieHeaders(); + expect(headers).toHaveLength(1); + expect(headers[0]).toContain('Max-Age=0'); + }); + + it('preserves explicit sameSite="none" when caller passes secure=true (cross-site delete)', async () => { + const parser = CookieParser.create(); + const jar = new CookieJar(parser, ''); + jar.delete('session', { sameSite: 'none', secure: true }); + const headers = await jar.getSetCookieHeaders({ isSecure: true }); + expect(headers[0]).toContain('SameSite=None'); + expect(headers[0]).toContain('Secure'); + }); + + it('preserves explicit sameSite="strict" on deletion', async () => { + const parser = CookieParser.create(); + const jar = new CookieJar(parser, ''); + jar.delete('session', { sameSite: 'strict' }); + const headers = await jar.getSetCookieHeaders(); + expect(headers[0]).toContain('SameSite=Strict'); + }); + + it('still defaults to lax when sameSite is omitted', async () => { + const parser = CookieParser.create(); + const jar = new CookieJar(parser, ''); + jar.delete('session'); + const headers = await jar.getSetCookieHeaders(); + expect(headers[0]).toContain('SameSite=Lax'); + }); + + it('drops inbound cookies whose value contains U+FFFD (Bun.CookieMap silent corruption guard)', () => { + const parser = CookieParser.create(); + const jar = new CookieJar(parser, 'good=ok; bad=hello%XXworld; also=fine'); + expect(jar.getRaw('good')).toBe('ok'); + expect(jar.getRaw('also')).toBe('fine'); + expect(jar.getRaw('bad')).toBeUndefined(); + expect(jar.has('bad')).toBe(false); + }); + + it('emits RFC 7231 IMF-fixdate Expires (with " GMT", not "-0000")', async () => { + const parser = CookieParser.create(); + const jar = new CookieJar(parser, ''); + jar.delete('session'); + const headers = await jar.getSetCookieHeaders(); + expect(headers[0]).toContain('Expires=Thu, 01 Jan 1970 00:00:00 GMT'); + expect(headers[0]).not.toContain('-0000'); + }); + }); + + describe('getSetCookieHeaders', () => { + it('should return empty array when no outbound cookies', async () => { + const parser = CookieParser.create(); + const jar = new CookieJar(parser, 'session=data'); + const headers = await jar.getSetCookieHeaders(); + expect(headers).toHaveLength(0); + }); + + it('should auto-sign outbound cookies when signing configured', async () => { + const parser = CookieParser.create({ secrets: ['gHBB3MwkPytgNA9vApSMJRDqJIPMNXgLrHUKSJZy1Kg'] }); + const jar = new CookieJar(parser, ''); + jar.set('session', 'data'); + const headers = await jar.getSetCookieHeaders(); + expect(headers[0]).toContain('session=data.'); + }); + + it('should auto-encrypt outbound cookies when encryption configured', async () => { + const parser = CookieParser.create({ encryptionSecret: '9v7BAwKpXHWZnoKZIHV2XWch22HvF8bleOM6t4nc-A4' }); + const jar = new CookieJar(parser, ''); + jar.set('session', 'secret'); + const headers = await jar.getSetCookieHeaders(); + expect(headers[0]).not.toContain('secret'); + }); + + it('should auto-sign then auto-encrypt when both configured', async () => { + const parser = CookieParser.create({ secrets: ['gHBB3MwkPytgNA9vApSMJRDqJIPMNXgLrHUKSJZy1Kg'], encryptionSecret: '9v7BAwKpXHWZnoKZIHV2XWch22HvF8bleOM6t4nc-A4' }); + const jar = new CookieJar(parser, ''); + jar.set('session', 'data'); + const headers = await jar.getSetCookieHeaders(); + expect(headers[0]).not.toContain('data'); + + // Verify roundtrip through jar + const jar2 = new CookieJar(parser, `session=${headers[0]!.split('=')[1]!.split(';')[0]}`); + const result = await jar2.get('session'); + expect(result).toBe('data'); + }); + + it('should pass serialize context for secure auto', async () => { + const parser = CookieParser.create({ secure: 'auto' }); + const jar = new CookieJar(parser, ''); + jar.set('session', 'v'); + + const httpsHeaders = await jar.getSetCookieHeaders({ isSecure: true }); + expect(httpsHeaders[0]).toContain('Secure'); + + const httpHeaders = await jar.getSetCookieHeaders({ isSecure: false }); + expect(httpHeaders[0]).not.toContain('Secure'); + }); + + it('should handle multiple set and delete in correct order', async () => { + const parser = CookieParser.create(); + const jar = new CookieJar(parser, ''); + jar.set('a', '1'); + jar.set('b', '2'); + jar.delete('c'); + const headers = await jar.getSetCookieHeaders(); + expect(headers).toHaveLength(3); + }); + }); +}); diff --git a/packages/cookie/src/cookie-jar.ts b/packages/cookie/src/cookie-jar.ts new file mode 100644 index 0000000..0b43272 --- /dev/null +++ b/packages/cookie/src/cookie-jar.ts @@ -0,0 +1,134 @@ +import { Cookie } from 'bun'; +import { err } from '@zipbul/result'; +import type { ResultAsync } from '@zipbul/result'; + +import { CookieErrorReason } from './enums'; +import { CookieError } from './interfaces'; +import type { CookieAttributes, CookieErrorData, SerializeContext } from './interfaces'; +import type { CookieParser } from './cookie-parser'; + +interface OutboundEntry { + readonly cookie: Cookie; + readonly deleted: boolean; +} + +type Step = 'decrypt' | 'unsign'; + +export class CookieJar { + private readonly inbound: ReadonlyMap; + private readonly outbound = new Map(); + + constructor( + private readonly parser: CookieParser, + cookieHeader: string, + ) { + const parsed = new Map(); + if (cookieHeader !== '') { + const map = new Bun.CookieMap(cookieHeader); + for (const [name, value] of map) { + // Bun.CookieMap silently substitutes U+FFFD for invalid percent-encoding (`%XX` malformed). + // Drop those entries — silent corruption of cookie values is unacceptable for crypto / app code. + if (value.includes('�') || name.includes('�')) continue; + parsed.set(name, value); + } + } + this.inbound = parsed; + } + + public has(name: string): boolean { + return this.inbound.has(name); + } + + public getRaw(name: string): string | undefined { + return this.inbound.get(name); + } + + public async get(name: string): ResultAsync { + const raw = this.inbound.get(name); + if (raw === undefined) return null; + + let cookie = new Cookie(name, raw); + + if (this.parser.isEncryptionConfigured) { + try { + cookie = await this.parser.decrypt(cookie); + } catch (thrown) { + return this.toErr(thrown, 'decrypt'); + } + } + + if (this.parser.isSigningConfigured) { + try { + cookie = await this.parser.unsign(cookie); + } catch (thrown) { + return this.toErr(thrown, 'unsign'); + } + } + + return cookie.value; + } + + public set(name: string, value: string, options?: CookieAttributes): void { + const cookie = this.parser.createCookie(name, value, options); + this.outbound.set(name, { cookie, deleted: false }); + } + + public delete(name: string, options?: CookieAttributes): void { + // For deletion, the parser may have defaults (e.g. sameSite='none' + secure='auto') that would + // throw at serialize time when the request is insecure. We only fill defaults — explicit user + // input is honored verbatim so cross-site deletions (sameSite:'none' + secure:true) are possible. + const overrides: CookieAttributes = { + ...options, + maxAge: 0, + expires: new Date(0), + }; + if (options?.sameSite === undefined) { + overrides.sameSite = 'lax'; + } + if (options?.secure === undefined) { + overrides.secure = false; + } + const cookie = this.parser.createCookie(name, '', overrides); + this.outbound.set(name, { cookie, deleted: true }); + } + + public async getSetCookieHeaders(context?: SerializeContext): Promise { + const tasks: Promise[] = []; + + for (const [, entry] of this.outbound) { + if (entry.deleted) { + tasks.push(Promise.resolve(this.parser.serialize(entry.cookie, context))); + continue; + } + tasks.push(this.transformAndSerialize(entry.cookie, context)); + } + + return Promise.all(tasks); + } + + private async transformAndSerialize(cookie: Cookie, context?: SerializeContext): Promise { + let c = cookie; + if (this.parser.isSigningConfigured) { + c = this.parser.sign(c); + } + if (this.parser.isEncryptionConfigured) { + c = await this.parser.encrypt(c); + } + return this.parser.serialize(c, context); + } + + private toErr(thrown: unknown, step: Step): ReturnType> { + if (thrown instanceof CookieError) { + return err({ + reason: thrown.reason, + message: thrown.message, + }); + } + return err({ + reason: step === 'decrypt' + ? CookieErrorReason.DecryptionFailed + : CookieErrorReason.SignatureVerificationFailed, + message: thrown instanceof Error ? thrown.message : 'unknown cookie error', + }); + } +} diff --git a/packages/cookie/src/cookie-parser.spec.ts b/packages/cookie/src/cookie-parser.spec.ts new file mode 100644 index 0000000..6c0665b --- /dev/null +++ b/packages/cookie/src/cookie-parser.spec.ts @@ -0,0 +1,1303 @@ +import { describe, expect, it } from 'bun:test'; +import { Cookie } from 'bun'; + +import { CookieErrorReason } from './enums'; +import { CookieError } from './interfaces'; +import { CookieParser } from './cookie-parser'; + +describe('CookieParser', () => { + describe('create', () => { + it('should create instance without signing or encryption when no options', () => { + const cp = CookieParser.create(); + expect(cp).toBeInstanceOf(CookieParser); + }); + + it('should create instance with signing when secrets provided', () => { + const cp = CookieParser.create({ secrets: ['Zt0tEdS1HGYL9uL1XCdYAK7jcXMwVoTJcVWgM6ZgAC8'] }); + expect(cp).toBeInstanceOf(CookieParser); + }); + + it('should create instance with encryption when encryptionSecret provided', () => { + const cp = CookieParser.create({ encryptionSecret: '7jsSFQIsrYMx7njVC74raAcw-YrfDSdVdSJwq1t1xMA' }); + expect(cp).toBeInstanceOf(CookieParser); + }); + + it('should create instance with both when secrets and encryptionSecret provided', () => { + const cp = CookieParser.create({ secrets: ['gHBB3MwkPytgNA9vApSMJRDqJIPMNXgLrHUKSJZy1Kg'], encryptionSecret: '9v7BAwKpXHWZnoKZIHV2XWch22HvF8bleOM6t4nc-A4' }); + expect(cp).toBeInstanceOf(CookieParser); + }); + + it('should throw EmptySecrets when secrets array is empty', () => { + let caught: unknown; + try { + CookieParser.create({ secrets: [] }); + } catch (e) { + caught = e; + } + expect(caught).toBeInstanceOf(CookieError); + expect((caught as CookieError).reason).toBe(CookieErrorReason.EmptySecrets); + }); + + it('should throw InvalidSecret when a secret is blank', () => { + let caught: unknown; + try { + CookieParser.create({ secrets: ['FG-Qz_XD9uOM7e9O6mp_sZjsXPCrVik4ofHYTGagT3k', ' '] }); + } catch (e) { + caught = e; + } + expect(caught).toBeInstanceOf(CookieError); + expect((caught as CookieError).reason).toBe(CookieErrorReason.InvalidSecret); + }); + + it('should throw InvalidEncryptionSecret when encryptionSecret is blank', () => { + let caught: unknown; + try { + CookieParser.create({ encryptionSecret: ' ' }); + } catch (e) { + caught = e; + } + expect(caught).toBeInstanceOf(CookieError); + expect((caught as CookieError).reason).toBe( + CookieErrorReason.InvalidEncryptionSecret, + ); + }); + + it('should throw InvalidEncryptionSecret when secrets valid but encryptionSecret blank', () => { + let caught: unknown; + try { + CookieParser.create({ secrets: ['FG-Qz_XD9uOM7e9O6mp_sZjsXPCrVik4ofHYTGagT3k'], encryptionSecret: '' }); + } catch (e) { + caught = e; + } + expect(caught).toBeInstanceOf(CookieError); + expect((caught as CookieError).reason).toBe( + CookieErrorReason.InvalidEncryptionSecret, + ); + }); + + it('should create instance without signing or encryption when options is empty object', () => { + const cp = CookieParser.create({}); + expect(cp).toBeInstanceOf(CookieParser); + }); + }); + + describe('serialize', () => { + it('should return Set-Cookie header string when serializing Cookie', () => { + const cp = CookieParser.create(); + const cookie = new Cookie('session', 'abc', { path: '/', secure: true }); + const header = cp.serialize(cookie); + expect(header).toContain('session=abc'); + expect(header).toContain('Secure'); + }); + + it('should produce consistent result when serialize then parse roundtrip', () => { + const cp = CookieParser.create(); + const original = new Cookie('token', 'xyz', { path: '/', httpOnly: true }); + const header = cp.serialize(original); + const parsed = Cookie.parse(header); + expect(parsed.name).toBe('token'); + expect(parsed.value).toBe('xyz'); + expect(parsed.path).toBe('/'); + expect(parsed.httpOnly).toBe(true); + }); + }); + + describe('sign', () => { + it('should return Cookie with signed value when signing', () => { + const cp = CookieParser.create({ secrets: ['uLplyRvLnHhzccmlgR32eWltxxck4zA03xyJ40ik4DQ'] }); + const cookie = new Cookie('session', 'hello'); + const signed = cp.sign(cookie); + expect(signed.name).toBe('session'); + expect(signed.value).toContain('hello.'); + expect(signed.value).not.toBe('hello'); + }); + + it('should preserve cookie name and attributes when signing', () => { + const cp = CookieParser.create({ secrets: ['gHBB3MwkPytgNA9vApSMJRDqJIPMNXgLrHUKSJZy1Kg'] }); + const cookie = new Cookie('token', 'val', { + path: '/api', + secure: true, + httpOnly: true, + }); + const signed = cp.sign(cookie); + expect(signed.name).toBe('token'); + expect(signed.path).toBe('/api'); + expect(signed.secure).toBe(true); + expect(signed.httpOnly).toBe(true); + }); + + it('should throw SigningNotConfigured when signing without secrets', () => { + const cp = CookieParser.create(); + let caught: unknown; + try { + cp.sign(new Cookie('n', 'v')); + } catch (e) { + caught = e; + } + expect(caught).toBeInstanceOf(CookieError); + expect((caught as CookieError).reason).toBe( + CookieErrorReason.SigningNotConfigured, + ); + }); + + it('should return Cookie with .hmac format when signing empty value', () => { + const cp = CookieParser.create({ secrets: ['gHBB3MwkPytgNA9vApSMJRDqJIPMNXgLrHUKSJZy1Kg'] }); + const signed = cp.sign(new Cookie('n', '')); + expect(signed.value).toMatch(/^\..+/); + }); + + it('should return same result when signing same cookie twice', () => { + const cp = CookieParser.create({ secrets: ['gHBB3MwkPytgNA9vApSMJRDqJIPMNXgLrHUKSJZy1Kg'] }); + const cookie = new Cookie('n', 'v'); + const a = cp.sign(cookie); + const b = cp.sign(cookie); + expect(a.value).toBe(b.value); + }); + }); + + describe('unsign', () => { + it('should return Cookie with original value when unsigning', async () => { + const cp = CookieParser.create({ secrets: ['5LXB_5T8ke-OM3lbaSCxmSh5MLRfX-xzgfeqiC0XU-4'] }); + const signed = cp.sign(new Cookie('n', 'hello')); + const unsigned = await cp.unsign(signed); + expect(unsigned.name).toBe('n'); + expect(unsigned.value).toBe('hello'); + }); + + it('should succeed with second secret when unsigning with key rotation', async () => { + const cpOld = CookieParser.create({ secrets: ['c-BonY3Jbzq2IWbz7U92BtJtQVDGl9wnoudjt9RkihY'] }); + const signed = cpOld.sign(new Cookie('n', 'data')); + const cpNew = CookieParser.create({ secrets: ['xM8Em3o_YBlUuk66TuXhAUgxC2E4fMk-OAOUl4KV02A', 'c-BonY3Jbzq2IWbz7U92BtJtQVDGl9wnoudjt9RkihY'] }); + const unsigned = await cpNew.unsign(signed); + expect(unsigned.value).toBe('data'); + }); + + it('should throw SigningNotConfigured when unsigning without secrets', async () => { + const cp = CookieParser.create(); + let caught: unknown; + try { + await cp.unsign(new Cookie('n', 'v.sig')); + } catch (e) { + caught = e; + } + expect(caught).toBeInstanceOf(CookieError); + expect((caught as CookieError).reason).toBe( + CookieErrorReason.SigningNotConfigured, + ); + }); + + it('should throw InvalidSignature when unsigning value without dot', async () => { + const cp = CookieParser.create({ secrets: ['gHBB3MwkPytgNA9vApSMJRDqJIPMNXgLrHUKSJZy1Kg'] }); + let caught: unknown; + try { + await cp.unsign(new Cookie('n', 'nodot')); + } catch (e) { + caught = e; + } + expect(caught).toBeInstanceOf(CookieError); + expect((caught as CookieError).reason).toBe( + CookieErrorReason.InvalidSignature, + ); + }); + + it('should throw SignatureVerificationFailed when unsigning with wrong hmac', async () => { + const cp = CookieParser.create({ secrets: ['gHBB3MwkPytgNA9vApSMJRDqJIPMNXgLrHUKSJZy1Kg'] }); + let caught: unknown; + try { + await cp.unsign(new Cookie('n', 'value.wronghmac')); + } catch (e) { + caught = e; + } + expect(caught).toBeInstanceOf(CookieError); + expect((caught as CookieError).reason).toBe( + CookieErrorReason.SignatureVerificationFailed, + ); + }); + + it('should throw SignatureVerificationFailed when value was tampered', async () => { + const cp = CookieParser.create({ secrets: ['gHBB3MwkPytgNA9vApSMJRDqJIPMNXgLrHUKSJZy1Kg'] }); + const signed = cp.sign(new Cookie('n', 'original')); + const tampered = new Cookie( + 'n', + 'tampered' + signed.value.slice(signed.value.lastIndexOf('.')), + ); + let caught: unknown; + try { + await cp.unsign(tampered); + } catch (e) { + caught = e; + } + expect(caught).toBeInstanceOf(CookieError); + expect((caught as CookieError).reason).toBe( + CookieErrorReason.SignatureVerificationFailed, + ); + }); + + it('should split at last dot when unsigning value with multiple dots', async () => { + const cp = CookieParser.create({ secrets: ['gHBB3MwkPytgNA9vApSMJRDqJIPMNXgLrHUKSJZy1Kg'] }); + const cookie = new Cookie('n', 'a.b.c'); + const signed = cp.sign(cookie); + const unsigned = await cp.unsign(signed); + expect(unsigned.value).toBe('a.b.c'); + }); + + it('should return original value when sign then unsign roundtrip', async () => { + const cp = CookieParser.create({ secrets: ['_cxDYhedJoI3pyfq0QbajZaiG-_F-pAASJH65k7wr6w'] }); + const original = new Cookie('session', 'user:42', { path: '/', secure: true }); + const signed = cp.sign(original); + const unsigned = await cp.unsign(signed); + expect(unsigned.value).toBe('user:42'); + expect(unsigned.name).toBe('session'); + }); + }); + + describe('encrypt', () => { + it('should return Cookie with encrypted value when encrypting', async () => { + const cp = CookieParser.create({ encryptionSecret: 'Jxfcxvq26bQMrza3M9GXKSy-1jSPeLw4mUhtCiEv3aY' }); + const cookie = new Cookie('session', 'secret-data'); + const encrypted = await cp.encrypt(cookie); + expect(encrypted.name).toBe('session'); + expect(encrypted.value).not.toBe('secret-data'); + expect(encrypted.value.length).toBeGreaterThan(0); + }); + + it('should preserve cookie name and attributes when encrypting', async () => { + const cp = CookieParser.create({ encryptionSecret: '9v7BAwKpXHWZnoKZIHV2XWch22HvF8bleOM6t4nc-A4' }); + const cookie = new Cookie('token', 'val', { + path: '/api', + secure: true, + httpOnly: true, + }); + const encrypted = await cp.encrypt(cookie); + expect(encrypted.name).toBe('token'); + expect(encrypted.path).toBe('/api'); + expect(encrypted.secure).toBe(true); + expect(encrypted.httpOnly).toBe(true); + }); + + it('should throw EncryptionNotConfigured when encrypting without secret', async () => { + const cp = CookieParser.create(); + let caught: unknown; + try { + await cp.encrypt(new Cookie('n', 'v')); + } catch (e) { + caught = e; + } + expect(caught).toBeInstanceOf(CookieError); + expect((caught as CookieError).reason).toBe( + CookieErrorReason.EncryptionNotConfigured, + ); + }); + + it('should return different ciphertexts when encrypting same cookie twice', async () => { + const cp = CookieParser.create({ encryptionSecret: '9v7BAwKpXHWZnoKZIHV2XWch22HvF8bleOM6t4nc-A4' }); + const cookie = new Cookie('n', 'same-value'); + const a = await cp.encrypt(cookie); + const b = await cp.encrypt(cookie); + expect(a.value).not.toBe(b.value); + }); + }); + + describe('decrypt', () => { + it('should return Cookie with original value when decrypting', async () => { + const cp = CookieParser.create({ encryptionSecret: 'mrL_P-ipSo5gJWyLB1fpKzLvXpDQhWd127WUIjVkE0Q' }); + const encrypted = await cp.encrypt(new Cookie('n', 'plaintext')); + const decrypted = await cp.decrypt(encrypted); + expect(decrypted.name).toBe('n'); + expect(decrypted.value).toBe('plaintext'); + }); + + it('should throw EncryptionNotConfigured when decrypting without secret', async () => { + const cp = CookieParser.create(); + let caught: unknown; + try { + await cp.decrypt(new Cookie('n', 'cipher')); + } catch (e) { + caught = e; + } + expect(caught).toBeInstanceOf(CookieError); + expect((caught as CookieError).reason).toBe( + CookieErrorReason.EncryptionNotConfigured, + ); + }); + + it('should throw InvalidCiphertext when decrypting too-short value', async () => { + const cp = CookieParser.create({ encryptionSecret: '9v7BAwKpXHWZnoKZIHV2XWch22HvF8bleOM6t4nc-A4' }); + let caught: unknown; + try { + await cp.decrypt(new Cookie('n', 'short')); + } catch (e) { + caught = e; + } + expect(caught).toBeInstanceOf(CookieError); + expect((caught as CookieError).reason).toBe( + CookieErrorReason.InvalidCiphertext, + ); + }); + + it('should throw DecryptionFailed when decrypting tampered ciphertext', async () => { + const cp = CookieParser.create({ encryptionSecret: '9v7BAwKpXHWZnoKZIHV2XWch22HvF8bleOM6t4nc-A4' }); + const encrypted = await cp.encrypt(new Cookie('n', 'v')); + const tampered = new Cookie( + 'n', + encrypted.value.slice(0, -4) + 'XXXX', + ); + let caught: unknown; + try { + await cp.decrypt(tampered); + } catch (e) { + caught = e; + } + expect(caught).toBeInstanceOf(CookieError); + expect((caught as CookieError).reason).toBe( + CookieErrorReason.DecryptionFailed, + ); + }); + + it('should throw DecryptionFailed when decrypting with wrong key', async () => { + const cpA = CookieParser.create({ encryptionSecret: '15MzBo5XvJ5s4pH6_Qg2rdLQ73O_ZWOyoNT2vsDtN1U' }); + const cpB = CookieParser.create({ encryptionSecret: 'G2ChMLgCJsc5VkAXlrN2ZUqgAKHsrASwTplEv5lcS1w' }); + const encrypted = await cpA.encrypt(new Cookie('n', 'v')); + let caught: unknown; + try { + await cpB.decrypt(encrypted); + } catch (e) { + caught = e; + } + expect(caught).toBeInstanceOf(CookieError); + expect((caught as CookieError).reason).toBe( + CookieErrorReason.DecryptionFailed, + ); + }); + + it('should return original value when encrypt then decrypt roundtrip', async () => { + const cp = CookieParser.create({ encryptionSecret: '_cxDYhedJoI3pyfq0QbajZaiG-_F-pAASJH65k7wr6w' }); + const original = new Cookie('session', 'user:42', { path: '/', secure: true }); + const encrypted = await cp.encrypt(original); + const decrypted = await cp.decrypt(encrypted); + expect(decrypted.value).toBe('user:42'); + expect(decrypted.name).toBe('session'); + }); + }); + + describe('validatePrefix', () => { + it('should pass when validating __Host- cookie with all valid attributes', () => { + const cp = CookieParser.create(); + const cookie = new Cookie('__Host-session', 'v', { + secure: true, + path: '/', + }); + expect(() => cp.validatePrefix(cookie)).not.toThrow(); + }); + + it('should pass when validating __Secure- cookie with secure flag', () => { + const cp = CookieParser.create(); + const cookie = new Cookie('__Secure-token', 'v', { secure: true }); + expect(() => cp.validatePrefix(cookie)).not.toThrow(); + }); + + it('should pass when validating cookie without prefix', () => { + const cp = CookieParser.create(); + expect(() => cp.validatePrefix(new Cookie('normal', 'v'))).not.toThrow(); + }); + + it('should throw HostPrefixRequiresSecure when __Host- without secure', () => { + const cp = CookieParser.create(); + let caught: unknown; + try { + cp.validatePrefix(new Cookie('__Host-x', 'v', { path: '/' })); + } catch (e) { + caught = e; + } + expect(caught).toBeInstanceOf(CookieError); + expect((caught as CookieError).reason).toBe( + CookieErrorReason.HostPrefixRequiresSecure, + ); + }); + + it('should throw HostPrefixForbidsDomain when __Host- with domain', () => { + const cp = CookieParser.create(); + let caught: unknown; + try { + cp.validatePrefix( + new Cookie('__Host-x', 'v', { + secure: true, + path: '/', + domain: 'example.com', + }), + ); + } catch (e) { + caught = e; + } + expect(caught).toBeInstanceOf(CookieError); + expect((caught as CookieError).reason).toBe( + CookieErrorReason.HostPrefixForbidsDomain, + ); + }); + + it('should throw HostPrefixRequiresRootPath when __Host- with wrong path', () => { + const cp = CookieParser.create(); + let caught: unknown; + try { + cp.validatePrefix(new Cookie('__Host-x', 'v', { secure: true, path: '/admin' })); + } catch (e) { + caught = e; + } + expect(caught).toBeInstanceOf(CookieError); + expect((caught as CookieError).reason).toBe( + CookieErrorReason.HostPrefixRequiresRootPath, + ); + }); + + it('should throw SecurePrefixRequiresSecure when __Secure- without secure', () => { + const cp = CookieParser.create(); + let caught: unknown; + try { + cp.validatePrefix(new Cookie('__Secure-x', 'v')); + } catch (e) { + caught = e; + } + expect(caught).toBeInstanceOf(CookieError); + expect((caught as CookieError).reason).toBe( + CookieErrorReason.SecurePrefixRequiresSecure, + ); + }); + }); + + describe('pipeline', () => { + it('should complete outbound pipeline validatePrefix then sign then encrypt then serialize', async () => { + const cp = CookieParser.create({ secrets: ['gHBB3MwkPytgNA9vApSMJRDqJIPMNXgLrHUKSJZy1Kg'], encryptionSecret: '9v7BAwKpXHWZnoKZIHV2XWch22HvF8bleOM6t4nc-A4' }); + const cookie = new Cookie('__Secure-session', 'data', { + secure: true, + path: '/', + }); + cp.validatePrefix(cookie); + const signed = cp.sign(cookie); + const encrypted = await cp.encrypt(signed); + const header = cp.serialize(encrypted); + expect(header).toContain('__Secure-session='); + }); + + it('should complete inbound pipeline parse then decrypt then unsign', async () => { + const cp = CookieParser.create({ secrets: ['gHBB3MwkPytgNA9vApSMJRDqJIPMNXgLrHUKSJZy1Kg'], encryptionSecret: '9v7BAwKpXHWZnoKZIHV2XWch22HvF8bleOM6t4nc-A4' }); + const original = new Cookie('session', 'secret-data'); + const signed = cp.sign(original); + const encrypted = await cp.encrypt(signed); + const header = cp.serialize(encrypted); + const parsed = Cookie.parse(header); + const decrypted = await cp.decrypt(parsed); + const unsigned = await cp.unsign(decrypted); + expect(unsigned.value).toBe('secret-data'); + }); + + it('should produce different results when sign-then-encrypt vs encrypt-then-sign', async () => { + const cp = CookieParser.create({ secrets: ['gHBB3MwkPytgNA9vApSMJRDqJIPMNXgLrHUKSJZy1Kg'], encryptionSecret: '9v7BAwKpXHWZnoKZIHV2XWch22HvF8bleOM6t4nc-A4' }); + const cookie = new Cookie('n', 'v'); + const signFirst = await cp.encrypt(cp.sign(cookie)); + const encryptFirst = cp.sign(await cp.encrypt(cookie)); + expect(signFirst.value).not.toBe(encryptFirst.value); + }); + + it('should throw EncryptionNotConfigured when created with secrets only and encrypt called', async () => { + const cp = CookieParser.create({ secrets: ['gHBB3MwkPytgNA9vApSMJRDqJIPMNXgLrHUKSJZy1Kg'] }); + let caught: unknown; + try { + await cp.encrypt(new Cookie('n', 'v')); + } catch (e) { + caught = e; + } + expect(caught).toBeInstanceOf(CookieError); + expect((caught as CookieError).reason).toBe( + CookieErrorReason.EncryptionNotConfigured, + ); + }); + + it('should throw SigningNotConfigured when created with encryptionSecret only and sign called', () => { + const cp = CookieParser.create({ encryptionSecret: '9v7BAwKpXHWZnoKZIHV2XWch22HvF8bleOM6t4nc-A4' }); + let caught: unknown; + try { + cp.sign(new Cookie('n', 'v')); + } catch (e) { + caught = e; + } + expect(caught).toBeInstanceOf(CookieError); + expect((caught as CookieError).reason).toBe( + CookieErrorReason.SigningNotConfigured, + ); + }); + + it('should always sign with first secret in array', () => { + const cpA = CookieParser.create({ secrets: ['TkAnVMEz2b6plPoYz_d34hH8YUoKtqSpKw98hRF1jyc', 'xWrp7xEBI_mt-LG3QJDz6wMQr37-nK0PvNmQp2Ejg0g'] }); + const cpB = CookieParser.create({ secrets: ['TkAnVMEz2b6plPoYz_d34hH8YUoKtqSpKw98hRF1jyc'] }); + const cookie = new Cookie('n', 'v'); + expect(cpA.sign(cookie).value).toBe(cpB.sign(cookie).value); + }); + + it('should unsign with old secret when key rotation array includes it', async () => { + const cpOld = CookieParser.create({ secrets: ['L9B6csE6Sq9NA6MXZumamSev-eUUCfzGF_wMa8BRUaU'] }); + const signed = cpOld.sign(new Cookie('n', 'important')); + const cpRotated = CookieParser.create({ secrets: ['1cxQCYROyjGcQQ_wLx_R6aGe0sfQL2LYjoQ3UStKWUI', 'L9B6csE6Sq9NA6MXZumamSev-eUUCfzGF_wMa8BRUaU'] }); + const unsigned = await cpRotated.unsign(signed); + expect(unsigned.value).toBe('important'); + }); + }); + + describe('algorithm', () => { + it('should sign with sha384 algorithm', async () => { + const cp = CookieParser.create({ secrets: ['gHBB3MwkPytgNA9vApSMJRDqJIPMNXgLrHUKSJZy1Kg'], algorithm: 'sha384' }); + const signed = cp.sign(new Cookie('n', 'v')); + expect(signed.value).toContain('v.'); + const unsigned = await cp.unsign(signed); + expect(unsigned.value).toBe('v'); + }); + + it('should sign with sha512 algorithm', async () => { + const cp = CookieParser.create({ secrets: ['gHBB3MwkPytgNA9vApSMJRDqJIPMNXgLrHUKSJZy1Kg'], algorithm: 'sha512' }); + const signed = cp.sign(new Cookie('n', 'v')); + expect(signed.value).toContain('v.'); + const unsigned = await cp.unsign(signed); + expect(unsigned.value).toBe('v'); + }); + + it('should produce different signatures for different algorithms', () => { + const cp256 = CookieParser.create({ secrets: ['gHBB3MwkPytgNA9vApSMJRDqJIPMNXgLrHUKSJZy1Kg'], algorithm: 'sha256' }); + const cp512 = CookieParser.create({ secrets: ['gHBB3MwkPytgNA9vApSMJRDqJIPMNXgLrHUKSJZy1Kg'], algorithm: 'sha512' }); + const cookie = new Cookie('n', 'v'); + expect(cp256.sign(cookie).value).not.toBe(cp512.sign(cookie).value); + }); + + it('should fail to unsign with different algorithm', async () => { + const cp256 = CookieParser.create({ secrets: ['gHBB3MwkPytgNA9vApSMJRDqJIPMNXgLrHUKSJZy1Kg'], algorithm: 'sha256' }); + const cp512 = CookieParser.create({ secrets: ['gHBB3MwkPytgNA9vApSMJRDqJIPMNXgLrHUKSJZy1Kg'], algorithm: 'sha512' }); + const signed = cp256.sign(new Cookie('n', 'v')); + let caught: unknown; + try { + await cp512.unsign(signed); + } catch (e) { + caught = e; + } + expect(caught).toBeInstanceOf(CookieError); + expect((caught as CookieError).reason).toBe( + CookieErrorReason.SignatureVerificationFailed, + ); + }); + + it('should throw InvalidAlgorithm when algorithm is unsupported', () => { + let caught: unknown; + try { + CookieParser.create({ algorithm: 'md5' as any }); + } catch (e) { + caught = e; + } + expect(caught).toBeInstanceOf(CookieError); + expect((caught as CookieError).reason).toBe(CookieErrorReason.InvalidAlgorithm); + }); + }); + + describe('createCookie', () => { + it('should create cookie with no defaults when none configured', () => { + const cp = CookieParser.create(); + const cookie = cp.createCookie('session', 'abc'); + expect(cookie.name).toBe('session'); + expect(cookie.value).toBe('abc'); + }); + + it('should apply parser defaults to created cookie', () => { + const cp = CookieParser.create({ + httpOnly: true, + secure: true, + sameSite: 'strict', + path: '/app', + domain: 'example.com', + maxAge: 3600, + partitioned: true, + }); + const cookie = cp.createCookie('session', 'abc'); + expect(cookie.httpOnly).toBe(true); + expect(cookie.secure).toBe(true); + expect(cookie.sameSite).toBe('strict'); + expect(cookie.path).toBe('/app'); + expect(cookie.domain).toBe('example.com'); + expect(cookie.maxAge).toBe(3600); + expect(cookie.partitioned).toBe(true); + }); + + it('should allow per-cookie overrides over parser defaults', () => { + const cp = CookieParser.create({ + httpOnly: true, + secure: true, + path: '/', + }); + const cookie = cp.createCookie('session', 'abc', { + httpOnly: false, + secure: false, + path: '/admin', + }); + expect(cookie.httpOnly).toBe(false); + expect(cookie.secure).toBe(false); + expect(cookie.path).toBe('/admin'); + }); + + it('should not apply secure to cookie when default is auto', () => { + const cp = CookieParser.create({ secure: 'auto' }); + const cookie = cp.createCookie('session', 'abc'); + expect(cookie.secure).toBe(false); + }); + + it('should allow explicit secure override even when default is auto', () => { + const cp = CookieParser.create({ secure: 'auto' }); + const cookie = cp.createCookie('session', 'abc', { secure: true }); + expect(cookie.secure).toBe(true); + }); + }); + + describe('serialize with context', () => { + it('should resolve secure auto to true when context.isSecure is true', () => { + const cp = CookieParser.create({ secure: 'auto' }); + const cookie = new Cookie('session', 'abc'); + const header = cp.serialize(cookie, { isSecure: true }); + expect(header).toContain('Secure'); + }); + + it('should resolve secure auto to false when context.isSecure is false', () => { + const cp = CookieParser.create({ secure: 'auto' }); + const cookie = new Cookie('session', 'abc'); + const header = cp.serialize(cookie, { isSecure: false }); + expect(header).not.toContain('Secure'); + }); + + it('throws when secure="auto" but no SerializeContext is provided', () => { + const cp = CookieParser.create({ secure: 'auto' }); + const cookie = new Cookie('session', 'abc'); + let caught: unknown; + try { cp.serialize(cookie); } catch (e) { caught = e; } + expect(caught).toBeInstanceOf(CookieError); + expect((caught as CookieError).reason).toBe(CookieErrorReason.InvalidAttribute); + }); + + it('different kdfSalt produces signatures that do not cross-verify', async () => { + const secret = '5qly1QnPB1M6tT3thbFxuaY6A7OXv2zS8_O3VTHTAQ8'; + const a = CookieParser.create({ secrets: [secret], kdfSalt: 'deployment-A-salt-padding-32-bytes!!' }); + const b = CookieParser.create({ secrets: [secret], kdfSalt: 'deployment-B-salt-padding-32-bytes!!' }); + const signed = a.sign(new Cookie('s', 'v')); + let caught: unknown; + try { await b.unsign(signed); } catch (e) { caught = e; } + expect(caught).toBeInstanceOf(CookieError); + expect((caught as CookieError).reason).toBe(CookieErrorReason.SignatureVerificationFailed); + }); + + it('throws when secure="auto" but context.isSecure is undefined', () => { + const cp = CookieParser.create({ secure: 'auto' }); + const cookie = new Cookie('session', 'abc'); + let caught: unknown; + try { cp.serialize(cookie, {}); } catch (e) { caught = e; } + expect(caught).toBeInstanceOf(CookieError); + expect((caught as CookieError).reason).toBe(CookieErrorReason.InvalidAttribute); + }); + + it('should apply nullable defaults in serialize when cookie has no domain', () => { + const cp = CookieParser.create({ domain: 'example.com' }); + const cookie = new Cookie('session', 'abc'); + const header = cp.serialize(cookie); + expect(header).toContain('Domain=example.com'); + }); + + it('should not override cookie domain with default', () => { + const cp = CookieParser.create({ domain: 'default.com' }); + const cookie = new Cookie('session', 'abc', { domain: 'explicit.com' }); + const header = cp.serialize(cookie); + expect(header).toContain('Domain=explicit.com'); + expect(header).not.toContain('default.com'); + }); + + it('should apply nullable maxAge default when cookie has none', () => { + const cp = CookieParser.create({ maxAge: 7200 }); + const cookie = new Cookie('session', 'abc'); + const header = cp.serialize(cookie); + expect(header).toContain('Max-Age=7200'); + }); + }); + + describe('prefixValidation', () => { + it('should auto-validate prefix when prefixValidation is true', () => { + const cp = CookieParser.create({ prefixValidation: true }); + const cookie = new Cookie('__Host-x', 'v', { path: '/' }); + let caught: unknown; + try { + cp.serialize(cookie); + } catch (e) { + caught = e; + } + expect(caught).toBeInstanceOf(CookieError); + expect((caught as CookieError).reason).toBe( + CookieErrorReason.HostPrefixRequiresSecure, + ); + }); + + it('should not validate prefix when prefixValidation is false', () => { + const cp = CookieParser.create({ prefixValidation: false }); + const cookie = new Cookie('__Host-x', 'v', { path: '/' }); + expect(() => cp.serialize(cookie)).not.toThrow(); + }); + + it('should pass auto-validation for valid __Host- cookie', () => { + const cp = CookieParser.create({ prefixValidation: true }); + const cookie = new Cookie('__Host-session', 'v', { + secure: true, + path: '/', + }); + expect(() => cp.serialize(cookie)).not.toThrow(); + }); + + it('should auto-validate with secure auto resolved to true', () => { + const cp = CookieParser.create({ + prefixValidation: true, + secure: 'auto', + }); + const cookie = new Cookie('__Secure-token', 'v'); + expect(() => cp.serialize(cookie, { isSecure: true })).not.toThrow(); + }); + + it('should fail auto-validation with secure auto resolved to false', () => { + const cp = CookieParser.create({ + prefixValidation: true, + secure: 'auto', + }); + const cookie = new Cookie('__Secure-token', 'v'); + let caught: unknown; + try { + cp.serialize(cookie, { isSecure: false }); + } catch (e) { + caught = e; + } + expect(caught).toBeInstanceOf(CookieError); + expect((caught as CookieError).reason).toBe( + CookieErrorReason.SecurePrefixRequiresSecure, + ); + }); + }); + + describe('cloneCookieWithDefaults', () => { + it('should apply nullable defaults when signing cookie with no domain', () => { + const cp = CookieParser.create({ + secrets: ['gHBB3MwkPytgNA9vApSMJRDqJIPMNXgLrHUKSJZy1Kg'], + domain: 'example.com', + maxAge: 3600, + }); + const cookie = new Cookie('session', 'data'); + const signed = cp.sign(cookie); + const header = cp.serialize(signed); + expect(header).toContain('Domain=example.com'); + expect(header).toContain('Max-Age=3600'); + }); + + it('should preserve maxAge 0 through sign roundtrip', async () => { + const cp = CookieParser.create({ secrets: ['gHBB3MwkPytgNA9vApSMJRDqJIPMNXgLrHUKSJZy1Kg'] }); + const cookie = new Cookie('session', 'data', { maxAge: 0 }); + const signed = cp.sign(cookie); + expect(signed.maxAge).toBe(0); + const unsigned = await cp.unsign(signed); + expect(unsigned.maxAge).toBe(0); + }); + }); + + describe('RFC compliance', () => { + it('should throw InvalidCookieName when name is empty', () => { + const cp = CookieParser.create(); + let caught: unknown; + try { + cp.createCookie('', 'v'); + } catch (e) { + caught = e; + } + expect(caught).toBeInstanceOf(CookieError); + expect((caught as CookieError).reason).toBe(CookieErrorReason.InvalidCookieName); + }); + + it('should throw InvalidCookieName when name contains spaces', () => { + const cp = CookieParser.create(); + let caught: unknown; + try { + cp.createCookie('bad name', 'v'); + } catch (e) { + caught = e; + } + expect(caught).toBeInstanceOf(CookieError); + expect((caught as CookieError).reason).toBe(CookieErrorReason.InvalidCookieName); + }); + + it('should throw InvalidCookieName when name contains control chars', () => { + const cp = CookieParser.create(); + let caught: unknown; + try { + cp.createCookie('bad\x00name', 'v'); + } catch (e) { + caught = e; + } + expect(caught).toBeInstanceOf(CookieError); + expect((caught as CookieError).reason).toBe(CookieErrorReason.InvalidCookieName); + }); + + it('should throw InvalidCookieName when name contains separator chars', () => { + const cp = CookieParser.create(); + for (const ch of ['(', ')', '<', '>', '@', ',', ';', ':', '\\', '"', '/', '[', ']', '?', '=', '{', '}']) { + let caught: unknown; + try { + cp.createCookie(`bad${ch}name`, 'v'); + } catch (e) { + caught = e; + } + expect(caught).toBeInstanceOf(CookieError); + } + }); + + it('should accept valid token characters in cookie name', () => { + const cp = CookieParser.create(); + expect(() => cp.createCookie('valid-name_123.test~!', 'v')).not.toThrow(); + }); + + it('should throw CookieTooLarge when serialized cookie exceeds 4096 bytes', () => { + const cp = CookieParser.create(); + const cookie = new Cookie('session', 'x'.repeat(4096)); + let caught: unknown; + try { + cp.serialize(cookie); + } catch (e) { + caught = e; + } + expect(caught).toBeInstanceOf(CookieError); + expect((caught as CookieError).reason).toBe(CookieErrorReason.CookieTooLarge); + }); + + it('should not throw when serialized cookie is within 4096 bytes', () => { + const cp = CookieParser.create(); + const cookie = new Cookie('gHBB3MwkPytgNA9vApSMJRDqJIPMNXgLrHUKSJZy1Kg', 'v'); + expect(() => cp.serialize(cookie)).not.toThrow(); + }); + }); + + describe('edge cases', () => { + it('should encrypt and decrypt empty string value', async () => { + const cp = CookieParser.create({ encryptionSecret: '9v7BAwKpXHWZnoKZIHV2XWch22HvF8bleOM6t4nc-A4' }); + const cookie = new Cookie('session', ''); + const encrypted = await cp.encrypt(cookie); + const decrypted = await cp.decrypt(encrypted); + expect(decrypted.value).toBe(''); + }); + + it('should apply expires default in serialize when cookie has none', () => { + const expires = new Date(Date.now() + 30 * 86400 * 1000); + const cp = CookieParser.create({ expires }); + const cookie = new Cookie('session', 'v'); + const header = cp.serialize(cookie); + expect(header).toContain('Expires='); + }); + + it('should apply maxAge 0 default via createCookie', () => { + const cp = CookieParser.create({ maxAge: 0 }); + const cookie = cp.createCookie('session', 'v'); + expect(cookie.maxAge).toBe(0); + }); + + it('should not clone in serialize when no defaults apply and secure is not auto', () => { + const cp = CookieParser.create(); + const cookie = new Cookie('session', 'v', { secure: true, path: '/' }); + const header = cp.serialize(cookie); + expect(header).toContain('session=v'); + expect(header).toContain('Secure'); + }); + }); + + describe('RFC 6265bis compliance', () => { + it('should throw SameSiteNoneRequiresSecure when SameSite=None without Secure', () => { + const cp = CookieParser.create(); + const cookie = new Cookie('session', 'v', { sameSite: 'none' }); + let caught: unknown; + try { + cp.serialize(cookie); + } catch (e) { + caught = e; + } + expect(caught).toBeInstanceOf(CookieError); + expect((caught as CookieError).reason).toBe( + CookieErrorReason.SameSiteNoneRequiresSecure, + ); + }); + + it('should allow SameSite=None with Secure', () => { + const cp = CookieParser.create(); + const cookie = new Cookie('session', 'v', { sameSite: 'none', secure: true }); + expect(() => cp.serialize(cookie)).not.toThrow(); + }); + + it('should allow SameSite=Lax without Secure', () => { + const cp = CookieParser.create(); + const cookie = new Cookie('session', 'v', { sameSite: 'lax' }); + expect(() => cp.serialize(cookie)).not.toThrow(); + }); + + it('should allow SameSite=Strict without Secure', () => { + const cp = CookieParser.create(); + const cookie = new Cookie('session', 'v', { sameSite: 'strict' }); + expect(() => cp.serialize(cookie)).not.toThrow(); + }); + + it('should allow cookie without Max-Age', () => { + const cp = CookieParser.create(); + const cookie = new Cookie('session', 'v'); + expect(() => cp.serialize(cookie)).not.toThrow(); + }); + + it('should validate SameSite=None after secure auto resolves to true', () => { + const cp = CookieParser.create({ secure: 'auto' }); + const cookie = new Cookie('session', 'v', { sameSite: 'none' }); + expect(() => cp.serialize(cookie, { isSecure: true })).not.toThrow(); + }); + + it('should reject SameSite=None after secure auto resolves to false', () => { + const cp = CookieParser.create({ secure: 'auto' }); + const cookie = new Cookie('session', 'v', { sameSite: 'none' }); + let caught: unknown; + try { + cp.serialize(cookie, { isSecure: false }); + } catch (e) { + caught = e; + } + expect(caught).toBeInstanceOf(CookieError); + expect((caught as CookieError).reason).toBe( + CookieErrorReason.SameSiteNoneRequiresSecure, + ); + }); + it('should throw PartitionedRequiresSecure when Partitioned without Secure', () => { + const cp = CookieParser.create(); + const cookie = new Cookie('session', 'v', { partitioned: true }); + let caught: unknown; + try { + cp.serialize(cookie); + } catch (e) { + caught = e; + } + expect(caught).toBeInstanceOf(CookieError); + expect((caught as CookieError).reason).toBe( + CookieErrorReason.PartitionedRequiresSecure, + ); + }); + + it('should allow Partitioned with Secure', () => { + const cp = CookieParser.create(); + const cookie = new Cookie('session', 'v', { partitioned: true, secure: true }); + expect(() => cp.serialize(cookie)).not.toThrow(); + }); + + it('should reject domain with semicolon at Cookie construction (Bun validates)', () => { + expect(() => new Cookie('session', 'v', { domain: 'evil.com; Path=/' })).toThrow(); + }); + + it('should reject domain with newline at Cookie construction (Bun validates)', () => { + expect(() => new Cookie('session', 'v', { domain: "evil.com\r\nSet-Cookie: bad=1" })).toThrow(); + }); + + it('should reject path with semicolon at Cookie construction (Bun validates)', () => { + expect(() => new Cookie('session', 'v', { path: '/; Domain=evil.com' })).toThrow(); + }); + + it('should reject path with newline at Cookie construction (Bun validates)', () => { + expect(() => new Cookie('session', 'v', { path: "/\r\nSet-Cookie: bad=1" })).toThrow(); + }); + + it('should allow valid domain and path', () => { + const cp = CookieParser.create(); + const cookie = new Cookie('session', 'v', { + domain: 'example.com', + path: '/app/dashboard', + secure: true, + }); + expect(() => cp.serialize(cookie)).not.toThrow(); + }); + }); + + describe('name binding (C1, C2 fixes)', () => { + const SIGN_SECRET = 'CBzj5JR05_07YsY5omzjqXIij4t3dRfV53j5O7CQJ7A'; + const ENC_SECRET = 'cR4uVjV4lfCVnqFwvsyNGlH7SJ_mtBG5OdXE-evGkIY'; + + it('should reject HMAC-signed value when cookie name differs (C1)', async () => { + const cp = CookieParser.create({ secrets: [SIGN_SECRET] }); + const signed = cp.sign(new Cookie('admin', 'true')); + // Replay signature under a different cookie name + const replayed = new Cookie('user', signed.value); + let caught: unknown; + try { await cp.unsign(replayed); } catch (e) { caught = e; } + expect(caught).toBeInstanceOf(CookieError); + expect((caught as CookieError).reason).toBe(CookieErrorReason.SignatureVerificationFailed); + }); + + it('should reject AES-GCM ciphertext when cookie name differs (C2)', async () => { + const cp = CookieParser.create({ encryptionSecret: ENC_SECRET }); + const encrypted = await cp.encrypt(new Cookie('admin', 'true')); + const replayed = new Cookie('user', encrypted.value); + let caught: unknown; + try { await cp.decrypt(replayed); } catch (e) { caught = e; } + expect(caught).toBeInstanceOf(CookieError); + expect((caught as CookieError).reason).toBe(CookieErrorReason.DecryptionFailed); + }); + + it('should sign and unsign successfully when cookie name matches', async () => { + const cp = CookieParser.create({ secrets: [SIGN_SECRET] }); + const signed = cp.sign(new Cookie('session', 'data')); + const unsigned = await cp.unsign(signed); + expect(unsigned.value).toBe('data'); + }); + + it('should encrypt and decrypt successfully when cookie name matches', async () => { + const cp = CookieParser.create({ encryptionSecret: ENC_SECRET }); + const encrypted = await cp.encrypt(new Cookie('session', 'data')); + const decrypted = await cp.decrypt(encrypted); + expect(decrypted.value).toBe('data'); + }); + }); + + describe('encryption key rotation (H2 fix)', () => { + const KEY_OLD = '6H3Sj5cLS9TVElTBHCWw8a90Gdi1B0TyW4hs5ZUXK8o'; + const KEY_NEW = 'ESduDrMmoDDKP-g1nZ882YzFcaZiYg-IzQoIiDqQ5kU'; + + it('should accept encryptionSecret as array', () => { + const cp = CookieParser.create({ encryptionSecret: [KEY_NEW, KEY_OLD] }); + expect(cp).toBeInstanceOf(CookieParser); + }); + + it('should encrypt with first key and decrypt with any key in array', async () => { + const cpOld = CookieParser.create({ encryptionSecret: KEY_OLD }); + const encryptedOld = await cpOld.encrypt(new Cookie('s', 'data')); + const cpRotated = CookieParser.create({ encryptionSecret: [KEY_NEW, KEY_OLD] }); + const decrypted = await cpRotated.decrypt(encryptedOld); + expect(decrypted.value).toBe('data'); + }); + + it('should fail to decrypt when no rotation key matches', async () => { + const cpA = CookieParser.create({ encryptionSecret: KEY_OLD }); + const encrypted = await cpA.encrypt(new Cookie('s', 'data')); + const cpB = CookieParser.create({ encryptionSecret: 'Zgpo7Ytgh_uw3ubvZ7SssN8oCbLdnr1DeeN6XSKScMA' }); + let caught: unknown; + try { await cpB.decrypt(encrypted); } catch (e) { caught = e; } + expect(caught).toBeInstanceOf(CookieError); + expect((caught as CookieError).reason).toBe(CookieErrorReason.DecryptionFailed); + }); + }); + + describe('weak secret rejection (H1, N8 fixes)', () => { + it('should throw WeakSecret when signing secret is shorter than 32 chars', () => { + let caught: unknown; + try { CookieParser.create({ secrets: ['short'] }); } catch (e) { caught = e; } + expect(caught).toBeInstanceOf(CookieError); + expect((caught as CookieError).reason).toBe(CookieErrorReason.WeakSecret); + }); + + it('should throw WeakSecret when encryption secret is shorter than 32 chars', () => { + let caught: unknown; + try { CookieParser.create({ encryptionSecret: 'short' }); } catch (e) { caught = e; } + expect(caught).toBeInstanceOf(CookieError); + expect((caught as CookieError).reason).toBe(CookieErrorReason.WeakSecret); + }); + + it('should accept high-entropy secret with exactly 32 chars', () => { + const exact32 = 'lncXWJjxZUmIjzo4ihUH_c0hxCdx4KKVTEeHMAACqZ4'; + expect(() => CookieParser.create({ secrets: [exact32] })).not.toThrow(); + expect(() => CookieParser.create({ encryptionSecret: exact32 })).not.toThrow(); + }); + + it('should reject low-entropy secret (single repeated char)', () => { + let caught: unknown; + try { CookieParser.create({ secrets: ['a'.repeat(40)] }); } catch (e) { caught = e; } + expect(caught).toBeInstanceOf(CookieError); + expect((caught as CookieError).reason).toBe(CookieErrorReason.WeakSecret); + }); + }); + + describe('maxAge integer validation (N6, N7 fixes)', () => { + it('should throw InvalidMaxAge when maxAge is NaN via createCookie (N6)', () => { + const cp = CookieParser.create(); + let caught: unknown; + try { cp.createCookie('n', 'v', { maxAge: NaN as any }); } catch (e) { caught = e; } + expect(caught).toBeInstanceOf(CookieError); + expect((caught as CookieError).reason).toBe(CookieErrorReason.InvalidMaxAge); + }); + + it('should throw InvalidMaxAge when maxAge is decimal 0.5 via createCookie (N7)', () => { + const cp = CookieParser.create(); + let caught: unknown; + try { cp.createCookie('n', 'v', { maxAge: 0.5 as any }); } catch (e) { caught = e; } + expect(caught).toBeInstanceOf(CookieError); + expect((caught as CookieError).reason).toBe(CookieErrorReason.InvalidMaxAge); + }); + + it('should throw InvalidMaxAge when maxAge is Infinity via createCookie', () => { + const cp = CookieParser.create(); + let caught: unknown; + try { cp.createCookie('n', 'v', { maxAge: Infinity as any }); } catch (e) { caught = e; } + expect(caught).toBeInstanceOf(CookieError); + expect((caught as CookieError).reason).toBe(CookieErrorReason.InvalidMaxAge); + }); + + it('should throw InvalidMaxAge in serialize when raw Cookie has decimal maxAge', () => { + const cp = CookieParser.create(); + let caught: unknown; + try { cp.serialize(new Cookie('n', 'v', { maxAge: 0.5 as any })); } catch (e) { caught = e; } + expect(caught).toBeInstanceOf(CookieError); + expect((caught as CookieError).reason).toBe(CookieErrorReason.InvalidMaxAge); + }); + + it('should accept negative integer maxAge per RFC 6265bis §5.4', () => { + const cp = CookieParser.create(); + const header = cp.serialize(cp.createCookie('n', 'v', { maxAge: -1 })); + expect(header).toContain('Max-Age=-1'); + }); + + it('should accept zero maxAge', () => { + const cp = CookieParser.create(); + const header = cp.serialize(cp.createCookie('n', 'v', { maxAge: 0 })); + expect(header).toContain('Max-Age=0'); + }); + }); + + describe('constant-time HMAC verify (H3 fix)', () => { + it('should verify correctly regardless of secret position in array', async () => { + const KEY1 = '-0dchjqFPQroVsWenM90XGv9NwJ0SfIeMvViNC_P90s'; + const KEY5 = 'AjtO7x4Fi8N8X8_vJRapkf8F-lmYjkzTyTMoSr5Ywv4'; + const cp = CookieParser.create({ secrets: [KEY1, 't5U2PwbDwqncuRrp7ugKdwCdVNxY9l59p0DpZtCsr_w', 'nbfONK9H2TJNeewNHc4JE00NwToJpRqL8-PFeQgPsz4', 'h4Y-jMwAdZXyGnHTqRK3f4spihRsOLqCr1Z8NokZBkc', KEY5] }); + // Sign with last key + const cpLast = CookieParser.create({ secrets: [KEY5] }); + const signed = cpLast.sign(new Cookie('s', 'data')); + // Verifies even though it's last in rotation array + const unsigned = await cp.unsign(signed); + expect(unsigned.value).toBe('data'); + }); + }); + + describe('token validation across all entry points (H-1 fix)', () => { + const SIGN_SECRET = 'mUGiDLrJDq7yYP8XCeTmvHFu6uUYzYLNhl03gLPfllA'; + const ENC_SECRET = 'weIQlNCq5MacmAUQsFI8EnM1NM4Dana95Mn48ResQYs'; + + function expectInvalidName(fn: () => unknown | Promise): Promise { + return Promise.resolve() + .then(fn) + .then( + () => { throw new Error('expected InvalidCookieName but no error thrown'); }, + (e) => { + expect(e).toBeInstanceOf(CookieError); + expect((e as CookieError).reason).toBe(CookieErrorReason.InvalidCookieName); + }, + ); + } + + it('should reject comma in name via serialize (RFC 9110 §5.6.2 token violation)', () => { + const cp = CookieParser.create(); + return expectInvalidName(() => cp.serialize(new Cookie('bad,name', 'v'))); + }); + + it('should reject paren in name via serialize', () => { + const cp = CookieParser.create(); + return expectInvalidName(() => cp.serialize(new Cookie('bad(name', 'v'))); + }); + + it('should reject quote in name via serialize', () => { + const cp = CookieParser.create(); + return expectInvalidName(() => cp.serialize(new Cookie('bad"name', 'v'))); + }); + + it('should reject @ in name via serialize', () => { + const cp = CookieParser.create(); + return expectInvalidName(() => cp.serialize(new Cookie('bad@name', 'v'))); + }); + + it('should reject invalid name via sign', () => { + const cp = CookieParser.create({ secrets: [SIGN_SECRET] }); + return expectInvalidName(() => cp.sign(new Cookie('bad,name', 'v'))); + }); + + it('should reject invalid name via encrypt', async () => { + const cp = CookieParser.create({ encryptionSecret: ENC_SECRET }); + await expectInvalidName(() => cp.encrypt(new Cookie('bad,name', 'v'))); + }); + + it('should reject invalid name via unsign', async () => { + const cp = CookieParser.create({ secrets: [SIGN_SECRET] }); + await expectInvalidName(() => cp.unsign(new Cookie('bad,name', 'v.sig'))); + }); + + it('should reject invalid name via decrypt', async () => { + const cp = CookieParser.create({ encryptionSecret: ENC_SECRET }); + await expectInvalidName(() => cp.decrypt(new Cookie('bad,name', 'bDx0MVBNq29dB9qJ7q1QHW_zSizEq3rqcoDOM_X7RWs'))); + }); + + it('should reject invalid name via validatePrefix', () => { + const cp = CookieParser.create(); + return expectInvalidName(() => cp.validatePrefix(new Cookie('bad,name', 'v'))); + }); + }); + + describe('expires normalization (H-2 fix)', () => { + it('should throw CookieError(InvalidExpires) for invalid date string via createCookie', () => { + const cp = CookieParser.create(); + let caught: unknown; + try { cp.createCookie('n', 'v', { expires: 'not-a-date' as any }); } catch (e) { caught = e; } + expect(caught).toBeInstanceOf(CookieError); + expect((caught as CookieError).reason).toBe(CookieErrorReason.InvalidExpires); + }); + + it('should throw CookieError(InvalidExpires) for NaN expires', () => { + const cp = CookieParser.create(); + let caught: unknown; + try { cp.createCookie('n', 'v', { expires: NaN as any }); } catch (e) { caught = e; } + expect(caught).toBeInstanceOf(CookieError); + expect((caught as CookieError).reason).toBe(CookieErrorReason.InvalidExpires); + }); + + it('should throw CookieError(InvalidExpires) for invalid Date object', () => { + const cp = CookieParser.create(); + let caught: unknown; + try { cp.createCookie('n', 'v', { expires: new Date('invalid') }); } catch (e) { caught = e; } + expect(caught).toBeInstanceOf(CookieError); + expect((caught as CookieError).reason).toBe(CookieErrorReason.InvalidExpires); + }); + + it('should throw CookieError(InvalidExpires) for Infinity expires', () => { + const cp = CookieParser.create(); + let caught: unknown; + try { cp.createCookie('n', 'v', { expires: Infinity as any }); } catch (e) { caught = e; } + expect(caught).toBeInstanceOf(CookieError); + expect((caught as CookieError).reason).toBe(CookieErrorReason.InvalidExpires); + }); + + it('should accept valid IMF-fixdate string', () => { + const cp = CookieParser.create(); + expect(() => cp.createCookie('n', 'v', { expires: 'Wed, 21 Oct 2026 07:28:00 GMT' })).not.toThrow(); + }); + + it('should accept valid Date object', () => { + const cp = CookieParser.create(); + const future = new Date(Date.now() + 30 * 86400 * 1000); + expect(() => cp.createCookie('n', 'v', { expires: future })).not.toThrow(); + }); + + it('should accept valid number timestamp', () => { + const cp = CookieParser.create(); + expect(() => cp.createCookie('n', 'v', { expires: Date.now() + 30 * 86400 * 1000 })).not.toThrow(); + }); + + it('should wrap Bun ctor errors into CookieError (no TypeError leak)', () => { + const cp = CookieParser.create(); + let caught: unknown; + try { cp.createCookie('n', 'v', { domain: 'evil; injected' as any }); } catch (e) { caught = e; } + expect(caught).toBeInstanceOf(CookieError); + expect((caught as CookieError).reason).toBe(CookieErrorReason.InvalidDomain); + }); + + it('should wrap Bun path errors into CookieError', () => { + const cp = CookieParser.create(); + let caught: unknown; + try { cp.createCookie('n', 'v', { path: '/x;injected' as any }); } catch (e) { caught = e; } + expect(caught).toBeInstanceOf(CookieError); + expect((caught as CookieError).reason).toBe(CookieErrorReason.InvalidPath); + }); + }); +}); diff --git a/packages/cookie/src/cookie-parser.ts b/packages/cookie/src/cookie-parser.ts new file mode 100644 index 0000000..3d0f5bd --- /dev/null +++ b/packages/cookie/src/cookie-parser.ts @@ -0,0 +1,722 @@ +import { Cookie } from 'bun'; +import { isErr } from '@zipbul/result'; + +import { CookieErrorReason } from './enums'; +import { CookieError, type CookieAttributes, type CookieParserOptions, type CookiePriority, type SerializeContext } from './interfaces'; +import { resolveCookieParserOptions, validateCookieParserOptions } from './options'; +import type { ResolvedCookieParserOptions } from './types'; + +const IV_LENGTH = 12; +const KID_LENGTH = 4; +const AUTH_TAG_BITS = 128; +const AUTH_TAG_BYTES = AUTH_TAG_BITS / 8; +const MIN_CIPHERTEXT_LENGTH = KID_LENGTH + IV_LENGTH + AUTH_TAG_BYTES; +const MAX_NAME_VALUE_OCTETS = 4096; +const MAX_ATTRIBUTE_OCTETS = 1024; +const MAX_HEADER_OCTETS = 8190; +const NAME_VALUE_SEPARATOR = '\x00'; +// NIST SP 800-38D §8.3: per-key invocation cap for AES-GCM with RBG-based 96-bit IV. +const GCM_MAX_INVOCATIONS = 2 ** 32; + +// Cookie name token per RFC 9110 §5.6.2 minus '%' (Bun.CookieMap percent-decodes inbound names; excluding '%' guarantees round-trip). +const INVALID_TOKEN_CHARS = /[^\x21\x23\x24\x26\x27\x2A\x2B\x2D\x2E\x30-\x39\x41-\x5A\x5E-\x7A\x7C\x7E]/; + +// RFC 1034/1123 subdomain LDH rule. Allows optional leading dot (RFC 6265 §4.1.2.3 — UA strips). +const RFC1123_DOMAIN = /^\.?[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$/i; + +const PRIORITY_VALUES: ReadonlySet = new Set(['low', 'medium', 'high']); + +const HKDF_INFO_HMAC = new TextEncoder().encode('@zipbul/cookie hmac v2'); +const HKDF_INFO_AES = new TextEncoder().encode('@zipbul/cookie aes-gcm v2'); + +const utf8 = new TextEncoder(); + +interface CookieMeta { + explicit: Set; + priority?: CookiePriority; +} + +export class CookieParser { + private readonly meta = new WeakMap(); + private readonly hmacKeyPromises: Promise<{ key: CryptoKey; kid: Uint8Array }>[]; + private readonly aesKeyPromises: Promise<{ key: CryptoKey; kid: Uint8Array }>[]; + private readonly encryptCounters: Map; + + private constructor(private readonly options: ResolvedCookieParserOptions) { + this.hmacKeyPromises = options.secrets !== null + ? options.secrets.map((s) => deriveHmacKey(s, this.hashName(), options.kdfSalt)) + : []; + this.aesKeyPromises = options.encryptionSecrets !== null + ? options.encryptionSecrets.map((s) => deriveAesKey(s, options.kdfSalt)) + : []; + this.encryptCounters = new Map(); + } + + public static create(options?: CookieParserOptions): CookieParser { + const resolved = resolveCookieParserOptions(options); + const validation = validateCookieParserOptions(resolved); + if (isErr(validation)) throw new CookieError(validation.data); + return new CookieParser(resolved); + } + + public get isSigningConfigured(): boolean { + return this.options.secrets !== null; + } + + public get isEncryptionConfigured(): boolean { + return this.options.encryptionSecrets !== null; + } + + public createCookie(name: string, value: string, options?: CookieAttributes): Cookie { + this.assertValidName(name); + this.assertNameValueSize(name, value); + + const explicit = new Set(); + if (options) { + for (const k of Object.keys(options) as (keyof CookieAttributes)[]) { + if (options[k] !== undefined) explicit.add(k); + } + } + + const merged = this.mergeAttributes(options); + + if (merged.maxAge != null) { + this.assertValidMaxAge(merged.maxAge); + } + if (merged.expires !== undefined && merged.expires !== null) { + this.assertValidExpires(merged.expires); + } + if (merged.domain != null) { + this.assertValidDomain(merged.domain); + } + if (merged.path != null) { + this.assertValidPath(merged.path); + } + if (merged.priority != null) { + this.assertValidPriority(merged.priority); + } + this.assertAttributeSizes(merged); + + const priority = merged.priority; + const bunOpts: Record = { ...merged }; + delete bunOpts.priority; + + let cookie: Cookie; + try { + cookie = new Cookie(name, value, bunOpts); + } catch (e) { + throw this.wrapBunError(e); + } + + this.meta.set(cookie, { explicit, priority }); + return cookie; + } + + public serialize(cookie: Cookie, context?: SerializeContext): string { + this.assertValidName(cookie.name); + this.assertNameValueSize(cookie.name, cookie.value); + + const meta = this.meta.get(cookie); + const explicit = meta?.explicit ?? new Set(); + const { defaults } = this.options; + + const target = this.applyDefaultsForSerialize(cookie, explicit, context); + + if (target.sameSite === 'none' && !target.secure) { + throw new CookieError({ + reason: CookieErrorReason.SameSiteNoneRequiresSecure, + message: 'SameSite=None cookies must have the Secure attribute', + }); + } + if (target.partitioned && !target.secure) { + throw new CookieError({ + reason: CookieErrorReason.PartitionedRequiresSecure, + message: 'Partitioned cookies must have the Secure attribute', + }); + } + if (target.maxAge != null) { + this.assertValidMaxAge(target.maxAge); + } + if (target.domain != null) this.assertValidDomain(target.domain); + if (target.path != null) this.assertValidPath(target.path); + + if (this.options.prefixValidation) { + this.validatePrefix(target); + } + + let header: string; + try { + header = target.serialize(); + } catch (e) { + throw this.wrapBunError(e); + } + + // RFC 7231 §7.1.1.1: IMF-fixdate MUST end with " GMT" and use 2-digit day. + // Bun.Cookie emits "Fri, 1 Jan 1970 00:00:00 -0000" — non-conformant. Rewrite using toUTCString(). + if (target.expires != null) { + const ms = target.expires instanceof Date + ? target.expires.getTime() + : Date.parse(String(target.expires)); + if (Number.isFinite(ms)) { + const canonical = new Date(ms).toUTCString(); + header = header.replace(/Expires=[^;]+/i, 'Expires=' + canonical); + } + } + + const priority = meta?.priority ?? (defaults.priority ?? null); + if (priority !== null) { + const cap = priority.charAt(0).toUpperCase() + priority.slice(1); + header = `${header}; Priority=${cap}`; + } + + if (Buffer.byteLength(header, 'utf8') > MAX_HEADER_OCTETS) { + throw new CookieError({ + reason: CookieErrorReason.CookieTooLarge, + message: `serialized cookie exceeds ${MAX_HEADER_OCTETS} bytes`, + }); + } + + return header; + } + + public sign(cookie: Cookie): Cookie { + if (this.options.secrets === null) { + throw new CookieError({ + reason: CookieErrorReason.SigningNotConfigured, + message: 'signing requires secrets to be configured', + }); + } + this.assertValidName(cookie.name); + + const data = utf8.encode(cookie.name + NAME_VALUE_SEPARATOR + cookie.value); + const signed = this.signSync(data); + return this.cloneWithValue(cookie, `${cookie.value}.${signed}`); + } + + public async unsign(cookie: Cookie): Promise { + if (this.options.secrets === null) { + throw new CookieError({ + reason: CookieErrorReason.SigningNotConfigured, + message: 'unsigning requires secrets to be configured', + }); + } + this.assertValidName(cookie.name); + + const dotIndex = cookie.value.lastIndexOf('.'); + if (dotIndex === -1) { + throw new CookieError({ + reason: CookieErrorReason.InvalidSignature, + message: 'signed cookie value must contain a dot separator', + }); + } + + const value = cookie.value.slice(0, dotIndex); + const signature = cookie.value.slice(dotIndex + 1); + let sigBlob: Uint8Array; + try { + sigBlob = bufferFromB64Url(signature); + } catch { + throw new CookieError({ + reason: CookieErrorReason.SignatureVerificationFailed, + message: 'cookie signature verification failed', + }); + } + if (sigBlob.length < KID_LENGTH + 1) { + throw new CookieError({ + reason: CookieErrorReason.SignatureVerificationFailed, + message: 'cookie signature verification failed', + }); + } + + const sigKid = sigBlob.subarray(0, KID_LENGTH); + const macBytes = sigBlob.subarray(KID_LENGTH); + const dataBytes = utf8.encode(cookie.name + NAME_VALUE_SEPARATOR + value); + + // Strict KID matching: a cookie's signature MUST identify a configured key by its KID. + // We still iterate every configured key (constant-time over the key set) and verify on KID match, + // never short-circuiting, to avoid leaking which slot matched. + let valid = false; + for (const keyEntry of this.hmacKeyPromises) { + const { key, kid } = await keyEntry; + const kidMatches = constantTimeEqual(sigKid, kid); + const ok = await crypto.subtle.verify('HMAC', key, macBytes as Uint8Array, dataBytes); + valid = valid || (kidMatches && ok); + } + + if (valid) { + return this.cloneWithValue(cookie, value); + } + + throw new CookieError({ + reason: CookieErrorReason.SignatureVerificationFailed, + message: 'cookie signature verification failed', + }); + } + + public async encrypt(cookie: Cookie): Promise { + if (this.options.encryptionSecrets === null) { + throw new CookieError({ + reason: CookieErrorReason.EncryptionNotConfigured, + message: 'encryption requires encryptionSecret to be configured', + }); + } + this.assertValidName(cookie.name); + + // Atomically reserve a counter slot BEFORE any await — otherwise concurrent + // encrypt() calls would all observe the same `current` and the NIST SP 800-38D §8.3 + // invocation cap could be exceeded silently. + const current = this.encryptCounters.get(0) ?? 0; + if (current >= GCM_MAX_INVOCATIONS) { + throw new CookieError({ + reason: CookieErrorReason.EncryptionKeyExhausted, + message: `AES-GCM key reached the NIST SP 800-38D §8.3 invocation cap (${GCM_MAX_INVOCATIONS}); rotate the encryption key`, + }); + } + const next = current + 1; + this.encryptCounters.set(0, next); + + const { key, kid } = await this.aesKeyPromises[0]!; + const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH)); + const aad = utf8.encode(cookie.name); + + const ciphertext = await crypto.subtle.encrypt( + { name: 'AES-GCM', iv, additionalData: aad, tagLength: AUTH_TAG_BITS }, + key, + utf8.encode(cookie.value), + ); + + const ctBytes = new Uint8Array(ciphertext); + const combined = new Uint8Array(KID_LENGTH + IV_LENGTH + ctBytes.length); + combined.set(kid, 0); + combined.set(iv, KID_LENGTH); + combined.set(ctBytes, KID_LENGTH + IV_LENGTH); + + if (this.options.onEncrypt !== null) { + try { this.options.onEncrypt({ keyIndex: 0, counter: next }); } catch { /* swallow */ } + } + + return this.cloneWithValue(cookie, bufferToB64Url(combined)); + } + + public async decrypt(cookie: Cookie): Promise { + if (this.options.encryptionSecrets === null) { + throw new CookieError({ + reason: CookieErrorReason.EncryptionNotConfigured, + message: 'decryption requires encryptionSecret to be configured', + }); + } + this.assertValidName(cookie.name); + + let combined: Uint8Array; + try { + combined = bufferFromB64Url(cookie.value); + } catch { + throw new CookieError({ + reason: CookieErrorReason.InvalidCiphertext, + message: 'ciphertext is not valid base64url', + }); + } + if (combined.length < MIN_CIPHERTEXT_LENGTH) { + throw new CookieError({ + reason: CookieErrorReason.InvalidCiphertext, + message: 'ciphertext is too short to be valid', + }); + } + + const ctKid = combined.subarray(0, KID_LENGTH); + const iv = combined.subarray(KID_LENGTH, KID_LENGTH + IV_LENGTH); + const ct = combined.subarray(KID_LENGTH + IV_LENGTH); + const aad = utf8.encode(cookie.name); + + const matchedKeys: CryptoKey[] = []; + const allKeys: CryptoKey[] = []; + for (const entry of this.aesKeyPromises) { + const { key, kid } = await entry; + allKeys.push(key); + if (constantTimeEqual(ctKid, kid)) matchedKeys.push(key); + } + + const tryKeys = matchedKeys.length > 0 ? matchedKeys : allKeys; + for (const key of tryKeys) { + try { + const plaintext = await crypto.subtle.decrypt( + { name: 'AES-GCM', iv: iv as Uint8Array, additionalData: aad, tagLength: AUTH_TAG_BITS }, + key, + ct as Uint8Array, + ); + return this.cloneWithValue(cookie, new TextDecoder().decode(plaintext)); + } catch { /* try next */ } + } + + throw new CookieError({ + reason: CookieErrorReason.DecryptionFailed, + message: 'cookie decryption failed', + }); + } + + public validatePrefix(cookie: Cookie): void { + this.assertValidName(cookie.name); + const lower = cookie.name.toLowerCase(); + + if (lower.startsWith('__host-')) { + if (!cookie.secure) { + throw new CookieError({ + reason: CookieErrorReason.HostPrefixRequiresSecure, + message: '__Host- cookies must have the Secure attribute', + }); + } + if (cookie.domain != null && cookie.domain !== '') { + throw new CookieError({ + reason: CookieErrorReason.HostPrefixForbidsDomain, + message: '__Host- cookies must not have a Domain attribute', + }); + } + if (cookie.path !== '/') { + throw new CookieError({ + reason: CookieErrorReason.HostPrefixRequiresRootPath, + message: '__Host- cookies must have Path=/', + }); + } + return; + } + + if (lower.startsWith('__secure-')) { + if (!cookie.secure) { + throw new CookieError({ + reason: CookieErrorReason.SecurePrefixRequiresSecure, + message: '__Secure- cookies must have the Secure attribute', + }); + } + } + } + + // --- internals --- + + private hashName(): 'SHA-256' | 'SHA-384' | 'SHA-512' { + return `SHA-${this.options.algorithm.slice(3)}` as 'SHA-256' | 'SHA-384' | 'SHA-512'; + } + + private mergeAttributes(options?: CookieAttributes): CookieAttributes { + const { defaults } = this.options; + const merged: CookieAttributes = {}; + + if (defaults.httpOnly !== null) merged.httpOnly = defaults.httpOnly; + if (defaults.secure !== null && defaults.secure !== 'auto') merged.secure = defaults.secure; + if (defaults.sameSite !== null) merged.sameSite = defaults.sameSite; + if (defaults.path !== null) merged.path = defaults.path; + if (defaults.domain !== null) merged.domain = defaults.domain; + if (defaults.maxAge !== null) merged.maxAge = defaults.maxAge; + if (defaults.expires !== null) merged.expires = defaults.expires; + if (defaults.partitioned !== null) merged.partitioned = defaults.partitioned; + if (defaults.priority !== null) merged.priority = defaults.priority; + + if (options) { + for (const [key, val] of Object.entries(options)) { + if (val === undefined || val === null) continue; + (merged as Record)[key] = val; + } + } + + // Bun.Cookie throws on capitalized SameSite ('Lax'/'Strict'/'None'); normalize to lowercase + // so user input following common spec docs (which use Pascal-case) doesn't crash. + if (typeof merged.sameSite === 'string') { + merged.sameSite = merged.sameSite.toLowerCase() as typeof merged.sameSite; + } + + return merged; + } + + private applyDefaultsForSerialize( + cookie: Cookie, + explicit: Set, + context?: SerializeContext, + ): Cookie { + const { defaults } = this.options; + + let resolvedSecure: boolean | undefined = undefined; + if (defaults.secure === 'auto' && !explicit.has('secure')) { + // 'auto' is a security feature; require an explicit channel signal so we never silently + // emit an insecure cookie that the caller intended to be Secure. + if (context === undefined || context.isSecure === undefined) { + throw new CookieError({ + reason: CookieErrorReason.InvalidAttribute, + message: "secure: 'auto' requires SerializeContext.isSecure to be passed (true for HTTPS, false for plain HTTP)", + }); + } + resolvedSecure = context.isSecure; + } + + const applyDomain = cookie.domain == null && defaults.domain !== null; + const applyMaxAge = cookie.maxAge == null && defaults.maxAge !== null; + const applyExpires = cookie.expires == null && defaults.expires !== null; + const applySecure = resolvedSecure !== undefined; + + if (!applyDomain && !applyMaxAge && !applyExpires && !applySecure) { + return cookie; + } + + return new Cookie(cookie.name, cookie.value, { + domain: applyDomain ? defaults.domain! : (cookie.domain ?? undefined), + path: cookie.path ?? undefined, + secure: applySecure ? resolvedSecure! : cookie.secure, + httpOnly: cookie.httpOnly, + sameSite: cookie.sameSite ?? undefined, + maxAge: applyMaxAge ? defaults.maxAge! : (cookie.maxAge ?? undefined), + expires: applyExpires ? defaults.expires! : (cookie.expires ?? undefined), + partitioned: cookie.partitioned, + }); + } + + private signSync(data: Uint8Array): string { + const secret = this.options.secrets![0]!; + const hash = this.hashName(); + const derivedKey = deriveHmacKeyBytesSync(secret, hash, this.options.kdfSalt); + const algoName = hash.toLowerCase().replace('-', '') as 'sha256' | 'sha384' | 'sha512'; + const hasher = new Bun.CryptoHasher(algoName, derivedKey); + hasher.update(data); + const mac = hasher.digest(); + const kidHash = new Bun.CryptoHasher('sha256'); + kidHash.update(derivedKey); + const kid = new Uint8Array(kidHash.digest()).subarray(0, KID_LENGTH); + const blob = new Uint8Array(KID_LENGTH + mac.byteLength); + blob.set(kid, 0); + blob.set(new Uint8Array(mac), KID_LENGTH); + return bufferToB64Url(blob); + } + + private assertValidName(name: string): void { + if (name.length === 0 || INVALID_TOKEN_CHARS.test(name)) { + throw new CookieError({ + reason: CookieErrorReason.InvalidCookieName, + message: 'cookie name must be a valid RFC 9110 token', + }); + } + } + + private assertNameValueSize(name: string, value: string): void { + const bytes = Buffer.byteLength(name, 'utf8') + Buffer.byteLength(value, 'utf8'); + if (bytes > MAX_NAME_VALUE_OCTETS) { + throw new CookieError({ + reason: CookieErrorReason.CookieTooLarge, + message: `cookie name+value exceeds ${MAX_NAME_VALUE_OCTETS} octets (${bytes})`, + }); + } + } + + private assertAttributeSizes(merged: CookieAttributes): void { + const check = (label: string, val: string | undefined) => { + if (val === undefined) return; + const len = Buffer.byteLength(val, 'utf8'); + if (len > MAX_ATTRIBUTE_OCTETS) { + throw new CookieError({ + reason: CookieErrorReason.AttributeTooLarge, + message: `${label} attribute exceeds ${MAX_ATTRIBUTE_OCTETS} octets (${len})`, + }); + } + }; + check('Domain', merged.domain); + check('Path', merged.path); + if (typeof merged.expires === 'string') check('Expires', merged.expires); + } + + private assertValidMaxAge(maxAge: number): void { + if (!Number.isInteger(maxAge)) { + throw new CookieError({ + reason: CookieErrorReason.InvalidMaxAge, + message: 'Max-Age must be a finite integer', + }); + } + } + + private assertValidExpires(expires: number | Date | string): void { + let ms: number; + if (typeof expires === 'number') { + ms = expires; + } else if (expires instanceof Date) { + ms = expires.getTime(); + } else { + ms = Date.parse(expires); + } + if (!Number.isFinite(ms)) { + throw new CookieError({ + reason: CookieErrorReason.InvalidExpires, + message: 'expires must be a finite timestamp, Date, or RFC 7231 IMF-fixdate string', + }); + } + } + + private assertValidDomain(domain: string): void { + if (domain.length === 0) { + throw new CookieError({ + reason: CookieErrorReason.InvalidDomain, + message: 'Domain attribute must not be an empty string', + }); + } + // RFC 6265 §4.1.1: subdomain syntax forbids CTLs implicitly via LDH; explicit reject is defense-in-depth. + if (/[\x00-\x1F\x7F;]/.test(domain)) { + throw new CookieError({ + reason: CookieErrorReason.InvalidDomain, + message: 'Domain must not contain control characters or ";"', + }); + } + if (!RFC1123_DOMAIN.test(domain)) { + throw new CookieError({ + reason: CookieErrorReason.InvalidDomain, + message: 'Domain must be a valid RFC 1123 subdomain (LDH rule)', + }); + } + } + + private assertValidPath(path: string): void { + // RFC 6265 §4.1.1: path-value = *. CTLs = %x00-1F / %x7F. + if (/[\x00-\x1F\x7F;]/.test(path)) { + throw new CookieError({ + reason: CookieErrorReason.InvalidPath, + message: 'Path must not contain control characters or ";"', + }); + } + if (path !== '' && !path.startsWith('/')) { + throw new CookieError({ + reason: CookieErrorReason.InvalidPath, + message: 'Path must start with "/"', + }); + } + } + + private assertValidPriority(p: string): void { + if (!PRIORITY_VALUES.has(p as CookiePriority)) { + throw new CookieError({ + reason: CookieErrorReason.InvalidPriority, + message: 'priority must be one of: low, medium, high', + }); + } + } + + private wrapBunError(e: unknown): CookieError { + if (e instanceof CookieError) return e; + // Use the upstream message ONLY for routing — never re-emit it (defense against future Bun + // error formats that might echo input bytes; CWE-117). + const message = e instanceof Error ? e.message : String(e); + if (/cookie name/i.test(message)) { + return new CookieError({ reason: CookieErrorReason.InvalidCookieName, message: 'invalid cookie name' }); + } + if (/expir/i.test(message)) { + return new CookieError({ reason: CookieErrorReason.InvalidExpires, message: 'invalid expires attribute' }); + } + if (/domain/i.test(message)) { + return new CookieError({ reason: CookieErrorReason.InvalidDomain, message: 'invalid domain attribute' }); + } + if (/path/i.test(message)) { + return new CookieError({ reason: CookieErrorReason.InvalidPath, message: 'invalid path attribute' }); + } + if (/value/i.test(message)) { + return new CookieError({ reason: CookieErrorReason.InvalidCookieValue, message: 'invalid cookie value' }); + } + return new CookieError({ reason: CookieErrorReason.CookieParserError, message: 'cookie parser error' }); + } + + private cloneWithValue(source: Cookie, newValue: string): Cookie { + const { defaults } = this.options; + const cloned = new Cookie(source.name, newValue, { + domain: source.domain ?? defaults.domain ?? undefined, + path: source.path ?? undefined, + secure: source.secure, + httpOnly: source.httpOnly, + sameSite: source.sameSite ?? undefined, + maxAge: source.maxAge ?? defaults.maxAge ?? undefined, + expires: source.expires ?? defaults.expires ?? undefined, + partitioned: source.partitioned, + }); + const sourceMeta = this.meta.get(source); + if (sourceMeta) this.meta.set(cloned, sourceMeta); + return cloned; + } +} + +// --- key derivation --- + +async function deriveHmacKey(secret: string, hash: 'SHA-256' | 'SHA-384' | 'SHA-512', salt: Uint8Array): Promise<{ key: CryptoKey; kid: Uint8Array }> { + const ikm = utf8.encode(secret); + const baseKey = await crypto.subtle.importKey('raw', ikm, 'HKDF', false, ['deriveBits']); + const bits = await crypto.subtle.deriveBits( + { name: 'HKDF', hash, salt: salt as Uint8Array, info: HKDF_INFO_HMAC }, + baseKey, + 256, + ); + const keyBytes = new Uint8Array(bits); + const key = await crypto.subtle.importKey( + 'raw', keyBytes, { name: 'HMAC', hash }, false, ['verify'], + ); + const kid = await deriveKid(keyBytes); + return { key, kid }; +} + +async function deriveAesKey(secret: string, salt: Uint8Array): Promise<{ key: CryptoKey; kid: Uint8Array }> { + const ikm = utf8.encode(secret); + const baseKey = await crypto.subtle.importKey('raw', ikm, 'HKDF', false, ['deriveBits']); + const bits = await crypto.subtle.deriveBits( + { name: 'HKDF', hash: 'SHA-256', salt: salt as Uint8Array, info: HKDF_INFO_AES }, + baseKey, + 256, + ); + const keyBytes = new Uint8Array(bits); + const key = await crypto.subtle.importKey( + 'raw', keyBytes, 'AES-GCM', false, ['encrypt', 'decrypt'], + ); + const kid = await deriveKid(keyBytes); + return { key, kid }; +} + +async function deriveKid(keyBytes: Uint8Array): Promise { + const h = await crypto.subtle.digest('SHA-256', keyBytes as Uint8Array); + return new Uint8Array(h, 0, KID_LENGTH); +} + +function deriveHmacKeyBytesSync(secret: string, hash: 'SHA-256' | 'SHA-384' | 'SHA-512', salt: Uint8Array): Uint8Array { + // Sync HKDF derivation that mirrors async deriveHmacKey output exactly. + const prk = hkdfExtract(secret, salt, hash); + return hkdfExpand(prk, HKDF_INFO_HMAC, 32, hash); +} + +function hkdfExtract(ikm: string | Uint8Array, salt: Uint8Array, hash: 'SHA-256' | 'SHA-384' | 'SHA-512'): Uint8Array { + const algoName = hash.toLowerCase().replace('-', ''); + const h = new Bun.CryptoHasher(algoName as 'sha256' | 'sha384' | 'sha512', salt); + h.update(typeof ikm === 'string' ? utf8.encode(ikm) : ikm); + return new Uint8Array(h.digest()); +} + +function hkdfExpand(prk: Uint8Array, info: Uint8Array, length: number, hash: 'SHA-256' | 'SHA-384' | 'SHA-512'): Uint8Array { + const algoName = hash.toLowerCase().replace('-', '') as 'sha256' | 'sha384' | 'sha512'; + const hashLen = hash === 'SHA-256' ? 32 : hash === 'SHA-384' ? 48 : 64; + const N = Math.ceil(length / hashLen); + const out = new Uint8Array(N * hashLen); + let prev = new Uint8Array(0); + for (let i = 1; i <= N; i++) { + const h = new Bun.CryptoHasher(algoName, prk); + h.update(prev); + h.update(info); + h.update(new Uint8Array([i])); + prev = new Uint8Array(h.digest()); + out.set(prev, (i - 1) * hashLen); + } + return out.subarray(0, length); +} + +function bufferFromB64Url(s: string): Uint8Array { + const buf = Buffer.from(s, 'base64url'); + const ab = new ArrayBuffer(buf.byteLength); + const out = new Uint8Array(ab); + out.set(buf); + return out; +} + +function bufferToB64Url(bytes: Uint8Array): string { + return Buffer.from(bytes.buffer as ArrayBuffer, bytes.byteOffset, bytes.byteLength).toString('base64url'); +} + +function constantTimeEqual(a: Uint8Array, b: Uint8Array): boolean { + if (a.length !== b.length) return false; + let diff = 0; + for (let i = 0; i < a.length; i++) diff |= a[i]! ^ b[i]!; + return diff === 0; +} diff --git a/packages/cookie/src/enums.ts b/packages/cookie/src/enums.ts new file mode 100644 index 0000000..8167cab --- /dev/null +++ b/packages/cookie/src/enums.ts @@ -0,0 +1,31 @@ +export enum CookieErrorReason { + EmptySecrets = 'empty-secrets', + InvalidSecret = 'invalid-secret', + InvalidSignature = 'invalid-signature', + SignatureVerificationFailed = 'signature-verification-failed', + InvalidEncryptionSecret = 'invalid-encryption-secret', + InvalidCiphertext = 'invalid-ciphertext', + DecryptionFailed = 'decryption-failed', + SecurePrefixRequiresSecure = 'secure-prefix-requires-secure', + HostPrefixRequiresSecure = 'host-prefix-requires-secure', + HostPrefixForbidsDomain = 'host-prefix-forbids-domain', + HostPrefixRequiresRootPath = 'host-prefix-requires-root-path', + SigningNotConfigured = 'signing-not-configured', + EncryptionKeyExhausted = 'encryption-key-exhausted', + EncryptionNotConfigured = 'encryption-not-configured', + InvalidAlgorithm = 'invalid-algorithm', + InvalidCookieName = 'invalid-cookie-name', + CookieTooLarge = 'cookie-too-large', + AttributeTooLarge = 'attribute-too-large', + SameSiteNoneRequiresSecure = 'samesite-none-requires-secure', + PartitionedRequiresSecure = 'partitioned-requires-secure', + InvalidDomain = 'invalid-domain', + InvalidPath = 'invalid-path', + InvalidPriority = 'invalid-priority', + WeakSecret = 'weak-secret', + InvalidMaxAge = 'invalid-max-age', + InvalidExpires = 'invalid-expires', + InvalidCookieValue = 'invalid-cookie-value', + InvalidAttribute = 'invalid-attribute', + CookieParserError = 'cookie-parser-error', +} diff --git a/packages/cookie/src/interfaces.ts b/packages/cookie/src/interfaces.ts new file mode 100644 index 0000000..4f5620e --- /dev/null +++ b/packages/cookie/src/interfaces.ts @@ -0,0 +1,58 @@ +import type { CookieErrorReason } from './enums'; + +/** @internal */ +export interface CookieErrorData { + reason: CookieErrorReason; + message: string; +} + +export class CookieError extends Error { + public readonly reason: CookieErrorReason; + + constructor(data: CookieErrorData) { + super(data.message); + this.name = 'CookieError'; + this.reason = data.reason; + } +} + +export type CookiePriority = 'low' | 'medium' | 'high'; + +export interface CookieParserOptions { + secrets?: string[]; + algorithm?: 'sha256' | 'sha384' | 'sha512'; + encryptionSecret?: string | string[]; + prefixValidation?: boolean; + onEncrypt?: (info: { keyIndex: number; counter: number }) => void; + /** + * Optional HKDF salt (RFC 5869 §3.1). When omitted, a fixed library default is used. + * Supplying a per-deployment salt ensures two installations sharing the same secret derive + * independent keys. Must be at least 16 bytes when provided; longer is fine. + */ + kdfSalt?: string | Uint8Array; + httpOnly?: boolean; + secure?: boolean | 'auto'; + sameSite?: 'strict' | 'lax' | 'none'; + path?: string; + domain?: string; + maxAge?: number; + expires?: number | Date | string; + partitioned?: boolean; + priority?: CookiePriority; +} + +export interface CookieAttributes { + domain?: string; + path?: string; + secure?: boolean; + httpOnly?: boolean; + sameSite?: 'strict' | 'lax' | 'none'; + maxAge?: number; + expires?: number | Date | string; + partitioned?: boolean; + priority?: CookiePriority; +} + +export interface SerializeContext { + isSecure?: boolean; +} diff --git a/packages/cookie/src/options.spec.ts b/packages/cookie/src/options.spec.ts new file mode 100644 index 0000000..57ab9ad --- /dev/null +++ b/packages/cookie/src/options.spec.ts @@ -0,0 +1,225 @@ +import { describe, expect, it } from 'bun:test'; +import { isErr } from '@zipbul/result'; +import type { Err } from '@zipbul/result'; + +import { CookieErrorReason } from './enums'; +import type { CookieErrorData } from './interfaces'; +import { resolveCookieParserOptions, validateCookieParserOptions } from './options'; + +const VALID_SECRET = 'zt3oaxqd6dOCT4bNxEsuMoLxbpCnfOyiWBwS4vBWzxM'; +const VALID_ENC_SECRET = '5qly1QnPB1M6tT3thbFxuaY6A7OXv2zS8_O3VTHTAQ8'; + +describe('resolveCookieParserOptions', () => { + it('should return all defaults when no options provided', () => { + const resolved = resolveCookieParserOptions(); + expect(resolved.secrets).toBeNull(); + expect(resolved.algorithm).toBe('sha256'); + expect(resolved.encryptionSecrets).toBeNull(); + expect(resolved.prefixValidation).toBe(true); + expect(resolved.defaults.httpOnly).toBeNull(); + expect(resolved.defaults.secure).toBeNull(); + expect(resolved.defaults.sameSite).toBeNull(); + expect(resolved.defaults.path).toBeNull(); + expect(resolved.defaults.domain).toBeNull(); + expect(resolved.defaults.maxAge).toBeNull(); + expect(resolved.defaults.expires).toBeNull(); + expect(resolved.defaults.partitioned).toBeNull(); + }); + + it('should return all defaults when empty options provided', () => { + const resolved = resolveCookieParserOptions({}); + expect(resolved.secrets).toBeNull(); + expect(resolved.algorithm).toBe('sha256'); + expect(resolved.prefixValidation).toBe(true); + }); + + it('should resolve secrets when provided', () => { + const resolved = resolveCookieParserOptions({ secrets: [VALID_SECRET, VALID_SECRET + '_alt'] }); + expect(resolved.secrets).toEqual([VALID_SECRET, VALID_SECRET + '_alt']); + }); + + it('should resolve algorithm when provided', () => { + const resolved = resolveCookieParserOptions({ algorithm: 'sha512' }); + expect(resolved.algorithm).toBe('sha512'); + }); + + it('should normalize encryptionSecret single string into array', () => { + const resolved = resolveCookieParserOptions({ encryptionSecret: VALID_ENC_SECRET }); + expect(resolved.encryptionSecrets).toEqual([VALID_ENC_SECRET]); + }); + + it('should pass encryptionSecret array through unchanged', () => { + const resolved = resolveCookieParserOptions({ encryptionSecret: [VALID_ENC_SECRET, VALID_ENC_SECRET + '_alt'] }); + expect(resolved.encryptionSecrets).toEqual([VALID_ENC_SECRET, VALID_ENC_SECRET + '_alt']); + }); + + it('should resolve prefixValidation when provided', () => { + const resolved = resolveCookieParserOptions({ prefixValidation: true }); + expect(resolved.prefixValidation).toBe(true); + }); + + it('should resolve cookie defaults when provided', () => { + const resolved = resolveCookieParserOptions({ + httpOnly: true, + secure: true, + sameSite: 'strict', + path: '/', + domain: 'example.com', + maxAge: 3600, + expires: 1000, + partitioned: true, + }); + expect(resolved.defaults.httpOnly).toBe(true); + expect(resolved.defaults.secure).toBe(true); + expect(resolved.defaults.sameSite).toBe('strict'); + expect(resolved.defaults.path).toBe('/'); + expect(resolved.defaults.domain).toBe('example.com'); + expect(resolved.defaults.maxAge).toBe(3600); + expect(resolved.defaults.expires).toBe(1000); + expect(resolved.defaults.partitioned).toBe(true); + }); + + it('should resolve secure auto when provided', () => { + const resolved = resolveCookieParserOptions({ secure: 'auto' }); + expect(resolved.defaults.secure).toBe('auto'); + }); +}); + +describe('validateCookieParserOptions', () => { + it('should return undefined when options are valid', () => { + const resolved = resolveCookieParserOptions({ secrets: [VALID_SECRET], encryptionSecret: VALID_ENC_SECRET }); + expect(validateCookieParserOptions(resolved)).toBeUndefined(); + }); + + it('should return undefined when no options provided', () => { + const resolved = resolveCookieParserOptions(); + expect(validateCookieParserOptions(resolved)).toBeUndefined(); + }); + + it('should return EmptySecrets when secrets array is empty', () => { + const resolved = resolveCookieParserOptions(); + resolved.secrets = []; + const result = validateCookieParserOptions(resolved); + expect(isErr(result)).toBe(true); + expect((result as Err).data.reason).toBe(CookieErrorReason.EmptySecrets); + }); + + it('should return InvalidSecret when a secret is blank', () => { + const resolved = resolveCookieParserOptions(); + resolved.secrets = [VALID_SECRET, ' ']; + const result = validateCookieParserOptions(resolved); + expect(isErr(result)).toBe(true); + expect((result as Err).data.reason).toBe(CookieErrorReason.InvalidSecret); + }); + + it('should return WeakSecret when a signing secret is shorter than 32 chars', () => { + const resolved = resolveCookieParserOptions(); + resolved.secrets = ['short-secret']; + const result = validateCookieParserOptions(resolved); + expect(isErr(result)).toBe(true); + expect((result as Err).data.reason).toBe(CookieErrorReason.WeakSecret); + }); + + it('should return InvalidEncryptionSecret when encryptionSecret is blank', () => { + const resolved = resolveCookieParserOptions(); + resolved.encryptionSecrets = [' ']; + const result = validateCookieParserOptions(resolved); + expect(isErr(result)).toBe(true); + expect((result as Err).data.reason).toBe(CookieErrorReason.InvalidEncryptionSecret); + }); + + it('should return InvalidEncryptionSecret when encryptionSecrets array is empty', () => { + const resolved = resolveCookieParserOptions(); + resolved.encryptionSecrets = []; + const result = validateCookieParserOptions(resolved); + expect(isErr(result)).toBe(true); + expect((result as Err).data.reason).toBe(CookieErrorReason.InvalidEncryptionSecret); + }); + + it('should return WeakSecret when an encryptionSecret is shorter than 32 chars', () => { + const resolved = resolveCookieParserOptions(); + resolved.encryptionSecrets = ['short-enc']; + const result = validateCookieParserOptions(resolved); + expect(isErr(result)).toBe(true); + expect((result as Err).data.reason).toBe(CookieErrorReason.WeakSecret); + }); + + it('should return InvalidAlgorithm when algorithm is unsupported', () => { + const resolved = resolveCookieParserOptions(); + resolved.algorithm = 'md5' as any; + const result = validateCookieParserOptions(resolved); + expect(isErr(result)).toBe(true); + expect((result as Err).data.reason).toBe(CookieErrorReason.InvalidAlgorithm); + }); + + it('should accept sha384 algorithm', () => { + const resolved = resolveCookieParserOptions({ algorithm: 'sha384' }); + expect(validateCookieParserOptions(resolved)).toBeUndefined(); + }); + + it('should accept sha512 algorithm', () => { + const resolved = resolveCookieParserOptions({ algorithm: 'sha512' }); + expect(validateCookieParserOptions(resolved)).toBeUndefined(); + }); +}); + +describe('kdfSalt option (RFC 5869 §3.1)', () => { + it('uses default salt when omitted', () => { + const r = resolveCookieParserOptions(); + expect(r.kdfSalt).toBeInstanceOf(Uint8Array); + expect(r.kdfSalt.length).toBeGreaterThanOrEqual(16); + }); + it('accepts string salt', () => { + const r = resolveCookieParserOptions({ kdfSalt: 'my-deployment-salt-2026__padding' }); + expect(new TextDecoder().decode(r.kdfSalt)).toBe('my-deployment-salt-2026__padding'); + }); + it('accepts Uint8Array salt', () => { + const bytes = new Uint8Array(20).fill(7); + const r = resolveCookieParserOptions({ kdfSalt: bytes }); + expect(r.kdfSalt).toBe(bytes); + }); + it('rejects salt shorter than 16 bytes', () => { + const r = validateCookieParserOptions(resolveCookieParserOptions({ kdfSalt: 'short' })); + expect(r).toBeDefined(); + expect((r as Err).data.message).toContain('16 bytes'); + }); +}); + +describe('validateSecretStrength entropy floor (NIST SP 800-131A / OWASP)', () => { + it('rejects 32-byte low-entropy secret "abcdefgh".repeat(4) (96 bits)', () => { + const r = validateCookieParserOptions(resolveCookieParserOptions({ secrets: ['abcdefgh'.repeat(4)] })); + expect(r).toBeDefined(); + const e = (r as Err).data; + expect(e.reason).toBe(CookieErrorReason.WeakSecret); + expect(e.message).toContain('entropy too low'); + }); + it('rejects 32-byte secret of single repeated byte (0 bits)', () => { + const r = validateCookieParserOptions(resolveCookieParserOptions({ secrets: ['x'.repeat(40)] })); + expect(r).toBeDefined(); + expect((r as Err).data.reason).toBe(CookieErrorReason.WeakSecret); + }); + it('rejects 31-byte secret regardless of entropy', () => { + const r = validateCookieParserOptions(resolveCookieParserOptions({ secrets: [VALID_SECRET.slice(0, 31)] })); + expect(r).toBeDefined(); + const e = (r as Err).data; + expect(e.reason).toBe(CookieErrorReason.WeakSecret); + expect(e.message).toContain('32 bytes'); + }); + it('counts UTF-8 bytes, not UTF-16 code units (32-byte ASCII vs 16 emoji + 16 ASCII)', () => { + // 16 emoji = 64 UTF-8 bytes alone, easily over 32 bytes. + const r = validateCookieParserOptions(resolveCookieParserOptions({ secrets: ['🔐'.repeat(16) + 'abcdefghijklmnop'] })); + expect(r).toBeUndefined(); + }); + it('accepts uniform random base64url 32-byte secret', () => { + const bytes = new Uint8Array(32); + crypto.getRandomValues(bytes); + const secret = Buffer.from(bytes).toString('base64url'); + const r = validateCookieParserOptions(resolveCookieParserOptions({ secrets: [secret] })); + expect(r).toBeUndefined(); + }); + it('applies the same check to encryptionSecret', () => { + const r = validateCookieParserOptions(resolveCookieParserOptions({ encryptionSecret: 'abcdefgh'.repeat(4) })); + expect(r).toBeDefined(); + expect((r as Err).data.reason).toBe(CookieErrorReason.WeakSecret); + }); +}); diff --git a/packages/cookie/src/options.ts b/packages/cookie/src/options.ts new file mode 100644 index 0000000..7322f55 --- /dev/null +++ b/packages/cookie/src/options.ts @@ -0,0 +1,142 @@ +import { err } from '@zipbul/result'; +import type { Result } from '@zipbul/result'; + +import { CookieErrorReason } from './enums'; +import type { CookieErrorData, CookieParserOptions } from './interfaces'; +import type { ResolvedCookieDefaults, ResolvedCookieParserOptions } from './types'; + +const VALID_ALGORITHMS: ReadonlySet = new Set(['sha256', 'sha384', 'sha512']); +// Bytes (UTF-8), not UTF-16 code units. 32 bytes = 256 bits, the AES-256 / HMAC-SHA-256 key size. +const MIN_SECRET_BYTES = 32; +const MIN_KDF_SALT_BYTES = 16; +const DEFAULT_KDF_SALT = new TextEncoder().encode('@zipbul/cookie/2026'); +// Shannon entropy lower bound (bits) over the secret's byte distribution. +// Random 32 bytes yield ~256 bits; uniform base64-32 yields ~190 bits. +// 'abcdefgh'.repeat(4) yields exactly 96 bits → rejected at 128. +const MIN_SECRET_ENTROPY_BITS = 128; + +export function resolveCookieParserOptions(options?: CookieParserOptions): ResolvedCookieParserOptions { + const defaults: ResolvedCookieDefaults = { + httpOnly: options?.httpOnly ?? null, + secure: options?.secure ?? null, + sameSite: options?.sameSite ?? null, + path: options?.path ?? null, + domain: options?.domain ?? null, + maxAge: options?.maxAge ?? null, + expires: options?.expires ?? null, + partitioned: options?.partitioned ?? null, + priority: options?.priority ?? null, + }; + + const encryptionSecrets = options?.encryptionSecret == null + ? null + : Array.isArray(options.encryptionSecret) + ? options.encryptionSecret + : [options.encryptionSecret]; + + let kdfSalt: Uint8Array = DEFAULT_KDF_SALT; + if (options?.kdfSalt !== undefined) { + kdfSalt = typeof options.kdfSalt === 'string' + ? new TextEncoder().encode(options.kdfSalt) + : options.kdfSalt; + } + + return { + secrets: options?.secrets ?? null, + algorithm: options?.algorithm ?? 'sha256', + encryptionSecrets, + prefixValidation: options?.prefixValidation ?? true, + onEncrypt: options?.onEncrypt ?? null, + kdfSalt, + defaults, + }; +} + +// Shannon entropy of a byte string in bits: H = (-Σ p_i·log2(p_i)) × length. +// This is a lower-bound proxy for actual min-entropy. A secret that fails this check is +// definitely weak; passing does not prove cryptographic strength. +function shannonEntropyBits(bytes: Uint8Array): number { + if (bytes.length === 0) return 0; + const counts = new Uint32Array(256); + for (let i = 0; i < bytes.length; i++) counts[bytes[i]!]! += 1; + let h = 0; + for (let i = 0; i < 256; i++) { + const c = counts[i]!; + if (c === 0) continue; + const p = c / bytes.length; + h -= p * Math.log2(p); + } + return h * bytes.length; +} + +function validateSecretStrength(secret: string, label: string): Result { + if (secret.trim().length === 0) { + return err({ + reason: label === 'encryptionSecret' + ? CookieErrorReason.InvalidEncryptionSecret + : CookieErrorReason.InvalidSecret, + message: `each ${label} must be a non-blank string`, + }); + } + const bytes = new TextEncoder().encode(secret); + if (bytes.length < MIN_SECRET_BYTES) { + return err({ + reason: CookieErrorReason.WeakSecret, + message: `each ${label} must be at least ${MIN_SECRET_BYTES} bytes (UTF-8); got ${bytes.length}`, + }); + } + const entropy = shannonEntropyBits(bytes); + if (entropy < MIN_SECRET_ENTROPY_BITS) { + return err({ + reason: CookieErrorReason.WeakSecret, + message: `${label} entropy too low: estimated ${entropy.toFixed(1)} bits, need ≥${MIN_SECRET_ENTROPY_BITS} bits (OWASP / NIST SP 800-131A). Supply uniform random bytes, e.g. Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString('base64url').`, + }); + } + return undefined; +} + +export function validateCookieParserOptions( + resolved: ResolvedCookieParserOptions, +): Result { + if (resolved.secrets !== null) { + if (resolved.secrets.length === 0) { + return err({ + reason: CookieErrorReason.EmptySecrets, + message: 'secrets array must not be empty', + }); + } + for (const secret of resolved.secrets) { + const r = validateSecretStrength(secret, 'signing secret'); + if (r !== undefined) return r; + } + } + + if (resolved.encryptionSecrets !== null) { + if (resolved.encryptionSecrets.length === 0) { + return err({ + reason: CookieErrorReason.InvalidEncryptionSecret, + message: 'encryptionSecret array must not be empty', + }); + } + for (const secret of resolved.encryptionSecrets) { + const r = validateSecretStrength(secret, 'encryptionSecret'); + if (r !== undefined) return r; + } + } + + if (!VALID_ALGORITHMS.has(resolved.algorithm)) { + return err({ + reason: CookieErrorReason.InvalidAlgorithm, + message: 'algorithm must be one of: sha256, sha384, sha512', + }); + } + + if (resolved.kdfSalt.length < MIN_KDF_SALT_BYTES) { + return err({ + reason: CookieErrorReason.InvalidAttribute, + message: `kdfSalt must be at least ${MIN_KDF_SALT_BYTES} bytes (RFC 5869 §3.1)`, + }); + } + + return undefined; +} diff --git a/packages/cookie/src/types.ts b/packages/cookie/src/types.ts new file mode 100644 index 0000000..254d0d0 --- /dev/null +++ b/packages/cookie/src/types.ts @@ -0,0 +1,25 @@ +import type { CookiePriority } from './interfaces'; + +export type SigningAlgorithm = 'sha256' | 'sha384' | 'sha512'; + +export type ResolvedCookieParserOptions = { + secrets: string[] | null; + algorithm: SigningAlgorithm; + encryptionSecrets: string[] | null; + prefixValidation: boolean; + onEncrypt: ((info: { keyIndex: number; counter: number }) => void) | null; + kdfSalt: Uint8Array; + defaults: ResolvedCookieDefaults; +}; + +export type ResolvedCookieDefaults = { + httpOnly: boolean | null; + secure: boolean | 'auto' | null; + sameSite: 'strict' | 'lax' | 'none' | null; + path: string | null; + domain: string | null; + maxAge: number | null; + expires: number | Date | string | null; + partitioned: boolean | null; + priority: CookiePriority | null; +}; diff --git a/packages/cookie/test/conformance/rfc-conformance.test.ts b/packages/cookie/test/conformance/rfc-conformance.test.ts new file mode 100644 index 0000000..d8892ca --- /dev/null +++ b/packages/cookie/test/conformance/rfc-conformance.test.ts @@ -0,0 +1,355 @@ +/** + * RFC 6265bis / RFC 9110 / NIST SP 800-38D / FIPS 198-1 conformance matrix. + * Each test maps to a specific clause and must remain green to claim conformance. + */ +import { describe, expect, it } from 'bun:test'; +import { Cookie } from 'bun'; + +import { CookieParser, CookieJar, CookieError, CookieErrorReason } from '../../index'; + +const SECRET = 'yiLuooc8t1iy7BDCaU2eExB60URL8zacnqb1mA66aIo'; +const ENC = 'v3MALRP-T0CO2gZ46D5As25K-U1D74PDhsdQJGjk4QQ'; + +describe('RFC 9110 §5.6.2 — token grammar', () => { + const cp = CookieParser.create(); + const separators = ['(', ')', '<', '>', '@', ',', ';', ':', '\\', '"', '/', '[', ']', '?', '=', '{', '}']; + for (const ch of separators) { + it(`rejects "${ch}" in cookie name via createCookie`, () => { + const name = `bad${ch}name`; + let caught: unknown; + try { cp.createCookie(name, 'v'); } catch (e) { caught = e; } + expect(caught).toBeInstanceOf(CookieError); + }); + it(`rejects "${ch}" in cookie name via serialize when Bun ctor accepts it`, () => { + const name = `bad${ch}name`; + let raw: Cookie | undefined; + try { raw = new Cookie(name, 'v'); } catch { /* Bun rejected — already safe */ } + if (raw === undefined) return; + let caught: unknown; + try { cp.serialize(raw); } catch (e) { caught = e; } + expect(caught).toBeInstanceOf(CookieError); + }); + } + it('rejects CTL chars (0x00-0x1F, 0x7F) in name', () => { + for (const code of [0, 1, 9, 10, 13, 31, 127]) { + let caught: unknown; + try { cp.createCookie(`bad${String.fromCharCode(code)}`, 'v'); } catch (e) { caught = e; } + expect(caught).toBeInstanceOf(CookieError); + } + }); + it('accepts all valid tchar in cookie name (excluding "%" for Bun.CookieMap interop)', () => { + const valid = "!#$&'*+-.^_`|~0aZ"; + expect(() => cp.createCookie(valid, 'v')).not.toThrow(); + }); +}); + +describe('RFC 6265bis §4.1.1 — Set-Cookie syntax', () => { + it('produces name=value pair as first token', () => { + const cp = CookieParser.create(); + const h = cp.serialize(new Cookie('s', 'v')); + expect(h.startsWith('s=v')).toBe(true); + }); + it('separates attributes with "; "', () => { + const cp = CookieParser.create(); + const h = cp.serialize(new Cookie('s', 'v', { secure: true, httpOnly: true })); + expect(h).toMatch(/; /); + }); +}); + +describe('RFC 6265bis §4.1.2.1 — Expires attribute', () => { + const cp = CookieParser.create(); + it('rejects unparseable expires string', () => { + let caught: unknown; + try { cp.createCookie('s', 'v', { expires: 'definitely not a date' }); } catch (e) { caught = e; } + expect(caught).toBeInstanceOf(CookieError); + expect((caught as CookieError).reason).toBe(CookieErrorReason.InvalidExpires); + }); + it('accepts IMF-fixdate per RFC 7231', () => { + expect(() => cp.createCookie('s', 'v', { expires: 'Sun, 06 Nov 1994 08:49:37 GMT' })).not.toThrow(); + }); +}); + +describe('RFC 6265bis §4.1.2.2 — Max-Age attribute', () => { + const cp = CookieParser.create(); + it('rejects non-integer Max-Age (NaN)', () => { + let caught: unknown; + try { cp.createCookie('s', 'v', { maxAge: NaN }); } catch (e) { caught = e; } + expect((caught as CookieError).reason).toBe(CookieErrorReason.InvalidMaxAge); + }); + it('rejects non-integer Max-Age (decimal)', () => { + let caught: unknown; + try { cp.createCookie('s', 'v', { maxAge: 1.5 }); } catch (e) { caught = e; } + expect((caught as CookieError).reason).toBe(CookieErrorReason.InvalidMaxAge); + }); + it('accepts negative integer Max-Age (immediate expiry)', () => { + expect(() => cp.serialize(cp.createCookie('s', 'v', { maxAge: -1 }))).not.toThrow(); + }); +}); + +describe('RFC 6265bis §4.1.2.7 — SameSite=None requires Secure', () => { + const cp = CookieParser.create(); + it('rejects SameSite=None without Secure', () => { + let caught: unknown; + try { cp.serialize(new Cookie('s', 'v', { sameSite: 'none' })); } catch (e) { caught = e; } + expect((caught as CookieError).reason).toBe(CookieErrorReason.SameSiteNoneRequiresSecure); + }); +}); + +describe('RFC 6265bis §4.1.3.1 — __Secure- prefix', () => { + const cp = CookieParser.create(); + it('requires Secure attribute', () => { + let caught: unknown; + try { cp.validatePrefix(new Cookie('__Secure-x', 'v')); } catch (e) { caught = e; } + expect((caught as CookieError).reason).toBe(CookieErrorReason.SecurePrefixRequiresSecure); + }); +}); + +describe('RFC 6265bis §4.1.3.2 — __Host- prefix', () => { + const cp = CookieParser.create(); + it('requires Secure', () => { + let caught: unknown; + try { cp.validatePrefix(new Cookie('__Host-x', 'v', { path: '/' })); } catch (e) { caught = e; } + expect((caught as CookieError).reason).toBe(CookieErrorReason.HostPrefixRequiresSecure); + }); + it('forbids Domain', () => { + let caught: unknown; + try { cp.validatePrefix(new Cookie('__Host-x', 'v', { secure: true, domain: 'example.com', path: '/' })); } catch (e) { caught = e; } + expect((caught as CookieError).reason).toBe(CookieErrorReason.HostPrefixForbidsDomain); + }); + it('requires Path=/', () => { + let caught: unknown; + try { cp.validatePrefix(new Cookie('__Host-x', 'v', { secure: true, path: '/admin' })); } catch (e) { caught = e; } + expect((caught as CookieError).reason).toBe(CookieErrorReason.HostPrefixRequiresRootPath); + }); +}); + +describe('CHIPS — Partitioned requires Secure', () => { + const cp = CookieParser.create(); + it('rejects Partitioned without Secure', () => { + let caught: unknown; + try { cp.serialize(new Cookie('s', 'v', { partitioned: true })); } catch (e) { caught = e; } + expect((caught as CookieError).reason).toBe(CookieErrorReason.PartitionedRequiresSecure); + }); +}); + +describe('RFC 6265 §6.1 — 4096 octet limit', () => { + const cp = CookieParser.create(); + it('rejects serialized header > 4096 octets', () => { + let caught: unknown; + try { cp.serialize(new Cookie('s', 'x'.repeat(4096))); } catch (e) { caught = e; } + expect((caught as CookieError).reason).toBe(CookieErrorReason.CookieTooLarge); + }); +}); + +describe('NIST SP 800-38D — AES-256-GCM parameters', () => { + it('uses 12-byte IV (clause 5.2.1.1)', async () => { + const cp = CookieParser.create({ encryptionSecret: ENC }); + const enc = await cp.encrypt(new Cookie('s', 'v')); + const decoded = Buffer.from(enc.value, 'base64url'); + // First 12 bytes are IV + expect(decoded.length).toBeGreaterThanOrEqual(12 + 16); // IV + tag minimum + }); + it('uses 128-bit auth tag (clause 5.2.1.2)', async () => { + const cp = CookieParser.create({ encryptionSecret: ENC }); + // KID(4) + IV(12) + tag(16) = 32 bytes minimum (empty plaintext) + const enc = await cp.encrypt(new Cookie('s', '')); + const decoded = Buffer.from(enc.value, 'base64url'); + expect(decoded.length).toBe(4 + 12 + 16); + }); + it('produces unique IV across calls (probabilistic)', async () => { + const cp = CookieParser.create({ encryptionSecret: ENC }); + const a = await cp.encrypt(new Cookie('s', 'v')); + const b = await cp.encrypt(new Cookie('s', 'v')); + // bytes 4..15 are IV (after 4-byte KID prefix) + const aIv = Buffer.from(a.value, 'base64url').subarray(4, 16).toString('hex'); + const bIv = Buffer.from(b.value, 'base64url').subarray(4, 16).toString('hex'); + expect(aIv).not.toBe(bIv); + }); + it('binds cookie name as AAD (rejects cross-name replay)', async () => { + const cp = CookieParser.create({ encryptionSecret: ENC }); + const enc = await cp.encrypt(new Cookie('admin', 'true')); + let caught: unknown; + try { await cp.decrypt(new Cookie('user', enc.value)); } catch (e) { caught = e; } + expect((caught as CookieError).reason).toBe(CookieErrorReason.DecryptionFailed); + }); +}); + +describe('FIPS 198-1 §A — HMAC key minimum length L/2', () => { + it('SHA-256 (L=32, L/2=16) accepts 32-byte key', () => { + expect(() => CookieParser.create({ secrets: ['yiLuooc8t1iy7BDCaU2eExB60URL8zacnqb1mA66aIo'], algorithm: 'sha256' })).not.toThrow(); + }); + it('rejects key shorter than 32 (configured minimum)', () => { + let caught: unknown; + try { CookieParser.create({ secrets: ['x'.repeat(31)] }); } catch (e) { caught = e; } + expect((caught as CookieError).reason).toBe(CookieErrorReason.WeakSecret); + }); +}); + +describe('RFC 6265 §5.4 — cookie header parsing', () => { + it('parses name-value pairs separated by "; "', () => { + const cp = CookieParser.create(); + const jar = new CookieJar(cp, 'a=1; b=2; c=3'); + expect(jar.getRaw('a')).toBe('1'); + expect(jar.getRaw('b')).toBe('2'); + expect(jar.getRaw('c')).toBe('3'); + }); + it('handles empty Cookie header', () => { + const cp = CookieParser.create(); + const jar = new CookieJar(cp, ''); + expect(jar.has('any')).toBe(false); + }); +}); + +describe('Cross-instance isolation guarantees', () => { + it('encrypted cookie from instance A cannot be decrypted by instance B with different key', async () => { + const a = CookieParser.create({ encryptionSecret: 'yiLuooc8t1iy7BDCaU2eExB60URL8zacnqb1mA66aIo' }); + const b = CookieParser.create({ encryptionSecret: 'v3MALRP-T0CO2gZ46D5As25K-U1D74PDhsdQJGjk4QQ' }); + const enc = await a.encrypt(new Cookie('s', 'secret')); + let caught: unknown; + try { await b.decrypt(enc); } catch (e) { caught = e; } + expect((caught as CookieError).reason).toBe(CookieErrorReason.DecryptionFailed); + }); + it('signed cookie from instance A cannot be unsigned by instance B with different key', async () => { + const a = CookieParser.create({ secrets: ['yiLuooc8t1iy7BDCaU2eExB60URL8zacnqb1mA66aIo'] }); + const b = CookieParser.create({ secrets: ['v3MALRP-T0CO2gZ46D5As25K-U1D74PDhsdQJGjk4QQ'] }); + const signed = a.sign(new Cookie('s', 'v')); + let caught: unknown; + try { await b.unsign(signed); } catch (e) { caught = e; } + expect((caught as CookieError).reason).toBe(CookieErrorReason.SignatureVerificationFailed); + }); + it('algorithm confusion: sha256-signed cannot be unsigned with sha512', async () => { + const a = CookieParser.create({ secrets: [SECRET], algorithm: 'sha256' }); + const b = CookieParser.create({ secrets: [SECRET], algorithm: 'sha512' }); + const signed = a.sign(new Cookie('s', 'v')); + let caught: unknown; + try { await b.unsign(signed); } catch (e) { caught = e; } + expect((caught as CookieError).reason).toBe(CookieErrorReason.SignatureVerificationFailed); + }); +}); + +describe('RFC 6265bis §5.6 — name+value 4096 cap (R1)', () => { + const cp = CookieParser.create(); + it('accepts name+value exactly 4096 octets', () => { + expect(() => cp.createCookie('s', 'x'.repeat(4095))).not.toThrow(); + }); + it('rejects name+value 4097 octets', () => { + let caught: unknown; + try { cp.createCookie('s', 'x'.repeat(4096)); } catch (e) { caught = e; } + expect((caught as CookieError).reason).toBe(CookieErrorReason.CookieTooLarge); + }); +}); + +describe('RFC 6265bis §5.6 — attribute-value 1024 cap (R2)', () => { + const cp = CookieParser.create(); + it('rejects Path > 1024 octets', () => { + let caught: unknown; + try { cp.createCookie('s', 'v', { path: '/' + 'a'.repeat(1024) }); } catch (e) { caught = e; } + expect((caught as CookieError).reason).toBe(CookieErrorReason.AttributeTooLarge); + }); +}); + +describe('RFC 6265bis §5.7 — case-insensitive prefix matching (R3)', () => { + const cp = CookieParser.create({ prefixValidation: true }); + it('detects __host- (lowercase) as host-prefixed', () => { + let caught: unknown; + try { cp.serialize(new Cookie('__host-x', 'v', { domain: 'example.com', secure: true, path: '/' })); } catch (e) { caught = e; } + expect((caught as CookieError).reason).toBe(CookieErrorReason.HostPrefixForbidsDomain); + }); + it('detects __SECURE- (uppercase) as secure-prefixed', () => { + let caught: unknown; + try { cp.serialize(new Cookie('__SECURE-x', 'v')); } catch (e) { caught = e; } + expect((caught as CookieError).reason).toBe(CookieErrorReason.SecurePrefixRequiresSecure); + }); +}); + +describe('RFC 1123 Domain syntax (NEW-4)', () => { + const cp = CookieParser.create(); + it('rejects consecutive-dot domain', () => { + let caught: unknown; + try { cp.createCookie('s', 'v', { domain: 'a..b.com' }); } catch (e) { caught = e; } + expect((caught as CookieError).reason).toBe(CookieErrorReason.InvalidDomain); + }); + it('rejects leading-hyphen label', () => { + let caught: unknown; + try { cp.createCookie('s', 'v', { domain: '-bad.com' }); } catch (e) { caught = e; } + expect((caught as CookieError).reason).toBe(CookieErrorReason.InvalidDomain); + }); + it('rejects trailing-hyphen label', () => { + let caught: unknown; + try { cp.createCookie('s', 'v', { domain: 'bad-.com' }); } catch (e) { caught = e; } + expect((caught as CookieError).reason).toBe(CookieErrorReason.InvalidDomain); + }); + it('accepts valid two-label domain', () => { + expect(() => cp.createCookie('s', 'v', { domain: 'example.com' })).not.toThrow(); + }); +}); + +describe('Priority attribute (2-D)', () => { + const cp = CookieParser.create(); + it('emits Priority=High for priority:high', () => { + const c = cp.createCookie('s', 'v', { priority: 'high' }); + expect(cp.serialize(c)).toContain('Priority=High'); + }); + it('rejects invalid priority value', () => { + let caught: unknown; + try { cp.createCookie('s', 'v', { priority: 'urgent' as any }); } catch (e) { caught = e; } + expect((caught as CookieError).reason).toBe(CookieErrorReason.InvalidPriority); + }); +}); + +describe('AES-GCM IV usage counter hook (1-E)', () => { + it('invokes onEncrypt with monotonic counter', async () => { + const calls: { keyIndex: number; counter: number }[] = []; + const cp = CookieParser.create({ encryptionSecret: ENC, onEncrypt: (info) => calls.push(info) }); + await cp.encrypt(new Cookie('s', 'v')); + await cp.encrypt(new Cookie('s', 'v')); + expect(calls).toEqual([{ keyIndex: 0, counter: 1 }, { keyIndex: 0, counter: 2 }]); + }); +}); + +describe('HKDF key derivation + KID (1-A, 1-B)', () => { + it('encrypted payload starts with 4-byte KID prefix', async () => { + const cp = CookieParser.create({ encryptionSecret: ENC }); + const enc = await cp.encrypt(new Cookie('s', '')); + const decoded = Buffer.from(enc.value, 'base64url'); + expect(decoded.length).toBe(4 + 12 + 16); + }); + it('signed payload signature blob starts with 4-byte KID', () => { + const cp = CookieParser.create({ secrets: [SECRET] }); + const signed = cp.sign(new Cookie('s', 'v')); + const sig = signed.value.split('.').pop()!; + const decoded = Buffer.from(sig, 'base64url'); + // KID(4) + HMAC-SHA256(32) = 36 bytes + expect(decoded.length).toBe(4 + 32); + }); + it('KID mismatch causes signature to be rejected', async () => { + const cp = CookieParser.create({ secrets: [SECRET] }); + const signed = cp.sign(new Cookie('s', 'v')); + const sig = signed.value.split('.').pop()!; + const buf = Buffer.from(sig, 'base64url'); + buf[0] = (buf[0]! ^ 0xff) & 0xff; + const tampered = `${signed.value.split('.').slice(0, -1).join('.')}.${buf.toString('base64url')}`; + let caught: unknown; + try { await cp.unsign(new Cookie('s', tampered)); } catch (e) { caught = e; } + expect((caught as CookieError).reason).toBe(CookieErrorReason.SignatureVerificationFailed); + }); +}); + +describe('CookieError is the only public exception type', () => { + const cp = CookieParser.create(); + it('createCookie wraps Bun TypeError on bad domain', () => { + let caught: unknown; + try { cp.createCookie('s', 'v', { domain: 'evil; injected' }); } catch (e) { caught = e; } + expect(caught).toBeInstanceOf(CookieError); + }); + it('createCookie wraps Bun TypeError on bad path', () => { + let caught: unknown; + try { cp.createCookie('s', 'v', { path: '/x;injected' }); } catch (e) { caught = e; } + expect(caught).toBeInstanceOf(CookieError); + }); + it('createCookie wraps Bun TypeError on bad expires', () => { + let caught: unknown; + try { cp.createCookie('s', 'v', { expires: 'not-a-date' }); } catch (e) { caught = e; } + expect(caught).toBeInstanceOf(CookieError); + }); +}); diff --git a/packages/cookie/test/e2e/cookie-parser.test.ts b/packages/cookie/test/e2e/cookie-parser.test.ts new file mode 100644 index 0000000..4e7fa29 --- /dev/null +++ b/packages/cookie/test/e2e/cookie-parser.test.ts @@ -0,0 +1,177 @@ +import { describe, expect, it } from 'bun:test'; +import { isErr } from '@zipbul/result'; + +import { CookieParser, CookieJar } from '../../index'; + +describe('CookieParser E2E', () => { + describe('simulated HTTP request/response cycle via jar', () => { + it('should handle server setting signed+encrypted cookies and reading them back', async () => { + const parser = CookieParser.create({ + secrets: ['sWnweEbPWws_CcVlQqohqlGEUF462gq79jpEUxUBf6Y', 'BqkEVs6WS76VuKv1lVK-DuxK6fgH3zzFW1Zu7szIqls'], + encryptionSecret: 'BKHMtM44ThZwG0Ihx3UzQ8bcsn9iUNVW1QcdzozALfQ', + httpOnly: true, + secure: true, + sameSite: 'strict', + path: '/', + }); + + const sessionData = JSON.stringify({ userId: 42, role: 'admin' }); + + // Server sets cookie + const outJar = new CookieJar(parser, ''); + outJar.set('session', sessionData); + const setCookieHeaders = await outJar.getSetCookieHeaders(); + + expect(setCookieHeaders).toHaveLength(1); + expect(setCookieHeaders[0]).toContain('session='); + expect(setCookieHeaders[0]).not.toContain('"userId"'); + expect(setCookieHeaders[0]).toContain('Secure'); + expect(setCookieHeaders[0]).toContain('HttpOnly'); + + // Browser sends cookie back + const cookieValue = setCookieHeaders[0]!.split('=').slice(1).join('=').split(';')[0]!; + const inJar = new CookieJar(parser, `session=${cookieValue}`); + const restored = JSON.parse((await inJar.get('session')) as string); + expect(restored.userId).toBe(42); + expect(restored.role).toBe('admin'); + }); + + it('should handle mixed signed and encrypted cookies', async () => { + const parser = CookieParser.create({ + secrets: ['sWnweEbPWws_CcVlQqohqlGEUF462gq79jpEUxUBf6Y'], + encryptionSecret: 'BKHMtM44ThZwG0Ihx3UzQ8bcsn9iUNVW1QcdzozALfQ', + }); + + const outJar = new CookieJar(parser, ''); + outJar.set('token', 'jwt.payload.sig'); + outJar.set('prefs', 'theme=dark&lang=ko'); + const headers = await outJar.getSetCookieHeaders(); + + const cookieParts = headers.map((h) => { + const name = h.split('=')[0]!; + const value = h.split('=').slice(1).join('=').split(';')[0]!; + return `${name}=${value}`; + }); + + const inJar = new CookieJar(parser, cookieParts.join('; ')); + expect(await inJar.get('token')).toBe('jwt.payload.sig'); + expect(await inJar.get('prefs')).toBe('theme=dark&lang=ko'); + }); + }); + + describe('simulated Bun.serve handler via jar', () => { + it('should work within a Bun.serve-like request handler flow', async () => { + const parser = CookieParser.create({ + secrets: ['_3gIp5HxBBG0kcL5MNF5cgDjuMMTZUID7fUbGdu1rZE'], + encryptionSecret: 'Q10pigFEyHH2ByBTiwWGEqlNolvEbGXl2Zmb3b_AitY', + httpOnly: true, + secure: true, + path: '/', + sameSite: 'lax', + }); + + async function handleRequest(cookieHeader: string): Promise<{ + headers: Record; + body: string; + }> { + const jar = new CookieJar(parser, cookieHeader); + const session = await jar.get('session'); + + if (session === null || isErr(session)) { + jar.set('session', 'new-user'); + return { + headers: { 'Set-Cookie': await jar.getSetCookieHeaders() }, + body: 'Welcome, new user!', + }; + } + + return { headers: {}, body: `Welcome back, ${session}!` }; + } + + const firstResponse = await handleRequest(''); + expect(firstResponse.body).toBe('Welcome, new user!'); + expect(firstResponse.headers['Set-Cookie']).toHaveLength(1); + + const setCookie = firstResponse.headers['Set-Cookie']![0]!; + const cookieValue = setCookie.split('=').slice(1).join('=').split(';')[0]!; + + const secondResponse = await handleRequest(`session=${cookieValue}`); + expect(secondResponse.body).toBe('Welcome back, new-user!'); + }); + }); + + describe('simulated key rotation scenario via jar', () => { + it('should migrate cookies from old key to new key', async () => { + const parserOld = CookieParser.create({ + secrets: ['EPw6kC4Y8inFYkU_551drP-BmmjjOblwAuQjvRsaNi0'], + encryptionSecret: 'LMT2NcnJHtCSSU7h6JaaUpLdlqvH2bo0h89IkV1-rPI', + }); + + const outJar = new CookieJar(parserOld, ''); + outJar.set('session', 'user-data'); + const oldHeaders = await outJar.getSetCookieHeaders(); + const oldValue = oldHeaders[0]!.split('=').slice(1).join('=').split(';')[0]!; + + // Migration parser accepts both keys + const parserMigrate = CookieParser.create({ + secrets: ['zMbrG5cQwBFs9FUGYMgHs-j4kUHu0MMWVR61r_nd1Pw', 'EPw6kC4Y8inFYkU_551drP-BmmjjOblwAuQjvRsaNi0'], + encryptionSecret: 'LMT2NcnJHtCSSU7h6JaaUpLdlqvH2bo0h89IkV1-rPI', + }); + const migrateJar = new CookieJar(parserMigrate, `session=${oldValue}`); + expect(await migrateJar.get('session')).toBe('user-data'); + + // Re-sign with new key + migrateJar.set('session', 'user-data'); + const newHeaders = await migrateJar.getSetCookieHeaders(); + const newValue = newHeaders[0]!.split('=').slice(1).join('=').split(';')[0]!; + + // New-only parser can read + const parserNew = CookieParser.create({ + secrets: ['zMbrG5cQwBFs9FUGYMgHs-j4kUHu0MMWVR61r_nd1Pw'], + encryptionSecret: 'LMT2NcnJHtCSSU7h6JaaUpLdlqvH2bo0h89IkV1-rPI', + }); + const newJar = new CookieJar(parserNew, `session=${newValue}`); + expect(await newJar.get('session')).toBe('user-data'); + }); + }); + + describe('simulated middleware pattern via jar', () => { + it('should handle complete middleware lifecycle', async () => { + const parser = CookieParser.create({ + secrets: ['gHBB3MwkPytgNA9vApSMJRDqJIPMNXgLrHUKSJZy1Kg'], + encryptionSecret: '9v7BAwKpXHWZnoKZIHV2XWch22HvF8bleOM6t4nc-A4', + httpOnly: true, + secure: 'auto', + sameSite: 'lax', + path: '/', + prefixValidation: true, + }); + + // Middleware creates jar on request + async function middlewareOnRequest( + cookieHeader: string, + isSecure: boolean, + ): Promise<{ jar: CookieJar; context: { isSecure: boolean } }> { + return { + jar: new CookieJar(parser, cookieHeader), + context: { isSecure }, + }; + } + + // Handler uses jar + const { jar, context } = await middlewareOnRequest('', true); + const session = await jar.get('session'); + expect(session).toBeNull(); + + jar.set('session', 'new-session'); + jar.delete('old-token'); + + // Middleware serializes on response + const headers = await jar.getSetCookieHeaders(context); + expect(headers).toHaveLength(2); + expect(headers[0]).toContain('Secure'); + expect(headers[0]).toContain('HttpOnly'); + expect(headers[1]).toContain('Max-Age=0'); + }); + }); +}); diff --git a/packages/cookie/test/fuzz/properties.test.ts b/packages/cookie/test/fuzz/properties.test.ts new file mode 100644 index 0000000..cddad6d --- /dev/null +++ b/packages/cookie/test/fuzz/properties.test.ts @@ -0,0 +1,251 @@ +/** + * Property-based fuzz testing of CookieParser invariants. + * Each property is checked over many random inputs via fast-check. + */ +import { describe, it } from 'bun:test'; +import { Cookie } from 'bun'; +import * as fc from 'fast-check'; + +import { CookieParser, CookieError, CookieErrorReason } from '../../index'; + +const RUNS = 200; + +// RFC 9110 §5.6.2 tchar minus '%' (Bun.CookieMap percent-decodes — see cookie-parser.ts comment) +const tcharArr = "!#$&'*+-.^_`|~0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ".split(''); +const validName: fc.Arbitrary = fc + .array(fc.constantFrom(...tcharArr), { minLength: 1, maxLength: 32 }) + .map((arr) => arr.join('')); +// Cookie value: ASCII printable except controls (Bun percent-encodes the rest) +const validValue: fc.Arbitrary = fc + .array(fc.integer({ min: 0x20, max: 0x7e }).map((c) => String.fromCharCode(c)), { minLength: 0, maxLength: 100 }) + .map((arr) => arr.join('')); +// Use full-byte uint8 arrays (32–64 bytes) base64url-encoded so the entropy floor +// (Shannon ≥ 128 bits) is always satisfied — single-character shrink targets like +// "!!!!..." are correctly rejected by the parser and would otherwise fail this property. +const secret32: fc.Arbitrary = fc + .uint8Array({ minLength: 32, maxLength: 64 }) + .map((bytes) => Buffer.from(bytes).toString('base64url')); + +describe('Property: HMAC sign/unsign roundtrip', () => { + it('any value signed then unsigned returns the original', async () => { + await fc.assert( + fc.asyncProperty(validName, validValue, secret32, async (name, value, secret) => { + const cp = CookieParser.create({ secrets: [secret] }); + const signed = cp.sign(new Cookie(name, value)); + const unsigned = await cp.unsign(signed); + return unsigned.value === value; + }), + { numRuns: RUNS }, + ); + }); +}); + +describe('Property: AES-GCM encrypt/decrypt roundtrip', () => { + it('any value encrypted then decrypted returns the original', async () => { + await fc.assert( + fc.asyncProperty(validName, validValue, secret32, async (name, value, secret) => { + const cp = CookieParser.create({ encryptionSecret: secret }); + const enc = await cp.encrypt(new Cookie(name, value)); + const dec = await cp.decrypt(enc); + return dec.value === value; + }), + { numRuns: RUNS }, + ); + }); +}); + +describe('Property: encrypt produces unique ciphertext per call (IV randomness)', () => { + it('two encrypts of identical input never collide', async () => { + await fc.assert( + fc.asyncProperty(validName, validValue, async (name, value) => { + const cp = CookieParser.create({ encryptionSecret: 'yiLuooc8t1iy7BDCaU2eExB60URL8zacnqb1mA66aIo' }); + const a = await cp.encrypt(new Cookie(name, value)); + const b = await cp.encrypt(new Cookie(name, value)); + return a.value !== b.value; + }), + { numRuns: RUNS }, + ); + }); +}); + +describe('Property: HMAC name-binding rejects cross-name replay', () => { + it('signature for cookie A never validates under cookie B', async () => { + await fc.assert( + fc.asyncProperty(validName, validName, validValue, async (n1, n2, value) => { + fc.pre(n1 !== n2); + const cp = CookieParser.create({ secrets: ['yiLuooc8t1iy7BDCaU2eExB60URL8zacnqb1mA66aIo'] }); + const signed = cp.sign(new Cookie(n1, value)); + const replayed = new Cookie(n2, signed.value); + let rejected = false; + try { await cp.unsign(replayed); } catch (e) { + rejected = e instanceof CookieError && e.reason === CookieErrorReason.SignatureVerificationFailed; + } + return rejected; + }), + { numRuns: RUNS }, + ); + }); +}); + +describe('Property: AES-GCM AAD-binding rejects cross-name replay', () => { + it('ciphertext for cookie A never decrypts under cookie B', async () => { + await fc.assert( + fc.asyncProperty(validName, validName, validValue, async (n1, n2, value) => { + fc.pre(n1 !== n2); + const cp = CookieParser.create({ encryptionSecret: 'yiLuooc8t1iy7BDCaU2eExB60URL8zacnqb1mA66aIo' }); + const enc = await cp.encrypt(new Cookie(n1, value)); + const replayed = new Cookie(n2, enc.value); + let rejected = false; + try { await cp.decrypt(replayed); } catch (e) { + rejected = e instanceof CookieError && e.reason === CookieErrorReason.DecryptionFailed; + } + return rejected; + }), + { numRuns: RUNS }, + ); + }); +}); + +describe('Property: ciphertext tampering always fails decryption', () => { + it('flipping any byte after IV breaks decryption', async () => { + await fc.assert( + fc.asyncProperty(validName, validValue, fc.integer({ min: 0, max: 50 }), async (name, value, idx) => { + const cp = CookieParser.create({ encryptionSecret: 'yiLuooc8t1iy7BDCaU2eExB60URL8zacnqb1mA66aIo' }); + const enc = await cp.encrypt(new Cookie(name, value)); + const buf = Buffer.from(enc.value, 'base64url'); + // Tamper byte after IV (12) + within bounds + const target = 12 + (idx % Math.max(1, buf.length - 12)); + buf[target] = (buf[target]! ^ 0xff) & 0xff; + const tampered = new Cookie(name, buf.toString('base64url')); + let rejected = false; + try { await cp.decrypt(tampered); } catch (e) { + rejected = e instanceof CookieError; + } + return rejected; + }), + { numRuns: RUNS }, + ); + }); +}); + +describe('Property: HMAC tampering always fails verification', () => { + it('flipping any character after the dot breaks verification', async () => { + await fc.assert( + fc.asyncProperty(validName, validValue, fc.integer({ min: 0, max: 40 }), async (name, value, idx) => { + const cp = CookieParser.create({ secrets: ['yiLuooc8t1iy7BDCaU2eExB60URL8zacnqb1mA66aIo'] }); + const signed = cp.sign(new Cookie(name, value)); + const dot = signed.value.lastIndexOf('.'); + const sig = signed.value.slice(dot + 1); + if (sig.length === 0) return true; + const i = idx % sig.length; + const flipped = sig.slice(0, i) + (sig[i] === 'A' ? 'B' : 'A') + sig.slice(i + 1); + const tampered = new Cookie(name, signed.value.slice(0, dot + 1) + flipped); + let rejected = false; + try { await cp.unsign(tampered); } catch (e) { + rejected = e instanceof CookieError && e.reason === CookieErrorReason.SignatureVerificationFailed; + } + return rejected; + }), + { numRuns: RUNS }, + ); + }); +}); + +describe('Property: invalid name is rejected at every entry point', () => { + const separators = ['(', ')', '<', '>', '@', ',', ';', ':', '\\', '"', '/', '[', ']', '?', '=', '{', '}']; + it('every entry point throws CookieError for separators in name', async () => { + await fc.assert( + fc.asyncProperty(fc.constantFrom(...separators), validName, async (sep, base) => { + const bad = base + sep + 'x'; + const cp = CookieParser.create({ secrets: ['yiLuooc8t1iy7BDCaU2eExB60URL8zacnqb1mA66aIo'], encryptionSecret: 'v3MALRP-T0CO2gZ46D5As25K-U1D74PDhsdQJGjk4QQ' }); + const checks = [ + () => cp.createCookie(bad, 'v'), + () => { const c = (() => { try { return new Cookie(bad, 'v'); } catch { return null; } })(); return c ? cp.serialize(c) : (() => { throw new CookieError({ reason: CookieErrorReason.InvalidCookieName, message: 'rejected at ctor' }); })(); }, + () => { const c = (() => { try { return new Cookie(bad, 'v'); } catch { return null; } })(); return c ? cp.sign(c) : (() => { throw new CookieError({ reason: CookieErrorReason.InvalidCookieName, message: 'rejected at ctor' }); })(); }, + ]; + for (const fn of checks) { + let caught: unknown; + try { await fn(); } catch (e) { caught = e; } + if (!(caught instanceof CookieError)) return false; + } + return true; + }), + { numRuns: RUNS }, + ); + }); +}); + +describe('Property: jar set/get roundtrip preserves arbitrary value', () => { + it('setting then getting returns the original value', async () => { + const { CookieJar } = await import('../../src/cookie-jar'); + await fc.assert( + fc.asyncProperty(validName, validValue, async (name, value) => { + const cp = CookieParser.create({ secrets: ['yiLuooc8t1iy7BDCaU2eExB60URL8zacnqb1mA66aIo'], encryptionSecret: 'v3MALRP-T0CO2gZ46D5As25K-U1D74PDhsdQJGjk4QQ' }); + const out = new CookieJar(cp, ''); + out.set(name, value); + const headers = await out.getSetCookieHeaders(); + const cookieValue = headers[0]!.split('=').slice(1).join('=').split(';')[0]!; + const inJar = new CookieJar(cp, `${name}=${cookieValue}`); + const got = await inJar.get(name); + return got === value; + }), + { numRuns: RUNS }, + ); + }); +}); + +describe('Property: 4096-octet boundary is honored', () => { + it('any header > 4096 octets is rejected', async () => { + const cp = CookieParser.create(); + await fc.assert( + fc.property(fc.integer({ min: 4097, max: 8192 }), (size) => { + let caught: unknown; + try { cp.serialize(new Cookie('s', 'x'.repeat(size))); } catch (e) { caught = e; } + return caught instanceof CookieError && (caught as CookieError).reason === CookieErrorReason.CookieTooLarge; + }), + { numRuns: 50 }, + ); + }); +}); + +describe('Property: key rotation invariants', () => { + it('cookie signed by any key in the rotation array verifies', async () => { + await fc.assert( + fc.asyncProperty( + fc.array(secret32, { minLength: 2, maxLength: 5 }), + fc.integer({ min: 0, max: 4 }), + validName, + validValue, + async (keys, signerIdx, name, value) => { + const idx = signerIdx % keys.length; + const signer = CookieParser.create({ secrets: [keys[idx]!] }); + const verifier = CookieParser.create({ secrets: keys }); + const signed = signer.sign(new Cookie(name, value)); + const unsigned = await verifier.unsign(signed); + return unsigned.value === value; + }, + ), + { numRuns: RUNS }, + ); + }); + + it('cookie encrypted by any key in rotation array decrypts', async () => { + await fc.assert( + fc.asyncProperty( + fc.array(secret32, { minLength: 2, maxLength: 4 }), + fc.integer({ min: 0, max: 3 }), + validName, + validValue, + async (keys, encIdx, name, value) => { + const idx = encIdx % keys.length; + const encryptor = CookieParser.create({ encryptionSecret: keys[idx]! }); + const decryptor = CookieParser.create({ encryptionSecret: keys }); + const enc = await encryptor.encrypt(new Cookie(name, value)); + const dec = await decryptor.decrypt(enc); + return dec.value === value; + }, + ), + { numRuns: 100 }, + ); + }); +}); diff --git a/packages/cookie/test/integration/cookie-parser.test.ts b/packages/cookie/test/integration/cookie-parser.test.ts new file mode 100644 index 0000000..1486672 --- /dev/null +++ b/packages/cookie/test/integration/cookie-parser.test.ts @@ -0,0 +1,161 @@ +import { describe, expect, it } from 'bun:test'; +import { Cookie } from 'bun'; +import { isErr } from '@zipbul/result'; + +import { CookieParser, CookieJar, CookieError, CookieErrorReason } from '../../index'; + +describe('CookieParser Integration', () => { + const SECRETS = ['td3qCKaEjHcSFiD6qbiC5HxGf0HsATsAzh3GKQN1l_w', 'CsstQ4zof6e0zBASss4tkPquUbhzIhA-Nl6OG1EyOxk']; + const ENCRYPTION_SECRET = 'Gnx9dw6ZiN_lcu0yfXcYgT1EJfEio3ag4hg5fpQxjrg'; + + describe('CookieJar full roundtrip', () => { + it('should set cookies via jar and read them back via another jar', async () => { + const parser = CookieParser.create({ + secrets: SECRETS, + encryptionSecret: ENCRYPTION_SECRET, + }); + + const outJar = new CookieJar(parser, ''); + outJar.set('session', 'uid=12345&role=admin'); + const headers = await outJar.getSetCookieHeaders(); + expect(headers).toHaveLength(1); + expect(headers[0]).not.toContain('uid=12345'); + + const cookieValue = headers[0]!.split('=').slice(1).join('=').split(';')[0]!; + const inJar = new CookieJar(parser, `session=${cookieValue}`); + const result = await inJar.get('session'); + expect(result).toBe('uid=12345&role=admin'); + }); + + it('should handle multiple cookies independently in a jar', async () => { + const parser = CookieParser.create({ + secrets: SECRETS, + encryptionSecret: ENCRYPTION_SECRET, + }); + + const outJar = new CookieJar(parser, ''); + outJar.set('session', 'sess-abc'); + outJar.set('prefs', 'dark-mode=true'); + outJar.set('tracking', 'ref=google'); + const headers = await outJar.getSetCookieHeaders(); + expect(headers).toHaveLength(3); + + const cookieParts = headers.map((h) => { + const name = h.split('=')[0]!; + const value = h.split('=').slice(1).join('=').split(';')[0]!; + return `${name}=${value}`; + }); + + const inJar = new CookieJar(parser, cookieParts.join('; ')); + expect(await inJar.get('session')).toBe('sess-abc'); + expect(await inJar.get('prefs')).toBe('dark-mode=true'); + expect(await inJar.get('tracking')).toBe('ref=google'); + }); + }); + + describe('key rotation via jar', () => { + it('should read cookie signed with old key after rotation', async () => { + const parserOld = CookieParser.create({ secrets: ['aRABzXyg7c1cSN8g1QuF4vPbqmSGYm5mxWpVKnscYv0'] }); + const outJar = new CookieJar(parserOld, ''); + outJar.set('token', 'jwt-payload'); + const headers = await outJar.getSetCookieHeaders(); + const cookieValue = headers[0]!.split('=').slice(1).join('=').split(';')[0]!; + + const parserNew = CookieParser.create({ secrets: ['wGYphHkW_xM6CekJnRk0N8-wrWTMpyjbMzJQTE0gtBY', 'aRABzXyg7c1cSN8g1QuF4vPbqmSGYm5mxWpVKnscYv0'] }); + const inJar = new CookieJar(parserNew, `token=${cookieValue}`); + expect(await inJar.get('token')).toBe('jwt-payload'); + }); + + it('should fail to read cookie signed with old key when old key removed', async () => { + const parserOld = CookieParser.create({ secrets: ['aRABzXyg7c1cSN8g1QuF4vPbqmSGYm5mxWpVKnscYv0'] }); + const outJar = new CookieJar(parserOld, ''); + outJar.set('token', 'data'); + const headers = await outJar.getSetCookieHeaders(); + const cookieValue = headers[0]!.split('=').slice(1).join('=').split(';')[0]!; + + const parserNew = CookieParser.create({ secrets: ['wGYphHkW_xM6CekJnRk0N8-wrWTMpyjbMzJQTE0gtBY'] }); + const inJar = new CookieJar(parserNew, `token=${cookieValue}`); + const result = await inJar.get('token'); + expect(isErr(result)).toBe(true); + }); + }); + + describe('cross-instance isolation via jar', () => { + it('should fail to read cookie encrypted by different instance', async () => { + const parserA = CookieParser.create({ encryptionSecret: '15MzBo5XvJ5s4pH6_Qg2rdLQ73O_ZWOyoNT2vsDtN1U' }); + const outJar = new CookieJar(parserA, ''); + outJar.set('session', 'secret'); + const headers = await outJar.getSetCookieHeaders(); + const cookieValue = headers[0]!.split('=').slice(1).join('=').split(';')[0]!; + + const parserB = CookieParser.create({ encryptionSecret: 'G2ChMLgCJsc5VkAXlrN2ZUqgAKHsrASwTplEv5lcS1w' }); + const inJar = new CookieJar(parserB, `session=${cookieValue}`); + const result = await inJar.get('session'); + expect(isErr(result)).toBe(true); + }); + }); + + describe('jar with defaults', () => { + it('should apply parser defaults in outbound headers', async () => { + const parser = CookieParser.create({ + httpOnly: true, + secure: true, + sameSite: 'strict', + path: '/', + domain: 'example.com', + }); + + const jar = new CookieJar(parser, ''); + jar.set('session', 'data'); + const headers = await jar.getSetCookieHeaders(); + expect(headers[0]).toContain('HttpOnly'); + expect(headers[0]).toContain('Secure'); + expect(headers[0]).toContain('Domain=example.com'); + expect(headers[0]).toContain('Path=/'); + }); + }); + + describe('jar delete', () => { + it('should produce deletion header via jar', async () => { + const parser = CookieParser.create(); + const jar = new CookieJar(parser, ''); + jar.delete('old-session'); + const headers = await jar.getSetCookieHeaders(); + expect(headers).toHaveLength(1); + expect(headers[0]).toContain('old-session='); + expect(headers[0]).toContain('Max-Age=0'); + }); + }); + + describe('jar with secure auto', () => { + it('should set Secure on HTTPS via serialize context', async () => { + const parser = CookieParser.create({ secure: 'auto' }); + const jar = new CookieJar(parser, ''); + jar.set('session', 'data'); + + const httpsHeaders = await jar.getSetCookieHeaders({ isSecure: true }); + expect(httpsHeaders[0]).toContain('Secure'); + + const httpHeaders = await jar.getSetCookieHeaders({ isSecure: false }); + expect(httpHeaders[0]).not.toContain('Secure'); + }); + }); + + describe('jar with algorithm', () => { + it('should sign with sha512 and complete jar roundtrip', async () => { + const parser = CookieParser.create({ + secrets: ['gHBB3MwkPytgNA9vApSMJRDqJIPMNXgLrHUKSJZy1Kg'], + encryptionSecret: '9v7BAwKpXHWZnoKZIHV2XWch22HvF8bleOM6t4nc-A4', + algorithm: 'sha512', + }); + + const outJar = new CookieJar(parser, ''); + outJar.set('session', 'secret-data'); + const headers = await outJar.getSetCookieHeaders(); + const cookieValue = headers[0]!.split('=').slice(1).join('=').split(';')[0]!; + + const inJar = new CookieJar(parser, `session=${cookieValue}`); + expect(await inJar.get('session')).toBe('secret-data'); + }); + }); +}); diff --git a/packages/cookie/test/security/attack-surface.test.ts b/packages/cookie/test/security/attack-surface.test.ts new file mode 100644 index 0000000..d17d4e3 --- /dev/null +++ b/packages/cookie/test/security/attack-surface.test.ts @@ -0,0 +1,357 @@ +/** + * Attack-surface reproduction suite. Each test enumerates a known cookie-attack + * vector and asserts that the library produces a CookieError or otherwise refuses. + */ +import { describe, expect, it } from 'bun:test'; +import { Cookie } from 'bun'; + +import { CookieParser, CookieJar, CookieError, CookieErrorReason } from '../../index'; + +const SECRET = 'yiLuooc8t1iy7BDCaU2eExB60URL8zacnqb1mA66aIo'; +const ENC = 'v3MALRP-T0CO2gZ46D5As25K-U1D74PDhsdQJGjk4QQ'; + +describe('Attack: header injection via attribute values', () => { + const cp = CookieParser.create(); + it('rejects "; injected" in domain', () => { + let caught: unknown; + try { cp.createCookie('s', 'v', { domain: 'evil.com; injected=1' }); } catch (e) { caught = e; } + expect(caught).toBeInstanceOf(CookieError); + }); + it('rejects CRLF in domain', () => { + let caught: unknown; + try { cp.createCookie('s', 'v', { domain: 'a\r\nSet-Cookie: x=1' }); } catch (e) { caught = e; } + expect(caught).toBeInstanceOf(CookieError); + }); + it('rejects "; injected" in path', () => { + let caught: unknown; + try { cp.createCookie('s', 'v', { path: '/x; injected' }); } catch (e) { caught = e; } + expect(caught).toBeInstanceOf(CookieError); + }); + it('rejects CRLF in path', () => { + let caught: unknown; + try { cp.createCookie('s', 'v', { path: '/x\r\nSet-Cookie: x=1' }); } catch (e) { caught = e; } + expect(caught).toBeInstanceOf(CookieError); + }); + it('percent-encodes ";" in cookie value (no escape from name=value)', () => { + const h = cp.serialize(new Cookie('s', 'v;injected=1')); + expect(h).not.toContain('v;injected'); + expect(h).toContain('%3B'); + }); + it('percent-encodes CRLF in cookie value', () => { + const h = cp.serialize(new Cookie('s', 'a\r\nSet-Cookie:x=1')); + expect(h).not.toContain('\r'); + expect(h).not.toContain('\n'); + }); +}); + +describe('Attack: cross-name signature replay (C1 fix)', () => { + it('signature for cookie A is invalid for cookie B', async () => { + const cp = CookieParser.create({ secrets: [SECRET] }); + const signed = cp.sign(new Cookie('admin', 'true')); + let caught: unknown; + try { await cp.unsign(new Cookie('user', signed.value)); } catch (e) { caught = e; } + expect((caught as CookieError).reason).toBe(CookieErrorReason.SignatureVerificationFailed); + }); +}); + +describe('Attack: cross-name ciphertext replay (C2 fix)', () => { + it('ciphertext for cookie A is invalid for cookie B', async () => { + const cp = CookieParser.create({ encryptionSecret: ENC }); + const enc = await cp.encrypt(new Cookie('admin', 'true')); + let caught: unknown; + try { await cp.decrypt(new Cookie('user', enc.value)); } catch (e) { caught = e; } + expect((caught as CookieError).reason).toBe(CookieErrorReason.DecryptionFailed); + }); +}); + +describe('Attack: algorithm confusion', () => { + it('SHA-256 signature does not validate as SHA-384', async () => { + const a = CookieParser.create({ secrets: [SECRET], algorithm: 'sha256' }); + const b = CookieParser.create({ secrets: [SECRET], algorithm: 'sha384' }); + const signed = a.sign(new Cookie('s', 'v')); + let caught: unknown; + try { await b.unsign(signed); } catch (e) { caught = e; } + expect((caught as CookieError).reason).toBe(CookieErrorReason.SignatureVerificationFailed); + }); + it('SHA-512 signature does not validate as SHA-256', async () => { + const a = CookieParser.create({ secrets: [SECRET], algorithm: 'sha512' }); + const b = CookieParser.create({ secrets: [SECRET], algorithm: 'sha256' }); + const signed = a.sign(new Cookie('s', 'v')); + let caught: unknown; + try { await b.unsign(signed); } catch (e) { caught = e; } + expect((caught as CookieError).reason).toBe(CookieErrorReason.SignatureVerificationFailed); + }); +}); + +describe('Attack: ciphertext truncation', () => { + it('rejects ciphertext shorter than IV+tag minimum', async () => { + const cp = CookieParser.create({ encryptionSecret: ENC }); + let caught: unknown; + try { await cp.decrypt(new Cookie('s', Buffer.from(new Uint8Array(20)).toString('base64url'))); } catch (e) { caught = e; } + expect((caught as CookieError).reason).toBe(CookieErrorReason.InvalidCiphertext); + }); + it('rejects ciphertext with corrupted tag', async () => { + const cp = CookieParser.create({ encryptionSecret: ENC }); + const enc = await cp.encrypt(new Cookie('s', 'v')); + const buf = Buffer.from(enc.value, 'base64url'); + buf[buf.length - 1] = (buf[buf.length - 1]! ^ 0xff) & 0xff; + let caught: unknown; + try { await cp.decrypt(new Cookie('s', buf.toString('base64url'))); } catch (e) { caught = e; } + expect((caught as CookieError).reason).toBe(CookieErrorReason.DecryptionFailed); + }); + it('rejects ciphertext with corrupted IV', async () => { + const cp = CookieParser.create({ encryptionSecret: ENC }); + const enc = await cp.encrypt(new Cookie('s', 'v')); + const buf = Buffer.from(enc.value, 'base64url'); + // IV occupies bytes 4..15 (after 4-byte KID prefix) + buf[4] = (buf[4]! ^ 0xff) & 0xff; + let caught: unknown; + try { await cp.decrypt(new Cookie('s', buf.toString('base64url'))); } catch (e) { caught = e; } + expect((caught as CookieError).reason).toBe(CookieErrorReason.DecryptionFailed); + }); +}); + +describe('Attack: signature malformation', () => { + const cp = CookieParser.create({ secrets: [SECRET] }); + it('rejects value without dot separator', async () => { + let caught: unknown; + try { await cp.unsign(new Cookie('s', 'nodot')); } catch (e) { caught = e; } + expect((caught as CookieError).reason).toBe(CookieErrorReason.InvalidSignature); + }); + it('rejects value with empty signature', async () => { + let caught: unknown; + try { await cp.unsign(new Cookie('s', 'value.')); } catch (e) { caught = e; } + expect((caught as CookieError).reason).toBe(CookieErrorReason.SignatureVerificationFailed); + }); + it('rejects value with garbage signature', async () => { + let caught: unknown; + try { await cp.unsign(new Cookie('s', 'value.NotABase64UrlString!!!')); } catch (e) { caught = e; } + expect((caught as CookieError).reason).toBe(CookieErrorReason.SignatureVerificationFailed); + }); +}); + +describe('Attack: prototype pollution via cookie names', () => { + it('Map.set with __proto__ does not pollute Object.prototype', () => { + const cp = CookieParser.create(); + const jar = new CookieJar(cp, '__proto__=evil; constructor=evil; toString=evil'); + expect(jar.getRaw('__proto__')).toBe('evil'); + // verify Object.prototype is untouched + expect(({} as any).evil).toBeUndefined(); + expect(({} as any).__proto__).not.toBe('evil'); + }); + it('out-jar set with __proto__ does not pollute', async () => { + const cp = CookieParser.create(); + const out = new CookieJar(cp, ''); + out.set('__proto__', 'evil'); + const headers = await out.getSetCookieHeaders(); + expect(headers[0]).toContain('__proto__=evil'); + expect(({} as any).evil).toBeUndefined(); + }); +}); + +describe('Attack: secret-less encrypt/sign attempts', () => { + const cp = CookieParser.create(); + it('sign() throws SigningNotConfigured', () => { + let caught: unknown; + try { cp.sign(new Cookie('s', 'v')); } catch (e) { caught = e; } + expect((caught as CookieError).reason).toBe(CookieErrorReason.SigningNotConfigured); + }); + it('unsign() throws SigningNotConfigured', async () => { + let caught: unknown; + try { await cp.unsign(new Cookie('s', 'v.sig')); } catch (e) { caught = e; } + expect((caught as CookieError).reason).toBe(CookieErrorReason.SigningNotConfigured); + }); + it('encrypt() throws EncryptionNotConfigured', async () => { + let caught: unknown; + try { await cp.encrypt(new Cookie('s', 'v')); } catch (e) { caught = e; } + expect((caught as CookieError).reason).toBe(CookieErrorReason.EncryptionNotConfigured); + }); + it('decrypt() throws EncryptionNotConfigured', async () => { + let caught: unknown; + try { await cp.decrypt(new Cookie('s', 'cipher')); } catch (e) { caught = e; } + expect((caught as CookieError).reason).toBe(CookieErrorReason.EncryptionNotConfigured); + }); +}); + +describe('Attack: weak/short secrets', () => { + it('rejects 31-char signing secret', () => { + let caught: unknown; + try { CookieParser.create({ secrets: ['x'.repeat(31)] }); } catch (e) { caught = e; } + expect((caught as CookieError).reason).toBe(CookieErrorReason.WeakSecret); + }); + it('rejects 31-char encryption secret', () => { + let caught: unknown; + try { CookieParser.create({ encryptionSecret: 'x'.repeat(31) }); } catch (e) { caught = e; } + expect((caught as CookieError).reason).toBe(CookieErrorReason.WeakSecret); + }); + it('rejects empty secrets array', () => { + let caught: unknown; + try { CookieParser.create({ secrets: [] }); } catch (e) { caught = e; } + expect((caught as CookieError).reason).toBe(CookieErrorReason.EmptySecrets); + }); + it('rejects whitespace-only secret', () => { + let caught: unknown; + try { CookieParser.create({ secrets: [' '.padEnd(40)] }); } catch (e) { caught = e; } + expect((caught as CookieError).reason).toBe(CookieErrorReason.InvalidSecret); + }); +}); + +describe('Attack: oversized payloads', () => { + it('rejects serialized cookie > 4096 octets', () => { + const cp = CookieParser.create(); + let caught: unknown; + try { cp.serialize(new Cookie('s', 'x'.repeat(5000))); } catch (e) { caught = e; } + expect((caught as CookieError).reason).toBe(CookieErrorReason.CookieTooLarge); + }); +}); + +describe('Attack: timing-safe HMAC iteration', () => { + it('verifies regardless of secret position in rotation array', async () => { + const k1 = 'DAFa9Tm4TjwbEew6OJ2WjyL4hDkBytmILCPItWAvij8'; + const k2 = '39bzADCd-pZ69QBXlC2wGBNmKLUWclgkYIPNBDfOEnc'; + const k3 = 'lU5yqjYisLZ0gXAEbAOiQwcNDKGXoVdLwvnCCNf12fg'; + const k4 = 'f_58oLMYSRKNGG5vIzRSurZqqZy8bTJaO-d6py0Slms'; + const k5 = 'nge6Avvzrm8caHPUDLTcI6-Qa_AalZKo0yikksU-iZs'; + const cp = CookieParser.create({ secrets: [k1, k2, k3, k4, k5] }); + const lastOnly = CookieParser.create({ secrets: [k5] }); + const signed = lastOnly.sign(new Cookie('s', 'v')); + const unsigned = await cp.unsign(signed); + expect(unsigned.value).toBe('v'); + }); +}); + +describe('Attack: control characters in cookie name', () => { + const cp = CookieParser.create(); + for (const code of [0x00, 0x09, 0x0a, 0x0d, 0x1f, 0x20, 0x7f]) { + it(`rejects 0x${code.toString(16).padStart(2, '0')} in name`, () => { + let caught: unknown; + try { cp.createCookie(`a${String.fromCharCode(code)}b`, 'v'); } catch (e) { caught = e; } + expect(caught).toBeInstanceOf(CookieError); + }); + } +}); + +describe('Attack: empty / blank cookie name', () => { + const cp = CookieParser.create(); + it('rejects empty name', () => { + let caught: unknown; + try { cp.createCookie('', 'v'); } catch (e) { caught = e; } + expect((caught as CookieError).reason).toBe(CookieErrorReason.InvalidCookieName); + }); +}); + +describe('Attack: percent in name (Bun.CookieMap interop)', () => { + const cp = CookieParser.create(); + it('rejects "%" in cookie name to guarantee Bun round-trip safety', () => { + let caught: unknown; + try { cp.createCookie('bad%name', 'v'); } catch (e) { caught = e; } + expect((caught as CookieError).reason).toBe(CookieErrorReason.InvalidCookieName); + }); +}); + +describe('Attack: error type leakage (H-2 fix)', () => { + const cp = CookieParser.create(); + it('createCookie never leaks TypeError to callers', () => { + const cases: any[] = [ + { expires: 'not-a-date' }, + { expires: NaN }, + { expires: Infinity }, + { expires: new Date('invalid') }, + { domain: 'evil; injected' }, + { path: '/x; injected' }, + ]; + for (const opts of cases) { + let caught: unknown; + try { cp.createCookie('s', 'v', opts); } catch (e) { caught = e; } + expect(caught).toBeInstanceOf(CookieError); + } + }); +}); + +describe('Attack: control characters in Path/Domain (RFC 6265 §4.1.1)', () => { + const cp = CookieParser.create(); + const ctls = ['\x00', '\x01', '\x09', '\x0B', '\x1F', '\x7F']; + for (const ch of ctls) { + const hex = ch.charCodeAt(0).toString(16).padStart(2, '0'); + it(`rejects path containing CTL byte 0x${hex}`, () => { + let caught: unknown; + try { cp.createCookie('s', 'v', { path: '/foo' + ch + 'bar' }); } catch (e) { caught = e; } + expect(caught).toBeInstanceOf(CookieError); + expect((caught as CookieError).reason).toBe(CookieErrorReason.InvalidPath); + }); + it(`rejects domain containing CTL byte 0x${hex}`, () => { + let caught: unknown; + try { cp.createCookie('s', 'v', { domain: 'ex' + ch + 'ample.com' }); } catch (e) { caught = e; } + expect(caught).toBeInstanceOf(CookieError); + expect((caught as CookieError).reason).toBe(CookieErrorReason.InvalidDomain); + }); + } +}); + +describe('CWE-117 defense: wrapBunError never echoes input in messages', () => { + const cp = CookieParser.create(); + const secretMarker = 'SECRET_INPUT_' + Math.random().toString(36).slice(2); + const cases: Array<{ label: string; opts: any; reason: CookieErrorReason }> = [ + { label: 'invalid expires', opts: { expires: secretMarker }, reason: CookieErrorReason.InvalidExpires }, + ]; + for (const c of cases) { + it(`canonicalizes ${c.label} message (no input echo)`, () => { + let caught: unknown; + try { cp.createCookie('s', 'v', c.opts); } catch (e) { caught = e; } + expect(caught).toBeInstanceOf(CookieError); + expect((caught as CookieError).reason).toBe(c.reason); + expect((caught as CookieError).message).not.toContain(secretMarker); + }); + } + + it('canonical fallback for unknown errors', () => { + // Direct call to wrap with an unknown-shaped error. + const wrap = (cp as any).wrapBunError.bind(cp); + const err1 = wrap(new Error('mystery')); + expect(err1).toBeInstanceOf(CookieError); + expect(err1.message).toBe('cookie parser error'); + const err2 = wrap('plain string error with secret-token-xyz'); + expect(err2.message).not.toContain('secret-token-xyz'); + const err3 = wrap(new Error('unexpected cookie value 42')); + expect(err3.reason).toBe(CookieErrorReason.InvalidCookieValue); + expect(err3.message).toBe('invalid cookie value'); + const err4 = wrap(new Error('bad cookie name foo')); + expect(err4.reason).toBe(CookieErrorReason.InvalidCookieName); + const err5 = wrap(new Error('domain bad')); + expect(err5.reason).toBe(CookieErrorReason.InvalidDomain); + const err6 = wrap(new Error('path bad')); + expect(err6.reason).toBe(CookieErrorReason.InvalidPath); + }); +}); + +describe('DX: sameSite case normalization', () => { + const cp = CookieParser.create(); + for (const v of ['Lax', 'LAX', 'Strict', 'STRICT', 'None']) { + it(`accepts "${v}" and normalizes to lowercase output`, () => { + const c = cp.createCookie('s', 'v', { sameSite: v as any, secure: true }); + const h = cp.serialize(c); + // Bun emits canonical title case in the header; the point is no crash and SameSite present. + expect(h).toMatch(/SameSite=(Lax|Strict|None)/i); + }); + } +}); + +describe('Attack: AES-GCM key invocation cap (NIST SP 800-38D §8.3)', () => { + it('throws EncryptionKeyExhausted at 2^32 invocations', async () => { + const cp = CookieParser.create({ secrets: [SECRET], encryptionSecret: ENC }); + (cp as unknown as { encryptCounters: Map }).encryptCounters.set(0, 2 ** 32); + let caught: unknown; + try { await cp.encrypt(cp.createCookie('s', 'v')); } catch (e) { caught = e; } + expect(caught).toBeInstanceOf(CookieError); + expect((caught as CookieError).reason).toBe(CookieErrorReason.EncryptionKeyExhausted); + }); + it('still encrypts at 2^32 - 1 then refuses on the next call', async () => { + const cp = CookieParser.create({ secrets: [SECRET], encryptionSecret: ENC }); + (cp as unknown as { encryptCounters: Map }).encryptCounters.set(0, 2 ** 32 - 1); + const enc = await cp.encrypt(cp.createCookie('s', 'v')); + expect(enc.value).toBeTypeOf('string'); + let caught: unknown; + try { await cp.encrypt(cp.createCookie('s', 'v')); } catch (e) { caught = e; } + expect((caught as CookieError).reason).toBe(CookieErrorReason.EncryptionKeyExhausted); + }); +}); diff --git a/packages/cookie/tsconfig.build.json b/packages/cookie/tsconfig.build.json new file mode 100644 index 0000000..dc47b07 --- /dev/null +++ b/packages/cookie/tsconfig.build.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "declaration": true, + "emitDeclarationOnly": true, + "verbatimModuleSyntax": false, + "outDir": "dist" + }, + "include": ["index.ts", "src/**/*.ts"], + "exclude": ["**/*.spec.ts", "**/*.test.ts", "test"] +} diff --git a/packages/cookie/tsconfig.json b/packages/cookie/tsconfig.json new file mode 100644 index 0000000..4082f16 --- /dev/null +++ b/packages/cookie/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../../tsconfig.json" +}