#188: purpose-bound token keys via HKDF, jose replaces the homegrown JWT
All checks were successful
CI / Build container images (pull_request) Successful in 3m51s
CI / Auth e2e pack (pull_request) Successful in 7m49s
CI / Import/export fidelity gate (pull_request) Successful in 56s
CI / Lint, typecheck, test (pull_request) Successful in 4m43s
CD / Build and push images (push) Successful in 20s
CD / Deploy to Test (push) Successful in 16s
CD / Smoke tests against Test (push) Successful in 1m19s
CD / Promote to Int (push) Successful in 11s
CI / Lint, typecheck, test (push) Successful in 4m54s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 7m39s
CI / Import/export fidelity gate (push) Successful in 59s

COLLAB_TOKEN_SECRET becomes a root key: every purpose derives its own
HKDF-SHA-256 subkey (deriveTokenKey), and no code path signs with the
root key directly. Collaboration tokens are signed and verified by jose
with HS256 as an explicit allowlist; the sign/verify API turns async at
its three call sites. Unsubscribe tokens move from a purpose-prefix
string to the structural subkey, with a documented dual-verify window
(legacy derivation accepted until 2026-11-01, covering the 90-day TTL
of links in already-sent mail).

The cross-runtime property that justified the homegrown implementation
is now proven by a test: the built CJS and ESM dist artefacts round-trip
tokens in both directions in child processes (jose v6 reaches CJS via
Node's require(esm), pinned Node 22 images). Negative tests cover
cross-purpose subkeys, root-key-signed tokens, alg:none and RS256.

Refs #188 (ADR 0020)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0168Ph5uBmHm8X28CSVpbpnJ
This commit is contained in:
Claude Fable 5 2026-07-30 06:41:05 +02:00
parent 3e377aaa57
commit 3d1f4fda53
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 { 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;

View File

@ -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({

View File

@ -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,

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,
// 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<string>) = () =>
signCollabToken({ userId, pageId: name, mode: 'rw' }, secret, 60),
): {
provider: HocuspocusProvider;
doc: Y.Doc;

View File

@ -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<string> }[] = [
{
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());

View File

@ -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<string> =>
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 };

View File

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

View File

@ -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 },

View File

@ -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 -----------------------------------------------------------------

View File

@ -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.

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
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
- [ ] **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
(`packages/shared/src/token-crypto.ts`). Einzeln wären es 56 AT._
Achtung: Unsubscribe-Tokens leben lang in versandten Mails →

View File

@ -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",

View File

@ -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<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', () => {
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);
});
});

View File

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

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 {
@ -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<string> {
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<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. */
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<CollabTokenVerification> {
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' };

8
pnpm-lock.yaml generated
View File

@ -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)