Compare commits

...
Author SHA1 Message Date
Charles Bochet e10525bbf9 poc(jwt): derive per-tenant ES256 signing key from master via HKDF + ECDH
Cryptographic per-workspace isolation on top of the existing rotatable
master key, without storing one keypair row per workspace.

How it works:
- A single ES256 keypair lives in core."signingKey" (unchanged).
- At sign time, we HKDF-derive a 32-byte scalar from the master private
  key DER, salted with "workspace:<workspaceId>" (or "user:<userId>" for
  WORKSPACE_AGNOSTIC tokens). The scalar is loaded into createECDH
  ('prime256v1') to recover the matching uncompressed point, which is
  turned into a JWK and exported as a PEM keypair.
- jwt.sign uses that derived private key; the kid in the header still
  points at the master (so rotation/revocation semantics stay the same).
- At verify time, JwtWrapperService.resolveVerificationKey loads the
  master *private* key for the header.kid, re-derives the same per-tenant
  keypair using the (unverified-but-now-bound-to-signature) workspaceId
  from the payload, and returns the derived public PEM to passport-jwt.

Properties:
- Each workspace has a cryptographically distinct ES256 keypair: a token
  signed for ws-1 will not verify under any other workspace's derived
  public key (unit test asserts this).
- Tampering with payload.workspaceId after signing changes the
  re-derived verification key and the signature stops verifying.
- Derivation is deterministic, so no extra DB writes per workspace.
- No new dependency: HKDF + ECDH come from node:crypto.

Trade-off vs the previous single-key model: the verifier now needs the
master *private* key (not just the public one), because P-256 has no
BIP32-style chain-code trick to derive child public keys from a master
public key alone. In Twenty's current topology signer and verifier run
in the same trust domain so this is acceptable for a POC; if we ever
want to distribute verifiers, we'd need to switch to per-workspace key
pairs (one row per workspace) or a richer KDF (chain code).

Files:
- jwt/utils/derive-workspace-signing-key.util.ts: HKDF + ECDH derivation
  and JwtPayload -> DerivationScope mapping.
- jwt/utils/derive-workspace-signing-key.util.spec.ts: covers determinism,
  scope namespacing, cross-workspace forgery, and payload tampering.
- jwt/services/jwt-key-manager.service.ts: getValidMasterPrivateKeyPemById
  with per-process memoisation by kid.
- jwt/services/jwt-wrapper.service.ts: signAsync derives the per-tenant
  key; resolveVerificationKey re-derives the per-tenant public key.
