Merge main checkpoint c01a388 into feat/erp-inventar-export

Brings the ERP inventory export up to date with the platform backbone and
field capture work. Conflicts in ARCHITECTURE.md, the API key service and
the API key settings tab resolved by keeping both sides:
- read and write keys stay separate; erp-inventory:read is read-only and
  cannot be combined with a write scope
- the failed-attempt lockout counts in the shared ThrottlerHit table so it
  holds across API pods
- the UI offers only scopes the user holds and creates one scope per key

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-13 22:49:53 +02:00
commit 53fab102f2
347 files changed
+26087 -2141

No files matched your search

@@ -1,37 +1,114 @@
import { decryptMfaSecret, encryptMfaSecret } from './mfa-crypto.util';
import { Logger } from '@nestjs/common';
import { KEY_ID_PATTERN } from '../../common/crypto/key-ring';
import {
decryptMfaSecret,
encryptMfaSecret,
mfaSecretNeedsRekey,
} from './mfa-crypto.util';
// Written by the code before the key ring (AES key sha256(JWT_ACCESS_SECRET),
// no key id) with JWT_ACCESS_SECRET = 'x' * 40.
const LEGACY_SECRET =
'zJD2lAy1b/wslLyQ.hn8scDeaxIADXlRFtcGIyg==.3+5qbYM52lKKN/VXpInUOQ==';
const PLAINTEXT = 'JBSWY3DPEHPK3PXP';
describe('mfa-crypto.util', () => {
const originalSecret = process.env.JWT_ACCESS_SECRET;
const originalEnv = { ...process.env };
beforeEach(() => {
process.env.JWT_ACCESS_SECRET = 'unit-test-jwt-access-secret';
process.env.JWT_ACCESS_SECRET = 'x'.repeat(40);
process.env.MFA_ENCRYPTION_KEY = 'm'.repeat(40);
delete process.env.MFA_ENCRYPTION_KEY_PREVIOUS;
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
});
afterEach(() => {
process.env.JWT_ACCESS_SECRET = originalSecret;
process.env = { ...originalEnv };
jest.restoreAllMocks();
});
it('round-trips a TOTP secret through encrypt/decrypt', () => {
const plaintext = 'JBSWY3DPEHPK3PXP';
const ciphertext = encryptMfaSecret(plaintext);
expect(ciphertext).not.toContain(plaintext);
expect(decryptMfaSecret(ciphertext)).toBe(plaintext);
it('round-trips a TOTP secret and prefixes the key id', () => {
const ciphertext = encryptMfaSecret(PLAINTEXT);
expect(ciphertext).toMatch(KEY_ID_PATTERN);
expect(ciphertext).not.toContain(PLAINTEXT);
expect(decryptMfaSecret(ciphertext)).toBe(PLAINTEXT);
});
it('produces a different ciphertext every time (random IV)', () => {
const plaintext = 'JBSWY3DPEHPK3PXP';
expect(encryptMfaSecret(plaintext)).not.toBe(encryptMfaSecret(plaintext));
expect(encryptMfaSecret(PLAINTEXT)).not.toBe(encryptMfaSecret(PLAINTEXT));
});
it('no longer depends on JWT_ACCESS_SECRET', () => {
const ciphertext = encryptMfaSecret(PLAINTEXT);
process.env.JWT_ACCESS_SECRET = 'z'.repeat(40);
expect(decryptMfaSecret(ciphertext)).toBe(PLAINTEXT);
});
it('fails to decrypt with the wrong key', () => {
const ciphertext = encryptMfaSecret('JBSWY3DPEHPK3PXP');
process.env.JWT_ACCESS_SECRET = 'a-different-secret';
expect(() => decryptMfaSecret(ciphertext)).toThrow();
const ciphertext = encryptMfaSecret(PLAINTEXT);
process.env.MFA_ENCRYPTION_KEY = 'n'.repeat(40);
expect(() => decryptMfaSecret(ciphertext)).toThrow('unknown key id');
});
it('opens a secret sealed with MFA_ENCRYPTION_KEY_PREVIOUS and flags it for rekey', () => {
const ciphertext = encryptMfaSecret(PLAINTEXT);
process.env.MFA_ENCRYPTION_KEY_PREVIOUS = process.env.MFA_ENCRYPTION_KEY;
process.env.MFA_ENCRYPTION_KEY = 'n'.repeat(40);
expect(decryptMfaSecret(ciphertext)).toBe(PLAINTEXT);
expect(mfaSecretNeedsRekey(ciphertext)).toBe(true);
});
it('does not flag a secret sealed with the current key', () => {
expect(mfaSecretNeedsRekey(encryptMfaSecret(PLAINTEXT))).toBe(false);
});
it('still opens a legacy MFA secret encrypted with the JWT-derived key', () => {
expect(decryptMfaSecret(LEGACY_SECRET)).toBe(PLAINTEXT);
expect(mfaSecretNeedsRekey(LEGACY_SECRET)).toBe(true);
});
it('fails closed on a legacy secret once JWT_ACCESS_SECRET changed', () => {
process.env.JWT_ACCESS_SECRET = 'z'.repeat(40);
expect(() => decryptMfaSecret(LEGACY_SECRET)).toThrow(
'ciphertext failed authentication',
);
});
it('never retries a k…: value that fails authentication with the legacy key', () => {
const warn = jest.spyOn(Logger.prototype, 'warn');
const currentKeyId = encryptMfaSecret(PLAINTEXT).split(':')[0];
// A valid legacy ciphertext behind the current key id: only a retry with
// sha256(JWT_ACCESS_SECRET) could open it.
const disguised = `${currentKeyId}:${LEGACY_SECRET}`;
expect(() => decryptMfaSecret(disguised)).toThrow(
'ciphertext failed authentication',
);
expect(() => decryptMfaSecret(`k00000000:${LEGACY_SECRET}`)).toThrow(
'unknown key id',
);
expect(warn).not.toHaveBeenCalled();
});
it('fails on a tampered ciphertext', () => {
const [keyId, body] = encryptMfaSecret(PLAINTEXT).split(':');
const [iv, tag, data] = body.split('.');
const bytes = Buffer.from(data, 'base64');
bytes[0] ^= 0xff;
expect(() =>
decryptMfaSecret(`${keyId}:${iv}.${tag}.${bytes.toString('base64')}`),
).toThrow('ciphertext failed authentication');
});
it('rejects a malformed ciphertext', () => {
expect(() => decryptMfaSecret('not-a-valid-ciphertext')).toThrow(
'Invalid MFA secret ciphertext',
'malformed ciphertext',
);
});
});
+21 -43
View File
@@ -1,53 +1,31 @@
import {
createCipheriv,
createDecipheriv,
createHash,
randomBytes,
} from 'node:crypto';
const ALGORITHM = 'aes-256-gcm';
const IV_BYTES = 12;
function getKey(): Buffer {
const secret = process.env.JWT_ACCESS_SECRET;
if (!secret) {
throw new Error('JWT_ACCESS_SECRET is not set');
}
return createHash('sha256').update(secret).digest();
}
KEY_ID_PATTERN,
isSealedWithCurrentKey,
loadKeyRing,
openLegacyCiphertext,
openWithRing,
sealWithRing,
} from '../../common/crypto/key-ring';
/**
* TOTP secrets must be reversible (unlike passwords/refresh tokens, which
* are one-way hashed) so the server can compute the expected code on every
* login. AES-256-GCM at rest, keyed off JWT_ACCESS_SECRET, keeps the secret
* out of the DB in plaintext without introducing a new secret to manage.
* login. They are sealed with the MFA key ring (MFA_ENCRYPTION_KEY, see
* common/crypto/key-ring.ts). Secrets stored before the key ring have no key
* id and were encrypted with sha256(JWT_ACCESS_SECRET); they stay readable
* until the rekey job reseals them.
*/
export function encryptMfaSecret(plaintext: string): string {
const iv = randomBytes(IV_BYTES);
const cipher = createCipheriv(ALGORITHM, getKey(), iv);
const encrypted = Buffer.concat([
cipher.update(plaintext, 'utf8'),
cipher.final(),
]);
const authTag = cipher.getAuthTag();
return [iv, authTag, encrypted]
.map((buffer) => buffer.toString('base64'))
.join('.');
return sealWithRing(loadKeyRing('mfa'), plaintext);
}
export function decryptMfaSecret(ciphertext: string): string {
const [ivB64, authTagB64, dataB64] = ciphertext.split('.');
if (!ivB64 || !authTagB64 || !dataB64) {
throw new Error('Invalid MFA secret ciphertext');
}
const decipher = createDecipheriv(
ALGORITHM,
getKey(),
Buffer.from(ivB64, 'base64'),
);
decipher.setAuthTag(Buffer.from(authTagB64, 'base64'));
return Buffer.concat([
decipher.update(Buffer.from(dataB64, 'base64')),
decipher.final(),
]).toString('utf8');
export function decryptMfaSecret(stored: string): string {
return KEY_ID_PATTERN.test(stored)
? openWithRing(loadKeyRing('mfa'), stored).plaintext
: openLegacyCiphertext('mfa', stored);
}
/** True for secrets the rekey job should reseal with the current key. */
export function mfaSecretNeedsRekey(stored: string): boolean {
return !isSealedWithCurrentKey(loadKeyRing('mfa'), stored);
}