#188: purpose-bound token keys via HKDF + jose #239

Merged
stwaidele merged 1 commits from feat/188-token-key-separation into main 2026-07-30 08:47:21 +02:00
18 changed files with 372 additions and 125 deletions

View File

@ -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');
});
});

View File

@ -1,31 +1,54 @@
import { createHmac, timingSafeEqual } from 'node:crypto'; import { createHmac, timingSafeEqual } from 'node:crypto';
import { deriveTokenKey } from '@dorfteich/shared/token-crypto';
/** /**
* Single-purpose unsubscribe tokens for the digest mails (issue #95): a * Single-purpose unsubscribe tokens for the digest mails (issue #95): a
* signed `{userId, exp}` blob whose only power is flipping that user's * 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 * 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; 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( const body = Buffer.from(
JSON.stringify({ userId, exp: Math.floor(now / 1000) + TTL_SECONDS }), JSON.stringify({ userId, exp: Math.floor(now / 1000) + TTL_SECONDS }),
'utf8', 'utf8',
).toString('base64url'); ).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. */ /** The user id, or null for anything invalid or expired. Never throws. */
export function verifyUnsubscribeToken( export function verifyUnsubscribeToken(
token: string, token: string,
secret: string, rootSecret: string,
now = Date.now(), now = Date.now(),
): string | null { ): string | null {
const [body, sig] = token.split('.'); const [body, sig] = token.split('.');
@ -36,8 +59,10 @@ export function verifyUnsubscribeToken(
} catch { } catch {
return null; return null;
} }
const expected = signature(body, secret); const current = matches(provided, signature(body, rootSecret));
if (provided.length !== expected.length || !timingSafeEqual(provided, expected)) return null; const legacy =
!current && now < LEGACY_VERIFY_UNTIL && matches(provided, legacySignature(body, rootSecret));
if (!current && !legacy) return null;
try { try {
const payload = JSON.parse(Buffer.from(body, 'base64url').toString('utf8')) as { const payload = JSON.parse(Buffer.from(body, 'base64url').toString('utf8')) as {
userId?: string; userId?: string;

View File

@ -104,7 +104,7 @@ describe.skipIf(!hasTestDb)('collab token (e2e, issue #34)', () => {
expect(res.body.mode).toBe('rw'); expect(res.body.mode).toBe('rw');
expect(res.body.expiresInSeconds).toBe(60); 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); expect(verified.valid).toBe(true);
if (verified.valid) { if (verified.valid) {
expect(collabTokenClaimsSchema.parse(verified.claims)).toEqual({ expect(collabTokenClaimsSchema.parse(verified.claims)).toEqual({

View File

@ -378,7 +378,7 @@ export class PagesService {
const canWrite = await this.permissions.canAccessPage(user, page, 'write'); const canWrite = await this.permissions.canAccessPage(user, page, 'write');
const mode = canWrite ? 'rw' : 'ro'; const mode = canWrite ? 'rw' : 'ro';
const userId = user?.id ?? null; const userId = user?.id ?? null;
const token = signCollabToken( const token = await signCollabToken(
{ userId, pageId: page.id, mode }, { userId, pageId: page.id, mode },
this.config.env.COLLAB_TOKEN_SECRET, this.config.env.COLLAB_TOKEN_SECRET,
COLLAB_TOKEN_TTL_SECONDS, COLLAB_TOKEN_TTL_SECONDS,

View File

@ -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, // 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 // exactly as the web client re-fetches from the api — which is how a
// downgrade to `ro` takes effect on reconnect (issue #53). // downgrade to `ro` takes effect on reconnect (issue #53).
token: string | (() => string) = signCollabToken( token: string | (() => Promise<string>) = () =>
{ userId, pageId: name, mode: 'rw' }, signCollabToken({ userId, pageId: name, mode: 'rw' }, secret, 60),
secret,
60,
),
): { ): {
provider: HocuspocusProvider; provider: HocuspocusProvider;
doc: Y.Doc; doc: Y.Doc;

View File

@ -83,7 +83,11 @@ describe('collab authentication', () => {
} }
it('accepts a valid token whose page matches the document', async () => { 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); const client = connect('page-ok', token);
await waitFor(client.authenticated); await waitFor(client.authenticated);
expect(client.authenticated()).toBe(true); expect(client.authenticated()).toBe(true);
@ -93,7 +97,7 @@ describe('collab authentication', () => {
}); });
const claims: CollabTokenClaims = { userId: 'u1', pageId: 'page-x', mode: 'rw' }; 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<string> }[] = [
{ {
label: 'an expired token', label: 'an expired token',
name: 'page-x', name: 'page-x',
@ -102,8 +106,8 @@ describe('collab authentication', () => {
{ {
label: 'a tampered token', label: 'a tampered token',
name: 'page-x', name: 'page-x',
token: () => { token: async () => {
const [h, p, s = ''] = signCollabToken(claims, secret, 60).split('.'); const [h, p, s = ''] = (await signCollabToken(claims, secret, 60)).split('.');
// Flip the last character of the signature. // Flip the last character of the signature.
const flipped = s.slice(0, -1) + (s.endsWith('A') ? 'B' : 'A'); const flipped = s.slice(0, -1) + (s.endsWith('A') ? 'B' : 'A');
return `${h}.${p}.${flipped}`; return `${h}.${p}.${flipped}`;
@ -122,7 +126,7 @@ describe('collab authentication', () => {
]; ];
it.each(rejectionCases)('rejects $label', async ({ name, token }) => { it.each(rejectionCases)('rejects $label', async ({ name, token }) => {
const client = connect(name, token()); const client = connect(name, await token());
await waitFor(client.failed); await waitFor(client.failed);
expect(client.failed()).toBe(true); expect(client.failed()).toBe(true);
expect(client.authenticated()).toBe(false); expect(client.authenticated()).toBe(false);
@ -134,11 +138,11 @@ describe('collab authentication', () => {
const page = 'page-shared'; const page = 'page-shared';
const rw = connect( const rw = connect(
page, page,
signCollabToken({ userId: 'w', pageId: page, mode: 'rw' }, secret, 60), await signCollabToken({ userId: 'w', pageId: page, mode: 'rw' }, secret, 60),
); );
const ro = connect( const ro = connect(
page, 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()); await waitFor(() => rw.synced() && ro.synced());

View File

@ -45,7 +45,8 @@ describe('collab persistence hooks', () => {
function connect(url: string, pageId: string): { provider: HocuspocusProvider; doc: Y.Doc } { function connect(url: string, pageId: string): { provider: HocuspocusProvider; doc: Y.Doc } {
const doc = new Y.Doc(); const doc = new Y.Doc();
const token = signCollabToken({ userId: `u-${pageId}`, pageId, mode: 'rw' }, secret, 60); const token = (): Promise<string> =>
signCollabToken({ userId: `u-${pageId}`, pageId, mode: 'rw' }, secret, 60);
const provider = new HocuspocusProvider({ url, name: pageId, document: doc, token }); const provider = new HocuspocusProvider({ url, name: pageId, document: doc, token });
cleanups.push(() => provider.destroy()); cleanups.push(() => provider.destroy());
return { provider, doc }; return { provider, doc };

View File

@ -120,7 +120,7 @@ describe.skipIf(!url)('restore listener (DB-backed, issue #42)', () => {
url: wsUrl, url: wsUrl,
name: pageId, name: pageId,
document: doc, document: doc,
token: signCollabToken({ userId, pageId, mode: 'rw' }, secret, 60), token: await signCollabToken({ userId, pageId, mode: 'rw' }, secret, 60),
}); });
await waitFor(() => provider.isSynced); await waitFor(() => provider.isSynced);

View File

@ -113,7 +113,7 @@ export function createCollabServer(deps: CollabServerDeps): Server {
); );
throw new Error('maintenance'); throw new Error('maintenance');
} }
const result = verifyCollabToken(token, tokenSecret); const result = await verifyCollabToken(token, tokenSecret);
if (!result.valid) { if (!result.valid) {
logger.info( logger.info(
{ event: 'auth.rejected', documentName, reason: result.reason }, { event: 'auth.rejected', documentName, reason: result.reason },

View File

@ -5,9 +5,12 @@
# PostgreSQL password for the `dorfteich` database user. # PostgreSQL password for the `dorfteich` database user.
POSTGRES_PASSWORD=change-me POSTGRES_PASSWORD=change-me
# Secret that signs/verifies the short-lived collaboration tokens (issue #34). # ROOT key of the token key hierarchy (ADR 0020, issue #188): every token
# The api and collab services share this one value; use a long random string # purpose (collaboration tokens, digest unsubscribe links) derives its own
# (e.g. `openssl rand -base64 32`). Min length 16. # 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 COLLAB_TOKEN_SECRET=change-me-to-a-long-random-string
# --- images ----------------------------------------------------------------- # --- images -----------------------------------------------------------------

View File

@ -66,13 +66,27 @@ or sloppy plugin authors, compromised dependencies.
## Secrets & configuration ## 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 the stage `.env` (mode 600, never in git) and container env — not in the
database (`instance_settings` stores non-secret config; the SMTP password database (`instance_settings` stores non-secret config; the SMTP password
entered in the setup wizard is written to the env-backed secret store, entered in the setup wizard is written to the env-backed secret store,
not to a DB row). not to a DB row).
- Key rotation: collab signing key and session pepper rotate via env change - Token key hierarchy (ADR 0020, issue #188): `COLLAB_TOKEN_SECRET` is a
plus rolling restart; procedure documented in `operations.md` runbooks. 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 - Dependencies: lockfile-pinned; monthly update batch; images pinned to
digests in Prod. digests in Prod.

View File

@ -84,9 +84,9 @@ _Meilensteine: `M24 — VS-NfD: security quick wins`; die nachgezogenen
Punkte (#199, #200, #201, #202) in `M25 — VS-NfD: hardening & supply Punkte (#199, #200, #201, #202) in `M25 — VS-NfD: hardening & supply
chain`_ chain`_
- [ ] **Schlüsseltrennung `COLLAB_TOKEN_SECRET`** per HKDF (zweckgebundene - [x] **Schlüsseltrennung `COLLAB_TOKEN_SECRET`** per HKDF (zweckgebundene
Subkeys) — echter Fund, vor allen Features · 12 AT · #188 Subkeys) — echter Fund, vor allen Features · 12 AT · #188
- [ ] **Eigenbau-HMAC-JWT durch `jose` ersetzen** · +23 AT · #188 ⟵ neu aus Roadmap - [x] **Eigenbau-HMAC-JWT durch `jose` ersetzen** · +23 AT · #188 ⟵ neu aus Roadmap
_Gebündelt mit der Zeile darüber, weil dieselbe Datei _Gebündelt mit der Zeile darüber, weil dieselbe Datei
(`packages/shared/src/token-crypto.ts`). Einzeln wären es 56 AT._ (`packages/shared/src/token-crypto.ts`). Einzeln wären es 56 AT._
Achtung: Unsubscribe-Tokens leben lang in versandten Mails → Achtung: Unsubscribe-Tokens leben lang in versandten Mails →

View File

@ -46,6 +46,7 @@
"test": "vitest run --passWithNoTests" "test": "vitest run --passWithNoTests"
}, },
"dependencies": { "dependencies": {
"jose": "^6.2.4",
"markdown-it": "^14.3.0", "markdown-it": "^14.3.0",
"prosemirror-markdown": "^1.13.4", "prosemirror-markdown": "^1.13.4",
"prosemirror-model": "^1.25.9", "prosemirror-model": "^1.25.9",

View File

@ -1,64 +1,122 @@
import { SignJWT } from 'jose';
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import type { CollabTokenClaims } from './collab-token'; 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 secret = 'test-secret-at-least-16-chars-long';
const claims: CollabTokenClaims = { userId: 'user-1', pageId: 'page-1', mode: 'rw' }; 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<string> {
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', () => { describe('collab token', () => {
it('round-trips valid claims', () => { it('round-trips valid claims', async () => {
const token = signCollabToken(claims, secret, 60); const token = await signCollabToken(claims, secret, 60);
const result = verifyCollabToken(token, secret); const result = await verifyCollabToken(token, secret);
expect(result).toEqual({ valid: true, claims }); expect(result).toEqual({ valid: true, claims });
}); });
it('rejects a token signed with a different secret', () => { it('rejects a token signed with a different secret', async () => {
const token = signCollabToken(claims, secret, 60); const token = await signCollabToken(claims, secret, 60);
expect(verifyCollabToken(token, 'another-secret-16-chars')).toEqual({ expect(await verifyCollabToken(token, 'another-secret-16-chars')).toEqual({
valid: false, valid: false,
reason: 'bad_signature', reason: 'bad_signature',
}); });
}); });
it('rejects a tampered payload', () => { it("rejects a token signed with a different purpose's subkey (ADR 0020)", async () => {
const token = signCollabToken(claims, secret, 60); 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 [header, , signature] = token.split('.');
const forgedPayload = Buffer.from( const forgedPayload = Buffer.from(
JSON.stringify({ ...claims, mode: 'rw', iat: 0, exp: 9999999999 }), JSON.stringify({ ...claims, mode: 'rw', iat: 0, exp: 9999999999 }),
'utf8', 'utf8',
).toString('base64url'); ).toString('base64url');
const forged = `${header}.${forgedPayload}.${signature}`; 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', () => { it('rejects an expired token', async () => {
const token = signCollabToken(claims, secret, 60); const token = await signCollabToken(claims, secret, 60);
// 61 seconds later the 60 s token is past its expiry. // 61 seconds later the 60 s token is past its expiry.
const later = Date.now() + 61_000; 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', () => { it('rejects a malformed token', async () => {
expect(verifyCollabToken('not-a-jwt', secret)).toEqual({ valid: false, reason: 'malformed' }); expect(await verifyCollabToken('not-a-jwt', secret)).toEqual({
expect(verifyCollabToken('a.b', secret).valid).toBe(false); 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', () => { it('rejects alg:none — HS256 is an explicit allowlist', async () => {
// 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.
const header = Buffer.from(JSON.stringify({ alg: 'none', typ: 'JWT' })).toString('base64url'); const header = Buffer.from(JSON.stringify({ alg: 'none', typ: 'JWT' })).toString('base64url');
const payload = Buffer.from(JSON.stringify({ ...claims, iat: 0, exp: 9999999999 })).toString( const payload = Buffer.from(JSON.stringify({ ...claims, iat: 0, exp: 9999999999 })).toString(
'base64url', 'base64url',
); );
const result = verifyCollabToken(`${header}.${payload}.`, secret); const result = await verifyCollabToken(`${header}.${payload}.`, secret);
expect(result.valid).toBe(false); expect(result.valid).toBe(false);
}); });
it('preserves the read-only mode claim', () => { it('rejects an RS256 header before any signature check', async () => {
const roToken = signCollabToken({ ...claims, mode: 'ro' }, secret, 60); const header = Buffer.from(JSON.stringify({ alg: 'RS256', typ: 'JWT' })).toString('base64url');
const result = verifyCollabToken(roToken, secret); 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' } }); 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);
});
}); });

View File

@ -4,8 +4,8 @@ import { z } from 'zod';
* Types and schemas for the short-lived collaboration tokens (issue #34, * 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 * 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 * 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 * in `./token-crypto` (HKDF subkeys + `jose`, ADR 0020) and are imported only
* collab server. * by the api and the collab server.
*/ */
export const collabTokenModeSchema = z.enum(['rw', 'ro']); export const collabTokenModeSchema = z.enum(['rw', 'ro']);

View File

@ -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, string>): 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 });
});
});

View File

@ -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 { z } from 'zod';
import { import {
@ -9,91 +10,87 @@ import {
} from './collab-token'; } from './collab-token';
/** /**
* Sign and verify collaboration tokens (issue #34, ADR 0007). These are the * Purpose-bound token keys and collaboration-token signing (issue #34,
* ONLY JWTs in the system. Implemented with `node:crypto` rather than a library * ADR 0007; key separation and `jose` per ADR 0020, issue #188).
* 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`.
* *
* The token is a standard compact HS256 JWT. Only HS256 is ever produced or * The configured `COLLAB_TOKEN_SECRET` is a ROOT key: every purpose derives
* accepted; the signature is checked in constant time before any untrusted * its own subkey via HKDF-SHA-256, and no code path signs with the root key
* field is read. * 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). */ /** Full JWT payload: the claims plus standard `iat`/`exp` (seconds since epoch). */
const payloadSchema = collabTokenClaimsSchema.extend({ const payloadSchema = collabTokenClaimsSchema.extend({
iat: z.number().int().nonnegative(), 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. */ /** Sign a collaboration token that expires `ttlSeconds` from now. */
export function signCollabToken( export async function signCollabToken(
claims: CollabTokenClaims, claims: CollabTokenClaims,
secret: string, rootSecret: string,
ttlSeconds: number, ttlSeconds: number,
): string { ): Promise<string> {
const now = Math.floor(Date.now() / 1000); const now = Math.floor(Date.now() / 1000);
const payload = { ...collabTokenClaimsSchema.parse(claims), iat: now, exp: now + ttlSeconds }; return await new SignJWT({ ...collabTokenClaimsSchema.parse(claims) })
const signingInput = `${b64url(JSON.stringify(HEADER))}.${b64url(JSON.stringify(payload))}`; .setProtectedHeader({ alg: 'HS256', typ: 'JWT' })
return `${signingInput}.${hmac(signingInput, secret)}`; .setIssuedAt(now)
.setExpirationTime(now + ttlSeconds)
.sign(deriveTokenKey(rootSecret, 'collab'));
}
/** Map a `jose` verification error onto the stable rejection reasons. */
function reasonOf(error: unknown): Exclude<CollabTokenVerification, { valid: true }>['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. */ /** Verify signature, algorithm, claims, and expiry. Never throws. */
export function verifyCollabToken( export async function verifyCollabToken(
token: string, token: string,
secret: string, rootSecret: string,
now: number = Date.now(), now: number = Date.now(),
): CollabTokenVerification { ): Promise<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;
let payload: unknown; let payload: unknown;
try { try {
header = JSON.parse(Buffer.from(headerPart, 'base64url').toString('utf8')); ({ payload } = await jwtVerify(token, deriveTokenKey(rootSecret, 'collab'), {
payload = JSON.parse(Buffer.from(payloadPart, 'base64url').toString('utf8')); algorithms: ['HS256'],
} catch { currentDate: new Date(now),
return { valid: false, reason: 'malformed' }; clockTolerance: 0,
}));
} catch (error) {
return { valid: false, reason: reasonOf(error) };
} }
// `jose` treats `exp` as optional; the schema makes it mandatory so a
// Defense in depth: even though the signature already pinned HS256, reject a // token without an expiry can never verify.
// 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' };
}
const parsed = payloadSchema.safeParse(payload); const parsed = payloadSchema.safeParse(payload);
if (!parsed.success) { if (!parsed.success) {
return { valid: false, reason: 'invalid_claims' }; return { valid: false, reason: 'invalid_claims' };

8
pnpm-lock.yaml generated
View File

@ -559,6 +559,9 @@ importers:
packages/shared: packages/shared:
dependencies: dependencies:
jose:
specifier: ^6.2.4
version: 6.2.4
markdown-it: markdown-it:
specifier: ^14.3.0 specifier: ^14.3.0
version: 14.3.0 version: 14.3.0
@ -4951,6 +4954,9 @@ packages:
jose@6.2.3: jose@6.2.3:
resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==} resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==}
jose@6.2.4:
resolution: {integrity: sha512-N8acGzVsQy6M/fjFcxtysNc4Q379TcM5dM/qKkNtsHFji88yANnXTr7BLeP75iPnFwBfQzM/jg2BZ9+HZrHCZA==}
jotai-scope@0.7.2: jotai-scope@0.7.2:
resolution: {integrity: sha512-Gwed97f3dDObrO43++2lRcgOqw4O2sdr4JCjP/7eHK1oPACDJ7xKHGScpJX9XaflU+KBHXF+VhwECnzcaQiShg==} resolution: {integrity: sha512-Gwed97f3dDObrO43++2lRcgOqw4O2sdr4JCjP/7eHK1oPACDJ7xKHGScpJX9XaflU+KBHXF+VhwECnzcaQiShg==}
peerDependencies: peerDependencies:
@ -11482,6 +11488,8 @@ snapshots:
jose@6.2.3: {} 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): jotai-scope@0.7.2(jotai@2.11.0(@types/react@19.2.17)(react@19.2.7))(react@19.2.7):
dependencies: dependencies:
jotai: 2.11.0(@types/react@19.2.17)(react@19.2.7) jotai: 2.11.0(@types/react@19.2.17)(react@19.2.7)