Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e10525bbf9 |
+54
@@ -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)) {
|
||||
|
||||
+145
@@ -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' });
|
||||
});
|
||||
});
|
||||
+118
@@ -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);
|
||||
};
|
||||
Reference in New Issue
Block a user