2026-05-12 19:39:13 +02:00
4 changed files with 354 additions and 6 deletions
@@ -29,6 +29,13 @@ export class JwtKeyManagerService {
private currentSigningKeyPromise: Promise<CurrentSigningKey | null> | null =
null;
// POC: master private keys are needed on the verify path to derive
// per-tenant public keys, so we memoise them per-process by kid.
private readonly masterPrivateKeyPemByIdPromise = new Map<
string,
Promise<string | null>
>();
constructor(
@InjectRepository(SigningKeyEntity)
private readonly signingKeyRepository: Repository<SigningKeyEntity>,
@@ -63,6 +70,53 @@ export class JwtKeyManagerService {
return this.coreEntityCacheService.get('signingKeyPublicKey', id);
}
async getValidMasterPrivateKeyPemById(id: string): Promise<string | null> {
if (!isNonEmptyString(id) || !isValidUuid(id)) {
return null;
}
const cached = this.masterPrivateKeyPemByIdPromise.get(id);
if (isDefined(cached)) {
return cached;
}
const loader = this.loadMasterPrivateKeyPemById(id);
this.masterPrivateKeyPemByIdPromise.set(id, loader);
try {
const result = await loader;
if (!isDefined(result)) {
this.masterPrivateKeyPemByIdPromise.delete(id);
}
return result;
} catch (error) {
this.masterPrivateKeyPemByIdPromise.delete(id);
throw error;
}
}
private async loadMasterPrivateKeyPemById(
id: string,
): Promise<string | null> {
const row = await this.signingKeyRepository.findOne({
where: { id, revokedAt: IsNull() },
});
if (!isDefined(row)) {
return null;
}
if (!isDefined(row.privateKey)) {
return null;
}
return this.secretEncryptionService.decrypt(row.privateKey);
}
private async loadOrCreateCurrentSigningKey(): Promise<CurrentSigningKey | null> {
try {
const existing = await this.findCurrentSigningKeyRow();
@@ -28,6 +28,10 @@ import { JwtKeyManagerService } from 'src/engine/core-modules/jwt/services/jwt-k
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { decodeJwtHeader } from 'src/engine/core-modules/jwt/utils/decode-jwt-header.util';
import { decodeJwtPayload } from 'src/engine/core-modules/jwt/utils/decode-jwt-payload.util';
import {
deriveTenantKeyPair,
extractDerivationScope,
} from 'src/engine/core-modules/jwt/utils/derive-workspace-signing-key.util';
import {
isAsymmetricJwtHeader,
isAsymmetricSigningEligible,
@@ -84,6 +88,17 @@ export class JwtWrapperService {
);
}
const scope = extractDerivationScope(payload);
if (!isDefined(scope)) {
throw new AuthException(
`Cannot derive a per-tenant signing key for token type "${payload.type}": payload has no workspaceId nor userId`,
AuthExceptionCode.INVALID_JWT_TOKEN_TYPE,
);
}
const derived = deriveTenantKeyPair(signingKey.privateKeyPem, scope);
const signOptions: jwt.SignOptions = {
expiresIn: options.expiresIn as jwt.SignOptions['expiresIn'],
algorithm: JWT_ASYMMETRIC_ALGORITHM,
@@ -91,7 +106,7 @@ export class JwtWrapperService {
...(isDefined(options.jwtid) ? { jwtid: options.jwtid } : {}),
};
return jwt.sign(payload as object, signingKey.privateKeyPem, signOptions);
return jwt.sign(payload as object, derived.privateKeyPem, signOptions);
}
// oxlint-disable-next-line @typescripttypescript/no-explicit-any
@@ -113,18 +128,34 @@ export class JwtWrapperService {
const header = decodeJwtHeader(rawToken);
const payload = decodeJwtPayload<JwtPayload>(rawToken);
if (isAsymmetricJwtHeader(header, payload)) {
const publicKeyPem =
await this.jwtKeyManagerService.getValidPublicKeyPemById(header.kid);
if (isAsymmetricJwtHeader(header, payload) && isDefined(payload)) {
const masterPrivateKeyPem =
await this.jwtKeyManagerService.getValidMasterPrivateKeyPemById(
header.kid,
);
if (!isDefined(publicKeyPem)) {
if (!isDefined(masterPrivateKeyPem)) {
throw new AuthException(
'Token invalid.',
AuthExceptionCode.UNAUTHENTICATED,
);
}
return { key: publicKeyPem, algorithm: JWT_ASYMMETRIC_ALGORITHM };
const scope = extractDerivationScope(payload);
if (!isDefined(scope)) {
throw new AuthException(
'Token invalid.',
AuthExceptionCode.UNAUTHENTICATED,
);
}
const derived = deriveTenantKeyPair(masterPrivateKeyPem, scope);
return {
key: derived.publicKeyPem,
algorithm: JWT_ASYMMETRIC_ALGORITHM,
};
}
if (!isDefined(payload)) {
@@ -0,0 +1,145 @@
import { generateKeyPairSync } from 'crypto';
import * as jwt from 'jsonwebtoken';
import {
type AccessTokenJwtPayload,
JwtTokenTypeEnum,
} from 'src/engine/core-modules/auth/types/auth-context.type';
import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type';
import {
deriveTenantKeyPair,
extractDerivationScope,
} from 'src/engine/core-modules/jwt/utils/derive-workspace-signing-key.util';
const generateMasterPrivateKeyPem = (): string => {
const { privateKey } = generateKeyPairSync('ec', { namedCurve: 'P-256' });
return privateKey.export({ format: 'pem', type: 'pkcs8' }).toString();
};
const buildAccessTokenPayload = (
workspaceId: string,
): AccessTokenJwtPayload => ({
sub: 'user-1',
type: JwtTokenTypeEnum.ACCESS,
userId: 'user-1',
workspaceId,
userWorkspaceId: 'uw-1',
authProvider: AuthProviderEnum.PASSWORD,
});
describe('deriveTenantKeyPair', () => {
it('derives the same key pair for the same (master, scope)', () => {
const masterPrivateKeyPem = generateMasterPrivateKeyPem();
const scope = { kind: 'workspace', workspaceId: 'ws-1' } as const;
const first = deriveTenantKeyPair(masterPrivateKeyPem, scope);
const second = deriveTenantKeyPair(masterPrivateKeyPem, scope);
expect(first.privateKeyPem).toBe(second.privateKeyPem);
expect(first.publicKeyPem).toBe(second.publicKeyPem);
});
it('derives a different key pair when the workspace changes', () => {
const masterPrivateKeyPem = generateMasterPrivateKeyPem();
const ws1 = deriveTenantKeyPair(masterPrivateKeyPem, {
kind: 'workspace',
workspaceId: 'ws-1',
});
const ws2 = deriveTenantKeyPair(masterPrivateKeyPem, {
kind: 'workspace',
workspaceId: 'ws-2',
});
expect(ws1.privateKeyPem).not.toBe(ws2.privateKeyPem);
expect(ws1.publicKeyPem).not.toBe(ws2.publicKeyPem);
});
it('namespaces workspace and user scopes so they cannot collide', () => {
const masterPrivateKeyPem = generateMasterPrivateKeyPem();
const sharedId = '00000000-0000-0000-0000-000000000001';
const workspaceScoped = deriveTenantKeyPair(masterPrivateKeyPem, {
kind: 'workspace',
workspaceId: sharedId,
});
const userScoped = deriveTenantKeyPair(masterPrivateKeyPem, {
kind: 'user',
userId: sharedId,
});
expect(workspaceScoped.privateKeyPem).not.toBe(userScoped.privateKeyPem);
});
it('rejects cross-workspace token forgery', () => {
const masterPrivateKeyPem = generateMasterPrivateKeyPem();
const w1 = deriveTenantKeyPair(masterPrivateKeyPem, {
kind: 'workspace',
workspaceId: 'ws-1',
});
const w2 = deriveTenantKeyPair(masterPrivateKeyPem, {
kind: 'workspace',
workspaceId: 'ws-2',
});
const token = jwt.sign(buildAccessTokenPayload('ws-1'), w1.privateKeyPem, {
algorithm: 'ES256',
expiresIn: '5m',
});
expect(() =>
jwt.verify(token, w1.publicKeyPem, { algorithms: ['ES256'] }),
).not.toThrow();
expect(() =>
jwt.verify(token, w2.publicKeyPem, { algorithms: ['ES256'] }),
).toThrow(jwt.JsonWebTokenError);
});
it('rejects tokens whose payload workspaceId was tampered with', () => {
const masterPrivateKeyPem = generateMasterPrivateKeyPem();
const w1 = deriveTenantKeyPair(masterPrivateKeyPem, {
kind: 'workspace',
workspaceId: 'ws-1',
});
// Sign legitimately for ws-1, then re-derive the (different) key for the
// workspaceId an attacker pretends the token is for. Verification with
// the re-derived w2 public key MUST fail.
const tokenForWs1 = jwt.sign(
buildAccessTokenPayload('ws-1'),
w1.privateKeyPem,
{ algorithm: 'ES256', expiresIn: '5m' },
);
const reDerivedForWs2 = deriveTenantKeyPair(masterPrivateKeyPem, {
kind: 'workspace',
workspaceId: 'ws-2',
});
expect(() =>
jwt.verify(tokenForWs1, reDerivedForWs2.publicKeyPem, {
algorithms: ['ES256'],
}),
).toThrow(jwt.JsonWebTokenError);
});
});
describe('extractDerivationScope', () => {
it('returns the workspaceId for workspace-bound tokens', () => {
const scope = extractDerivationScope(buildAccessTokenPayload('ws-1'));
expect(scope).toEqual({ kind: 'workspace', workspaceId: 'ws-1' });
});
it('returns the userId for WORKSPACE_AGNOSTIC tokens', () => {
const scope = extractDerivationScope({
sub: 'user-1',
type: JwtTokenTypeEnum.WORKSPACE_AGNOSTIC,
userId: 'user-1',
});
expect(scope).toEqual({ kind: 'user', userId: 'user-1' });
});
});
@@ -0,0 +1,118 @@
import {
createECDH,
createPrivateKey,
createPublicKey,
hkdfSync,
type KeyObject,
} from 'crypto';
import { isNonEmptyString } from '@sniptt/guards';
import {
type JwtPayload,
JwtTokenTypeEnum,
} from 'src/engine/core-modules/auth/types/auth-context.type';
const HKDF_HASH = 'sha256';
const HKDF_OUTPUT_BYTES = 32;
const HKDF_INFO = Buffer.from('twenty/jwt/derive/v1', 'utf8');
// HKDF + ECDH-based derivation of a per-tenant ES256 key pair from a master
// ES256 key. The whole point of this util is to give every workspace (and,
// for workspace-agnostic tokens, every user) a cryptographically distinct
// signing/verification key without storing one row per workspace.
//
// Security note: deriving a child PUBLIC key requires the master PRIVATE key
// (no chain-code / BIP32-style trick here), so both the signer and the
// verifier need access to the master private key. In Twenty that's fine —
// both paths run inside the same trust domain — but it is the main reason
// this stays a POC for now.
export type DerivedKeyPair = {
privateKeyPem: string;
publicKeyPem: string;
};
export type DerivationScope =
| { kind: 'workspace'; workspaceId: string }
| { kind: 'user'; userId: string };
export const extractDerivationScope = (
payload: JwtPayload,
): DerivationScope | undefined => {
if (
payload.type === JwtTokenTypeEnum.WORKSPACE_AGNOSTIC &&
isNonEmptyString(payload.userId)
) {
return { kind: 'user', userId: payload.userId };
}
if ('workspaceId' in payload && isNonEmptyString(payload.workspaceId)) {
return { kind: 'workspace', workspaceId: payload.workspaceId };
}
if ('userId' in payload && isNonEmptyString(payload.userId)) {
return { kind: 'user', userId: payload.userId };
}
return undefined;
};
const derivationScopeToSalt = (scope: DerivationScope): Buffer =>
Buffer.from(
scope.kind === 'workspace'
? `workspace:${scope.workspaceId}`
: `user:${scope.userId}`,
'utf8',
);
const masterPrivateKeyToIkm = (masterPrivateKeyPem: string): Buffer => {
const masterKey: KeyObject = createPrivateKey(masterPrivateKeyPem);
return masterKey.export({ format: 'der', type: 'pkcs8' });
};
const scalarToKeyPair = (scalar: Buffer): DerivedKeyPair => {
const ecdh = createECDH('prime256v1');
// setPrivateKey throws if the scalar is 0 or >= n. Probability for a
// random 32-byte HKDF output is ~2^-128, so in practice this never fires.
ecdh.setPrivateKey(scalar);
const uncompressedPub = ecdh.getPublicKey();
const xBuf = uncompressedPub.subarray(1, 33);
const yBuf = uncompressedPub.subarray(33, 65);
const x = xBuf.toString('base64url');
const y = yBuf.toString('base64url');
const d = scalar.toString('base64url');
const privateKey = createPrivateKey({
key: { kty: 'EC', crv: 'P-256', d, x, y },
format: 'jwk',
});
const publicKey = createPublicKey({
key: { kty: 'EC', crv: 'P-256', x, y },
format: 'jwk',
});
return {
privateKeyPem: privateKey
.export({ format: 'pem', type: 'pkcs8' })
.toString(),
publicKeyPem: publicKey.export({ format: 'pem', type: 'spki' }).toString(),
};
};
export const deriveTenantKeyPair = (
masterPrivateKeyPem: string,
scope: DerivationScope,
): DerivedKeyPair => {
const ikm = masterPrivateKeyToIkm(masterPrivateKeyPem);
const salt = derivationScopeToSalt(scope);
const scalar = Buffer.from(
hkdfSync(HKDF_HASH, ikm, salt, HKDF_INFO, HKDF_OUTPUT_BYTES),
);
return scalarToKeyPair(scalar);
};