diff --git a/apps/api/src/notifications/unsubscribe-token.test.ts b/apps/api/src/notifications/unsubscribe-token.test.ts new file mode 100644 index 0000000..56ebf14 --- /dev/null +++ b/apps/api/src/notifications/unsubscribe-token.test.ts @@ -0,0 +1,72 @@ +import { createHmac } from 'node:crypto'; + +import { deriveTokenKey } from '@dorfteich/shared/token-crypto'; +import { describe, expect, it } from 'vitest'; + +import { + LEGACY_VERIFY_UNTIL, + signUnsubscribeToken, + verifyUnsubscribeToken, +} from './unsubscribe-token'; + +const secret = 'test-secret-at-least-16-chars-long'; +const TTL_MS = 90 * 24 * 60 * 60 * 1000; + +/** A token as the pre-#188 implementation minted it: root secret + purpose prefix. */ +function legacyToken(userId: string, now = Date.now()): string { + const body = Buffer.from( + JSON.stringify({ userId, exp: Math.floor(now / 1000) + 90 * 24 * 60 * 60 }), + 'utf8', + ).toString('base64url'); + const sig = createHmac('sha256', secret).update(`digest-unsubscribe.${body}`).digest('base64url'); + return `${body}.${sig}`; +} + +describe('unsubscribe token', () => { + it('round-trips a user id', () => { + expect(verifyUnsubscribeToken(signUnsubscribeToken('u1', secret), secret)).toBe('u1'); + }); + + it('rejects an expired token', () => { + const token = signUnsubscribeToken('u1', secret); + expect(verifyUnsubscribeToken(token, secret, Date.now() + TTL_MS + 1000)).toBeNull(); + }); + + it('rejects garbage and a wrong secret', () => { + expect(verifyUnsubscribeToken('nonsense', secret)).toBeNull(); + expect(verifyUnsubscribeToken('a.b', secret)).toBeNull(); + const token = signUnsubscribeToken('u1', secret); + expect(verifyUnsubscribeToken(token, 'another-secret-16-chars')).toBeNull(); + }); + + it("rejects a token HMACed with a different purpose's subkey (ADR 0020)", () => { + const body = Buffer.from( + JSON.stringify({ userId: 'u1', exp: Math.floor(Date.now() / 1000) + 60 }), + 'utf8', + ).toString('base64url'); + const sig = createHmac('sha256', deriveTokenKey(secret, 'collab')) + .update(body) + .digest('base64url'); + expect(verifyUnsubscribeToken(`${body}.${sig}`, secret)).toBeNull(); + }); + + it('accepts a pre-separation legacy token inside the dual-verify window', () => { + const inWindow = LEGACY_VERIFY_UNTIL - 24 * 60 * 60 * 1000; + expect(verifyUnsubscribeToken(legacyToken('u1', inWindow - TTL_MS / 2), secret, inWindow)).toBe( + 'u1', + ); + }); + + it('rejects a legacy token once the dual-verify window has closed', () => { + const afterWindow = LEGACY_VERIFY_UNTIL + 1000; + // Unexpired on its own terms — rejected purely because the window closed. + const token = legacyToken('u1', afterWindow - 1000); + expect(verifyUnsubscribeToken(token, secret, afterWindow)).toBeNull(); + }); + + it('verifies freshly minted tokens via the subkey, independent of the window', () => { + const afterWindow = LEGACY_VERIFY_UNTIL + 24 * 60 * 60 * 1000; + const token = signUnsubscribeToken('u1', secret, afterWindow); + expect(verifyUnsubscribeToken(token, secret, afterWindow + 1000)).toBe('u1'); + }); +}); diff --git a/apps/api/src/notifications/unsubscribe-token.ts b/apps/api/src/notifications/unsubscribe-token.ts index adaa4e7..87d18ea 100644 --- a/apps/api/src/notifications/unsubscribe-token.ts +++ b/apps/api/src/notifications/unsubscribe-token.ts @@ -1,31 +1,54 @@ import { createHmac, timingSafeEqual } from 'node:crypto'; +import { deriveTokenKey } from '@dorfteich/shared/token-crypto'; + /** * Single-purpose unsubscribe tokens for the digest mails (issue #95): a * signed `{userId, exp}` blob whose only power is flipping that user's * digest setting to `off` — it never creates a session (ADR 0007's "signed - * tokens" pattern, purpose-bound so it cannot be replayed anywhere else). + * tokens" pattern). Purpose-binding is structural since #188 (ADR 0020): + * the HMAC key is the HKDF subkey for `unsubscribe`, so a token signed for + * any other purpose cannot verify here regardless of its content. */ -const PURPOSE = 'digest-unsubscribe'; const TTL_SECONDS = 90 * 24 * 60 * 60; -function signature(body: string, secret: string): Buffer { - return createHmac('sha256', secret).update(`${PURPOSE}.${body}`).digest(); +/** + * Dual-verify window (#188, ADR 0020): before the key separation, tokens + * were HMACed with the root secret over a `digest-unsubscribe.` prefix. + * Those links live in digest mails that are already sent and stay valid + * for their full 90-day TTL, so verification accepts the legacy derivation + * until every pre-separation token has expired. Tokens are only ever + * SIGNED with the new subkey; the legacy path is verify-only and goes dead + * automatically on the date below (last possible legacy expiry, rounded up). + */ +export const LEGACY_VERIFY_UNTIL = Date.parse('2026-11-01T00:00:00Z'); +const LEGACY_PURPOSE = 'digest-unsubscribe'; + +function signature(body: string, rootSecret: string): Buffer { + return createHmac('sha256', deriveTokenKey(rootSecret, 'unsubscribe')).update(body).digest(); } -export function signUnsubscribeToken(userId: string, secret: string, now = Date.now()): string { +function legacySignature(body: string, rootSecret: string): Buffer { + return createHmac('sha256', rootSecret).update(`${LEGACY_PURPOSE}.${body}`).digest(); +} + +export function signUnsubscribeToken(userId: string, rootSecret: string, now = Date.now()): string { const body = Buffer.from( JSON.stringify({ userId, exp: Math.floor(now / 1000) + TTL_SECONDS }), 'utf8', ).toString('base64url'); - return `${body}.${signature(body, secret).toString('base64url')}`; + return `${body}.${signature(body, rootSecret).toString('base64url')}`; +} + +function matches(provided: Buffer, expected: Buffer): boolean { + return provided.length === expected.length && timingSafeEqual(provided, expected); } /** The user id, or null for anything invalid or expired. Never throws. */ export function verifyUnsubscribeToken( token: string, - secret: string, + rootSecret: string, now = Date.now(), ): string | null { const [body, sig] = token.split('.'); @@ -36,8 +59,10 @@ export function verifyUnsubscribeToken( } catch { return null; } - const expected = signature(body, secret); - if (provided.length !== expected.length || !timingSafeEqual(provided, expected)) return null; + const current = matches(provided, signature(body, rootSecret)); + const legacy = + !current && now < LEGACY_VERIFY_UNTIL && matches(provided, legacySignature(body, rootSecret)); + if (!current && !legacy) return null; try { const payload = JSON.parse(Buffer.from(body, 'base64url').toString('utf8')) as { userId?: string; diff --git a/apps/api/src/pages/collab-token.e2e.db.test.ts b/apps/api/src/pages/collab-token.e2e.db.test.ts index 1612955..6a282bf 100644 --- a/apps/api/src/pages/collab-token.e2e.db.test.ts +++ b/apps/api/src/pages/collab-token.e2e.db.test.ts @@ -104,7 +104,7 @@ describe.skipIf(!hasTestDb)('collab token (e2e, issue #34)', () => { expect(res.body.mode).toBe('rw'); expect(res.body.expiresInSeconds).toBe(60); - const verified = verifyCollabToken(res.body.token, secret); + const verified = await verifyCollabToken(res.body.token, secret); expect(verified.valid).toBe(true); if (verified.valid) { expect(collabTokenClaimsSchema.parse(verified.claims)).toEqual({ diff --git a/apps/api/src/pages/pages.service.ts b/apps/api/src/pages/pages.service.ts index 85f3865..ce8b06a 100644 --- a/apps/api/src/pages/pages.service.ts +++ b/apps/api/src/pages/pages.service.ts @@ -378,7 +378,7 @@ export class PagesService { const canWrite = await this.permissions.canAccessPage(user, page, 'write'); const mode = canWrite ? 'rw' : 'ro'; const userId = user?.id ?? null; - const token = signCollabToken( + const token = await signCollabToken( { userId, pageId: page.id, mode }, this.config.env.COLLAB_TOKEN_SECRET, COLLAB_TOKEN_TTL_SECONDS, diff --git a/apps/collab/src/access-listener.db.test.ts b/apps/collab/src/access-listener.db.test.ts index b32aeb4..1d4f69e 100644 --- a/apps/collab/src/access-listener.db.test.ts +++ b/apps/collab/src/access-listener.db.test.ts @@ -108,11 +108,8 @@ describe.skipIf(!url)('access listener (DB-backed)', () => { // A string reuses one token; a function is re-invoked on every (re)connect, // exactly as the web client re-fetches from the api — which is how a // downgrade to `ro` takes effect on reconnect (issue #53). - token: string | (() => string) = signCollabToken( - { userId, pageId: name, mode: 'rw' }, - secret, - 60, - ), + token: string | (() => Promise) = () => + signCollabToken({ userId, pageId: name, mode: 'rw' }, secret, 60), ): { provider: HocuspocusProvider; doc: Y.Doc; diff --git a/apps/collab/src/auth.test.ts b/apps/collab/src/auth.test.ts index 9e047f0..457e5fc 100644 --- a/apps/collab/src/auth.test.ts +++ b/apps/collab/src/auth.test.ts @@ -83,7 +83,11 @@ describe('collab authentication', () => { } it('accepts a valid token whose page matches the document', async () => { - const token = signCollabToken({ userId: 'u1', pageId: 'page-ok', mode: 'rw' }, secret, 60); + const token = await signCollabToken( + { userId: 'u1', pageId: 'page-ok', mode: 'rw' }, + secret, + 60, + ); const client = connect('page-ok', token); await waitFor(client.authenticated); expect(client.authenticated()).toBe(true); @@ -93,7 +97,7 @@ describe('collab authentication', () => { }); const claims: CollabTokenClaims = { userId: 'u1', pageId: 'page-x', mode: 'rw' }; - const rejectionCases: { label: string; name: string; token: () => string }[] = [ + const rejectionCases: { label: string; name: string; token: () => Promise }[] = [ { label: 'an expired token', name: 'page-x', @@ -102,8 +106,8 @@ describe('collab authentication', () => { { label: 'a tampered token', name: 'page-x', - token: () => { - const [h, p, s = ''] = signCollabToken(claims, secret, 60).split('.'); + token: async () => { + const [h, p, s = ''] = (await signCollabToken(claims, secret, 60)).split('.'); // Flip the last character of the signature. const flipped = s.slice(0, -1) + (s.endsWith('A') ? 'B' : 'A'); return `${h}.${p}.${flipped}`; @@ -122,7 +126,7 @@ describe('collab authentication', () => { ]; it.each(rejectionCases)('rejects $label', async ({ name, token }) => { - const client = connect(name, token()); + const client = connect(name, await token()); await waitFor(client.failed); expect(client.failed()).toBe(true); expect(client.authenticated()).toBe(false); @@ -134,11 +138,11 @@ describe('collab authentication', () => { const page = 'page-shared'; const rw = connect( page, - signCollabToken({ userId: 'w', pageId: page, mode: 'rw' }, secret, 60), + await signCollabToken({ userId: 'w', pageId: page, mode: 'rw' }, secret, 60), ); const ro = connect( page, - signCollabToken({ userId: 'r', pageId: page, mode: 'ro' }, secret, 60), + await signCollabToken({ userId: 'r', pageId: page, mode: 'ro' }, secret, 60), ); await waitFor(() => rw.synced() && ro.synced()); diff --git a/apps/collab/src/persistence.test.ts b/apps/collab/src/persistence.test.ts index 7a01f38..b0d4d16 100644 --- a/apps/collab/src/persistence.test.ts +++ b/apps/collab/src/persistence.test.ts @@ -45,7 +45,8 @@ describe('collab persistence hooks', () => { function connect(url: string, pageId: string): { provider: HocuspocusProvider; doc: Y.Doc } { const doc = new Y.Doc(); - const token = signCollabToken({ userId: `u-${pageId}`, pageId, mode: 'rw' }, secret, 60); + const token = (): Promise => + signCollabToken({ userId: `u-${pageId}`, pageId, mode: 'rw' }, secret, 60); const provider = new HocuspocusProvider({ url, name: pageId, document: doc, token }); cleanups.push(() => provider.destroy()); return { provider, doc }; diff --git a/apps/collab/src/restore-listener.db.test.ts b/apps/collab/src/restore-listener.db.test.ts index ed2b9a7..ca682b8 100644 --- a/apps/collab/src/restore-listener.db.test.ts +++ b/apps/collab/src/restore-listener.db.test.ts @@ -120,7 +120,7 @@ describe.skipIf(!url)('restore listener (DB-backed, issue #42)', () => { url: wsUrl, name: pageId, document: doc, - token: signCollabToken({ userId, pageId, mode: 'rw' }, secret, 60), + token: await signCollabToken({ userId, pageId, mode: 'rw' }, secret, 60), }); await waitFor(() => provider.isSynced); diff --git a/apps/collab/src/server.ts b/apps/collab/src/server.ts index 8f70237..4af8b18 100644 --- a/apps/collab/src/server.ts +++ b/apps/collab/src/server.ts @@ -113,7 +113,7 @@ export function createCollabServer(deps: CollabServerDeps): Server { ); throw new Error('maintenance'); } - const result = verifyCollabToken(token, tokenSecret); + const result = await verifyCollabToken(token, tokenSecret); if (!result.valid) { logger.info( { event: 'auth.rejected', documentName, reason: result.reason }, diff --git a/deploy/compose/.env.example b/deploy/compose/.env.example index c244da7..d72e3d6 100644 --- a/deploy/compose/.env.example +++ b/deploy/compose/.env.example @@ -5,9 +5,12 @@ # PostgreSQL password for the `dorfteich` database user. POSTGRES_PASSWORD=change-me -# Secret that signs/verifies the short-lived collaboration tokens (issue #34). -# The api and collab services share this one value; use a long random string -# (e.g. `openssl rand -base64 32`). Min length 16. +# ROOT key of the token key hierarchy (ADR 0020, issue #188): every token +# purpose (collaboration tokens, digest unsubscribe links) derives its own +# HKDF subkey from this value — nothing signs with it directly. The api and +# collab services share this one value; use a long random string +# (e.g. `openssl rand -base64 32`). Min length 16. Rotating it rotates all +# derived keys at once and invalidates outstanding tokens. COLLAB_TOKEN_SECRET=change-me-to-a-long-random-string # --- images ----------------------------------------------------------------- diff --git a/docs/architecture/security.md b/docs/architecture/security.md index 6f80528..fe32def 100644 --- a/docs/architecture/security.md +++ b/docs/architecture/security.md @@ -66,13 +66,27 @@ or sloppy plugin authors, compromised dependencies. ## Secrets & configuration -- Secrets (DB password, collab signing key, SMTP credentials) live only in +- Secrets (DB password, token root key, SMTP credentials) live only in the stage `.env` (mode 600, never in git) and container env — not in the database (`instance_settings` stores non-secret config; the SMTP password entered in the setup wizard is written to the env-backed secret store, not to a DB row). -- Key rotation: collab signing key and session pepper rotate via env change - plus rolling restart; procedure documented in `operations.md` runbooks. +- Token key hierarchy (ADR 0020, issue #188): `COLLAB_TOKEN_SECRET` is a + ROOT key. Each token purpose uses its own HKDF-SHA-256 subkey + (`deriveTokenKey` in `packages/shared/src/token-crypto.ts`): `collab` + for the collaboration JWTs (signed and verified by `jose`, HS256 as an + explicit allowlist), `unsubscribe` for the digest unsubscribe links. No + code path signs with the root key directly, so a compromise of one + purpose's tokens is not transferable to the other. +- Dual-verify window: unsubscribe links minted before the key separation + live in already-sent mail (90-day TTL). Verification accepts the legacy + derivation (root key + purpose prefix) until **2026-11-01** + (`LEGACY_VERIFY_UNTIL` in `apps/api/src/notifications/unsubscribe-token.ts`), + after which the legacy path goes dead automatically. New tokens are only + ever signed with the subkey. +- Key rotation: rotating the root key rotates every derived subkey at once + (desired: one secret to rotate) via env change plus rolling restart; + procedure documented in `operations.md` runbooks. - Dependencies: lockfile-pinned; monthly update batch; images pinned to digests in Prod. diff --git a/docs/vs-nfd/20-massnahmenplan.md b/docs/vs-nfd/20-massnahmenplan.md index 2f8134c..3c08fcc 100644 --- a/docs/vs-nfd/20-massnahmenplan.md +++ b/docs/vs-nfd/20-massnahmenplan.md @@ -84,9 +84,9 @@ _Meilensteine: `M24 — VS-NfD: security quick wins`; die nachgezogenen Punkte (#199, #200, #201, #202) in `M25 — VS-NfD: hardening & supply chain`_ -- [ ] **Schlüsseltrennung `COLLAB_TOKEN_SECRET`** per HKDF (zweckgebundene +- [x] **Schlüsseltrennung `COLLAB_TOKEN_SECRET`** per HKDF (zweckgebundene Subkeys) — echter Fund, vor allen Features · 1–2 AT · #188 -- [ ] **Eigenbau-HMAC-JWT durch `jose` ersetzen** · +2–3 AT · #188 ⟵ neu aus Roadmap +- [x] **Eigenbau-HMAC-JWT durch `jose` ersetzen** · +2–3 AT · #188 ⟵ neu aus Roadmap _Gebündelt mit der Zeile darüber, weil dieselbe Datei (`packages/shared/src/token-crypto.ts`). Einzeln wären es 5–6 AT._ Achtung: Unsubscribe-Tokens leben lang in versandten Mails → diff --git a/packages/shared/package.json b/packages/shared/package.json index 134b3a5..1f12113 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -46,6 +46,7 @@ "test": "vitest run --passWithNoTests" }, "dependencies": { + "jose": "^6.2.4", "markdown-it": "^14.3.0", "prosemirror-markdown": "^1.13.4", "prosemirror-model": "^1.25.9", diff --git a/packages/shared/src/collab-token.test.ts b/packages/shared/src/collab-token.test.ts index 5d6889c..1bf297c 100644 --- a/packages/shared/src/collab-token.test.ts +++ b/packages/shared/src/collab-token.test.ts @@ -1,64 +1,122 @@ +import { SignJWT } from 'jose'; import { describe, expect, it } from 'vitest'; import type { CollabTokenClaims } from './collab-token'; -import { signCollabToken, verifyCollabToken } from './token-crypto'; +import { deriveTokenKey, signCollabToken, verifyCollabToken } from './token-crypto'; const secret = 'test-secret-at-least-16-chars-long'; const claims: CollabTokenClaims = { userId: 'user-1', pageId: 'page-1', mode: 'rw' }; +/** Sign the claims with an arbitrary key — for cross-purpose/root-key forgeries. */ +async function signWithKey(key: Uint8Array): Promise { + const now = Math.floor(Date.now() / 1000); + return await new SignJWT({ ...claims }) + .setProtectedHeader({ alg: 'HS256', typ: 'JWT' }) + .setIssuedAt(now) + .setExpirationTime(now + 60) + .sign(key); +} + describe('collab token', () => { - it('round-trips valid claims', () => { - const token = signCollabToken(claims, secret, 60); - const result = verifyCollabToken(token, secret); + it('round-trips valid claims', async () => { + const token = await signCollabToken(claims, secret, 60); + const result = await verifyCollabToken(token, secret); expect(result).toEqual({ valid: true, claims }); }); - it('rejects a token signed with a different secret', () => { - const token = signCollabToken(claims, secret, 60); - expect(verifyCollabToken(token, 'another-secret-16-chars')).toEqual({ + it('rejects a token signed with a different secret', async () => { + const token = await signCollabToken(claims, secret, 60); + expect(await verifyCollabToken(token, 'another-secret-16-chars')).toEqual({ valid: false, reason: 'bad_signature', }); }); - it('rejects a tampered payload', () => { - const token = signCollabToken(claims, secret, 60); + it("rejects a token signed with a different purpose's subkey (ADR 0020)", async () => { + const forged = await signWithKey(deriveTokenKey(secret, 'unsubscribe')); + expect(await verifyCollabToken(forged, secret)).toEqual({ + valid: false, + reason: 'bad_signature', + }); + }); + + it('rejects a token signed with the raw root secret — no code path signs with it', async () => { + const forged = await signWithKey(new TextEncoder().encode(secret)); + expect(await verifyCollabToken(forged, secret)).toEqual({ + valid: false, + reason: 'bad_signature', + }); + }); + + it('rejects a tampered payload', async () => { + const token = await signCollabToken(claims, secret, 60); const [header, , signature] = token.split('.'); const forgedPayload = Buffer.from( JSON.stringify({ ...claims, mode: 'rw', iat: 0, exp: 9999999999 }), 'utf8', ).toString('base64url'); const forged = `${header}.${forgedPayload}.${signature}`; - expect(verifyCollabToken(forged, secret).valid).toBe(false); + expect((await verifyCollabToken(forged, secret)).valid).toBe(false); }); - it('rejects an expired token', () => { - const token = signCollabToken(claims, secret, 60); + it('rejects an expired token', async () => { + const token = await signCollabToken(claims, secret, 60); // 61 seconds later the 60 s token is past its expiry. const later = Date.now() + 61_000; - expect(verifyCollabToken(token, secret, later)).toEqual({ valid: false, reason: 'expired' }); + expect(await verifyCollabToken(token, secret, later)).toEqual({ + valid: false, + reason: 'expired', + }); }); - it('rejects a malformed token', () => { - expect(verifyCollabToken('not-a-jwt', secret)).toEqual({ valid: false, reason: 'malformed' }); - expect(verifyCollabToken('a.b', secret).valid).toBe(false); + it('rejects a malformed token', async () => { + expect(await verifyCollabToken('not-a-jwt', secret)).toEqual({ + valid: false, + reason: 'malformed', + }); + expect((await verifyCollabToken('a.b', secret)).valid).toBe(false); }); - it('rejects an alg:none token even if the signature check is bypassed', () => { - // Craft a header advertising `none` and an empty signature; the empty - // signature cannot match a real HMAC, so this is caught as bad_signature, - // but the algorithm guard is the intended second line of defense. + it('rejects alg:none — HS256 is an explicit allowlist', async () => { const header = Buffer.from(JSON.stringify({ alg: 'none', typ: 'JWT' })).toString('base64url'); const payload = Buffer.from(JSON.stringify({ ...claims, iat: 0, exp: 9999999999 })).toString( 'base64url', ); - const result = verifyCollabToken(`${header}.${payload}.`, secret); + const result = await verifyCollabToken(`${header}.${payload}.`, secret); expect(result.valid).toBe(false); }); - it('preserves the read-only mode claim', () => { - const roToken = signCollabToken({ ...claims, mode: 'ro' }, secret, 60); - const result = verifyCollabToken(roToken, secret); + it('rejects an RS256 header before any signature check', async () => { + const header = Buffer.from(JSON.stringify({ alg: 'RS256', typ: 'JWT' })).toString('base64url'); + const payload = Buffer.from(JSON.stringify({ ...claims, iat: 0, exp: 9999999999 })).toString( + 'base64url', + ); + const result = await verifyCollabToken(`${header}.${payload}.AAAA`, secret); + expect(result).toEqual({ valid: false, reason: 'bad_algorithm' }); + }); + + it('rejects a signed token without an expiry claim', async () => { + const token = await new SignJWT({ ...claims }) + .setProtectedHeader({ alg: 'HS256', typ: 'JWT' }) + .setIssuedAt() + .sign(deriveTokenKey(secret, 'collab')); + expect(await verifyCollabToken(token, secret)).toEqual({ + valid: false, + reason: 'invalid_claims', + }); + }); + + it('preserves the read-only mode claim', async () => { + const roToken = await signCollabToken({ ...claims, mode: 'ro' }, secret, 60); + const result = await verifyCollabToken(roToken, secret); expect(result).toEqual({ valid: true, claims: { ...claims, mode: 'ro' } }); }); + + it('derives distinct, stable subkeys per purpose', () => { + const collab = deriveTokenKey(secret, 'collab'); + const unsubscribe = deriveTokenKey(secret, 'unsubscribe'); + expect(collab).toHaveLength(32); + expect(Buffer.from(collab).equals(Buffer.from(unsubscribe))).toBe(false); + expect(Buffer.from(collab).equals(Buffer.from(deriveTokenKey(secret, 'collab')))).toBe(true); + }); }); diff --git a/packages/shared/src/collab-token.ts b/packages/shared/src/collab-token.ts index 2cf0578..c6b6d53 100644 --- a/packages/shared/src/collab-token.ts +++ b/packages/shared/src/collab-token.ts @@ -4,8 +4,8 @@ import { z } from 'zod'; * Types and schemas for the short-lived collaboration tokens (issue #34, * ADR 0003/0007). These are browser-safe (no Node built-ins) so the web app * can import them from the package barrel. The signing/verifying helpers live - * in `./token-crypto` (Node `crypto`) and are imported only by the api and the - * collab server. + * in `./token-crypto` (HKDF subkeys + `jose`, ADR 0020) and are imported only + * by the api and the collab server. */ export const collabTokenModeSchema = z.enum(['rw', 'ro']); diff --git a/packages/shared/src/token-crypto.crossruntime.test.ts b/packages/shared/src/token-crypto.crossruntime.test.ts new file mode 100644 index 0000000..f1533b7 --- /dev/null +++ b/packages/shared/src/token-crypto.crossruntime.test.ts @@ -0,0 +1,67 @@ +import { execFileSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +import { describe, expect, it } from 'vitest'; + +/** + * The reason the homegrown JWT existed was the guarantee that the identical + * code runs in the CommonJS api and the ESM collab server (ADR 0020). With + * `jose` that property must be PROVEN, not assumed — jose v6 is ESM-only + * and reaches CJS via Node's `require(esm)`. This test loads the actually + * built dist artefacts (what api and collab load at runtime) in child + * processes of both module systems and round-trips a token in each + * direction. Requires a current `pnpm --filter @dorfteich/shared build` + * (CI builds before testing). + */ + +const here = path.dirname(fileURLToPath(import.meta.url)); +const distCjs = path.resolve(here, '../dist/token-crypto.js'); +const distEsm = path.resolve(here, '../dist/token-crypto.mjs'); + +const secret = 'cross-runtime-secret-32-characters'; +const claims = { userId: 'user-x', pageId: 'page-x', mode: 'rw' } as const; + +function run(args: string[], extraEnv: Record): string { + return execFileSync(process.execPath, args, { + cwd: path.resolve(here, '..'), + env: { ...process.env, SECRET: secret, ...extraEnv }, + encoding: 'utf8', + }); +} + +const signCjs = `require(process.env.DIST).signCollabToken(JSON.parse(process.env.CLAIMS), process.env.SECRET, 60).then((t) => process.stdout.write(t));`; +const verifyCjs = `require(process.env.DIST).verifyCollabToken(process.env.TOKEN, process.env.SECRET).then((r) => process.stdout.write(JSON.stringify(r)));`; +const signEsm = `const m = await import(process.env.DIST_URL); process.stdout.write(await m.signCollabToken(JSON.parse(process.env.CLAIMS), process.env.SECRET, 60));`; +const verifyEsm = `const m = await import(process.env.DIST_URL); process.stdout.write(JSON.stringify(await m.verifyCollabToken(process.env.TOKEN, process.env.SECRET)));`; + +describe('token crypto across module systems (built dist)', () => { + it('has a built dist to test against', () => { + for (const file of [distCjs, distEsm]) { + if (!existsSync(file)) { + throw new Error(`${file} missing — run \`pnpm --filter @dorfteich/shared build\` first`); + } + } + }); + + it('verifies a CommonJS-signed token in an ESM runtime', () => { + const token = run(['-e', signCjs], { DIST: distCjs, CLAIMS: JSON.stringify(claims) }); + expect(token.split('.')).toHaveLength(3); + const result = run(['--input-type=module', '-e', verifyEsm], { + DIST_URL: pathToFileURL(distEsm).href, + TOKEN: token, + }); + expect(JSON.parse(result)).toEqual({ valid: true, claims }); + }); + + it('verifies an ESM-signed token in a CommonJS runtime', () => { + const token = run(['--input-type=module', '-e', signEsm], { + DIST_URL: pathToFileURL(distEsm).href, + CLAIMS: JSON.stringify(claims), + }); + expect(token.split('.')).toHaveLength(3); + const result = run(['-e', verifyCjs], { DIST: distCjs, TOKEN: token }); + expect(JSON.parse(result)).toEqual({ valid: true, claims }); + }); +}); diff --git a/packages/shared/src/token-crypto.ts b/packages/shared/src/token-crypto.ts index cab3aa9..4df5add 100644 --- a/packages/shared/src/token-crypto.ts +++ b/packages/shared/src/token-crypto.ts @@ -1,5 +1,6 @@ -import { createHmac, timingSafeEqual } from 'node:crypto'; +import { hkdfSync } from 'node:crypto'; +import { errors as joseErrors, jwtVerify, SignJWT } from 'jose'; import { z } from 'zod'; import { @@ -9,91 +10,87 @@ import { } from './collab-token'; /** - * Sign and verify collaboration tokens (issue #34, ADR 0007). These are the - * ONLY JWTs in the system. Implemented with `node:crypto` rather than a library - * so the exact same code runs in the CommonJS api and the ESM collab server - * without module-interop or dependency-version drift. This module pulls in a - * Node built-in, so it lives outside the browser-safe package barrel and is - * imported via `@dorfteich/shared/token-crypto`. + * Purpose-bound token keys and collaboration-token signing (issue #34, + * ADR 0007; key separation and `jose` per ADR 0020, issue #188). * - * The token is a standard compact HS256 JWT. Only HS256 is ever produced or - * accepted; the signature is checked in constant time before any untrusted - * field is read. + * The configured `COLLAB_TOKEN_SECRET` is a ROOT key: every purpose derives + * its own subkey via HKDF-SHA-256, and no code path signs with the root key + * directly. A token signed for one purpose cannot verify under another + * because the keys differ — the separation is structural, not a string + * prefix in the signed payload. + * + * Signing and verification go through `jose` (vetted, dependency-free) + * with HS256 as an explicit algorithm allowlist. The same module must work + * in the CommonJS api and the ESM collab server — proven by + * `token-crypto.crossruntime.test.ts` rather than assumed (jose v6 is + * ESM-only and reaches CJS via Node's `require(esm)`, available in the + * pinned Node 22 images). This module pulls in Node built-ins, so it lives + * outside the browser-safe package barrel and is imported via + * `@dorfteich/shared/token-crypto`. */ -const HEADER = { alg: 'HS256', typ: 'JWT' } as const; +/** Every purpose a subkey is derived for. Add here — never reuse a key. */ +export type TokenPurpose = 'collab' | 'unsubscribe'; + +/** Fixed HKDF salt: domain-separates this application's key hierarchy. */ +const HKDF_SALT = 'dorfteich-token-keys'; + +/** + * Derive the 32-byte subkey for `purpose` from the configured root secret. + * Deterministic: rotation of the root secret rotates every subkey at once, + * which is the intended behaviour (ADR 0020). + */ +export function deriveTokenKey(rootSecret: string, purpose: TokenPurpose): Uint8Array { + return new Uint8Array(hkdfSync('sha256', rootSecret, HKDF_SALT, `dorfteich/${purpose}/v1`, 32)); +} /** Full JWT payload: the claims plus standard `iat`/`exp` (seconds since epoch). */ const payloadSchema = collabTokenClaimsSchema.extend({ iat: z.number().int().nonnegative(), - exp: z.number().int().nonnegative(), + exp: z.number().int(), }); -function b64url(value: string): string { - return Buffer.from(value, 'utf8').toString('base64url'); -} - -function hmac(signingInput: string, secret: string): string { - return createHmac('sha256', secret).update(signingInput).digest('base64url'); -} - /** Sign a collaboration token that expires `ttlSeconds` from now. */ -export function signCollabToken( +export async function signCollabToken( claims: CollabTokenClaims, - secret: string, + rootSecret: string, ttlSeconds: number, -): string { +): Promise { const now = Math.floor(Date.now() / 1000); - const payload = { ...collabTokenClaimsSchema.parse(claims), iat: now, exp: now + ttlSeconds }; - const signingInput = `${b64url(JSON.stringify(HEADER))}.${b64url(JSON.stringify(payload))}`; - return `${signingInput}.${hmac(signingInput, secret)}`; + return await new SignJWT({ ...collabTokenClaimsSchema.parse(claims) }) + .setProtectedHeader({ alg: 'HS256', typ: 'JWT' }) + .setIssuedAt(now) + .setExpirationTime(now + ttlSeconds) + .sign(deriveTokenKey(rootSecret, 'collab')); +} + +/** Map a `jose` verification error onto the stable rejection reasons. */ +function reasonOf(error: unknown): Exclude['reason'] { + if (error instanceof joseErrors.JWTExpired) return 'expired'; + if (error instanceof joseErrors.JOSEAlgNotAllowed) return 'bad_algorithm'; + if (error instanceof joseErrors.JWSSignatureVerificationFailed) return 'bad_signature'; + if (error instanceof joseErrors.JWTClaimValidationFailed) return 'invalid_claims'; + return 'malformed'; } /** Verify signature, algorithm, claims, and expiry. Never throws. */ -export function verifyCollabToken( +export async function verifyCollabToken( token: string, - secret: string, + rootSecret: string, now: number = Date.now(), -): CollabTokenVerification { - const parts = token.split('.'); - const [headerPart, payloadPart, signaturePart] = parts; - if ( - parts.length !== 3 || - headerPart === undefined || - payloadPart === undefined || - signaturePart === undefined - ) { - return { valid: false, reason: 'malformed' }; - } - const signingInput = `${headerPart}.${payloadPart}`; - - // Verify the signature (recomputed with HS256, ignoring the header's claimed - // algorithm) before reading any field — defeats alg-confusion and forgery. - const expected = Buffer.from(hmac(signingInput, secret), 'utf8'); - const provided = Buffer.from(signaturePart, 'utf8'); - if (expected.length !== provided.length || !timingSafeEqual(expected, provided)) { - return { valid: false, reason: 'bad_signature' }; - } - - let header: unknown; +): Promise { let payload: unknown; try { - header = JSON.parse(Buffer.from(headerPart, 'base64url').toString('utf8')); - payload = JSON.parse(Buffer.from(payloadPart, 'base64url').toString('utf8')); - } catch { - return { valid: false, reason: 'malformed' }; + ({ payload } = await jwtVerify(token, deriveTokenKey(rootSecret, 'collab'), { + algorithms: ['HS256'], + currentDate: new Date(now), + clockTolerance: 0, + })); + } catch (error) { + return { valid: false, reason: reasonOf(error) }; } - - // Defense in depth: even though the signature already pinned HS256, reject a - // token whose header advertises anything else (e.g. `none`). - if ( - typeof header !== 'object' || - header === null || - (header as { alg?: unknown }).alg !== 'HS256' - ) { - return { valid: false, reason: 'bad_algorithm' }; - } - + // `jose` treats `exp` as optional; the schema makes it mandatory so a + // token without an expiry can never verify. const parsed = payloadSchema.safeParse(payload); if (!parsed.success) { return { valid: false, reason: 'invalid_claims' }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 51a08d4..db78446 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -559,6 +559,9 @@ importers: packages/shared: dependencies: + jose: + specifier: ^6.2.4 + version: 6.2.4 markdown-it: specifier: ^14.3.0 version: 14.3.0 @@ -4951,6 +4954,9 @@ packages: jose@6.2.3: resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==} + jose@6.2.4: + resolution: {integrity: sha512-N8acGzVsQy6M/fjFcxtysNc4Q379TcM5dM/qKkNtsHFji88yANnXTr7BLeP75iPnFwBfQzM/jg2BZ9+HZrHCZA==} + jotai-scope@0.7.2: resolution: {integrity: sha512-Gwed97f3dDObrO43++2lRcgOqw4O2sdr4JCjP/7eHK1oPACDJ7xKHGScpJX9XaflU+KBHXF+VhwECnzcaQiShg==} peerDependencies: @@ -11482,6 +11488,8 @@ snapshots: jose@6.2.3: {} + jose@6.2.4: {} + jotai-scope@0.7.2(jotai@2.11.0(@types/react@19.2.17)(react@19.2.7))(react@19.2.7): dependencies: jotai: 2.11.0(@types/react@19.2.17)(react@19.2.7)