Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,18 @@ jobs:
- name: Type check
run: pnpm type-check

# TypeScript's unused-symbol checks do not span modules, so a *closed
# island* — a group of files importing each other with no entry point
# from the app — compiles cleanly and type-checks cleanly forever. One
# (4 messaging modules, 719 LOC) survived from the initial commit to
# 2026-08 and cost two debugging sessions plus a false root cause on #69
# before anyone noticed. This is the only check that catches that shape.
#
# knip.jsonc carries a baseline of pre-existing findings (see #90) so
# this gate is green on arrival and fails only on NEW dead files.
- name: Detect dead code (unreferenced files)
run: pnpm lint:dead

# scripts/validate-breakpoints.ts asserts that the --breakpoint-* custom
# properties in globals.css match BREAKPOINTS in src/config/breakpoints.ts,
# and that the ranges have no gaps. It has existed since the breakpoints
Expand Down
40 changes: 29 additions & 11 deletions docs/SECURITY-ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,22 +180,40 @@ Auth events are logged to `audit_logs` table:

## Performance Security Trade-offs

### ECDH Caching
### ECDH derivation is amortized per batch, not cached

To prevent denial-of-service via expensive cryptographic operations, shared secrets are cached:
There is **no client-side shared-secret cache**. A cached design existed in
`useConversationRealtime` / `decryption-cache`, but it was never wired to any
route and was deleted (#92) — this section previously described it as a live
DoS mitigation, which it never was.

```typescript
// Module-level cache (cleared on page unload)
const sharedSecretCache = new Map<string, CryptoKey>();
```
The shipping path derives the shared secret **once per `getMessageHistory()`
call** and reuses it across every message in that page:

- `message-service.ts:801` — one `deriveSharedSecret()` per batch
- `message-service.ts:813-876` — up to 50 AES-GCM decryptions against it

So the expensive asymmetric operation is already amortized over the page. Cost
scales with the number of _fetches_, not the number of messages.

**Security properties this gives up, and gains:**

- **Key**: `${conversationId}:${otherParticipantId}`
- **Value**: Derived CryptoKey
- **Invalidation**: Page unload, logout
- **Gives up**: nothing meaningful. Re-derivation is a cost issue, not a
security one, and it is tracked as such in #91.
- **Gains**: no long-lived key material in module scope, and rotation and
revocation self-heal — `getUserPublicKey()` re-applies `.eq('revoked', false)`
(`key-service.ts:691`) on every fetch, so a revoked peer key stops being used
within one cycle with no invalidation machinery to get wrong.

This reduces per-message decryption from ~50ms to ~1ms.
Derived message keys are created **non-extractable** (`encryption.ts:97`), so
they cannot be exported to raw bytes by page script.

**File**: `src/hooks/useConversationRealtime.ts`
If per-message memoization is ever reintroduced, key it on
`(messageId, keyEpoch)` where the epoch derives from **both** parties' current
public keys, with evict-and-retry-once on decrypt failure. A subscription-based
invalidation (the previous design) is structurally blind to _peer_ rotation:
static-static ECDH means K(A,B) changes when either side rotates, and nothing
subscribes to the other party's key changes.

## OWASP Top 10 Compliance

Expand Down
86 changes: 86 additions & 0 deletions knip.jsonc
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
{
"$schema": "https://unpkg.com/knip@5/schema.json",

// Next.js app-router entry points. knip's built-in Next plugin is disabled
// (`"next": { "entry": [] }`) because this project is a static export with a
// non-default layout, and the plugin's assumptions produced false entries.
"entry": [
"src/app/**/{page,layout,template,loading,error,not-found,global-error,route,default}.{ts,tsx}",
".storybook/{main,preview}.{ts,tsx}",
"scripts/**/*.{ts,mjs,js}",
"tests/**/*.{ts,tsx}",
"src/**/*.stories.tsx",
"src/**/*.test.{ts,tsx}",
"src/**/*.accessibility.test.tsx",
"public/sw.js"
],

"project": ["src/**/*.{ts,tsx}"],

"ignoreDependencies": ["sharp", "canvas", "argon2"],
"ignoreBinaries": ["docker", "supabase"],

// ── Baseline (#92) ────────────────────────────────────────────────────────
//
// knip was added to catch a *closed dead island* — four messaging modules
// that imported each other and nothing else, which survived eight months
// because TypeScript's unused-symbol checks do not span modules. Nothing in
// the repo could detect that mechanically.
//
// These entries are the pre-existing findings at the time knip landed. They
// are ignored so the gate is green on day one and fails on anything NEW.
// This is a burn-down list, not an allowlist — see #90, which is the audit
// of these same orphans. Delete entries from here as they are resolved.
//
// Two distinct categories, deliberately not separated in config because knip
// takes one list:
// 1. One-line barrel files (`index.ts`) re-exporting a LIVE component.
// An import-style decision, not dead code — see #82's out-of-scope note.
// 2. Genuine orphans awaiting individual verification under #90.
"ignore": [
// Resolved by Vite alias in .storybook/main.ts:43,47 (path.resolve), which
// knip cannot follow — these are reachable, not orphaned.
".storybook/mocks/**",

"src/components/AccessibilityProvider.tsx",
"src/components/atomic/Card/index.tsx",
"src/components/atomic/index.ts",
"src/components/atomic/QueueStatusIndicator/useQueueStatusIndicator.ts",
"src/components/atomic/ReadReceipt/index.tsx",
"src/components/forms/FormError.tsx",
"src/components/forms/FormField.tsx",
"src/components/forms/index.ts",
"src/components/forms/ValidatedInput.tsx",
"src/components/molecular/BlogContent/index.tsx",
"src/components/molecular/MessageThread/useMessageThread.ts",
"src/components/organisms/ChatWindow/useChatWindow.ts",
"src/components/organisms/CompanyMap/index.tsx",
"src/components/organisms/ConnectionManager/useConnectionManager.ts",
"src/components/payment/PaymentButton/index.tsx",
"src/components/payment/PaymentConsentModal/index.tsx",
"src/components/payment/PaymentHistory/index.tsx",
"src/components/payment/PaymentStatusDisplay/index.tsx",
"src/components/subatomic/index.ts",
"src/components/subatomic/Text/index.tsx",
"src/config/blog.config.ts",
"src/config/social-platforms.ts",
"src/config/social.ts",
"src/lib/analytics/index.tsx",
"src/lib/auth/protected-route.tsx",
"src/lib/companies/index.ts",
"src/lib/map/index.ts",
"src/lib/seo/content.ts",
"src/lib/seo/keywords.ts",
"src/lib/seo/readability.ts",
"src/lib/seo/technical.ts",
"src/lib/supabase/messaging-types.ts",
"src/lib/supabase/server.ts",
"src/lib/validation/index.ts",
"src/types/**",
"src/utils/codeblock-utils.ts",
"src/utils/map-colors.ts",
"src/utils/test-utils.ts"
],

"next": { "entry": [] }
}
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
"lint:staged": "lint-staged",
"clean:next": "rm -rf .next/* .next/.* 2>/dev/null || true",
"type-check": "tsc --noEmit",
"lint:dead": "knip --include files --no-progress",
"ci:validate": "./scripts/validate-ci.sh",
"validate:breakpoints": "tsx scripts/validate-breakpoints.ts",
"ci:quick": "./scripts/validate-ci.sh --quick",
Expand Down Expand Up @@ -173,6 +174,7 @@
"husky": "^9.1.7",
"jest-axe": "^10.0.0",
"jsdom": "^26.1.0",
"knip": "^6.31.0",
"libsodium-wrappers": "^0.8.1",
"lint-staged": "^16.1.6",
"msw": "^2.0.0",
Expand Down
Loading
Loading