feat: add TOTP multifactor authentication

This commit is contained in:
jb
2026-08-07 13:10:05 -04:00
parent 4777a3abd5
commit e67c3d4cdb
50 changed files with 6646 additions and 539 deletions
+59
View File
@@ -5,6 +5,7 @@ const APP_REQUIRED = [
'APP_ORIGIN',
'ORIGIN',
'PASSKEY_RP_ID',
'MFA_TOTP_ENCRYPTION_KEYS',
'SCHEDULER_TOKEN',
'TDR_DATABASE_PATH',
'TDR_AVATAR_STORAGE_PATH',
@@ -48,6 +49,7 @@ export function validateRuntimeEnvironment(mode, environment) {
requireValues(APP_REQUIRED);
validateSchedulerToken(get('SCHEDULER_TOKEN'), errors);
validateMfaTotpEncryptionKeys(get('MFA_TOTP_ENCRYPTION_KEYS'), errors);
const appOrigin = validateOrigin('APP_ORIGIN', get('APP_ORIGIN'), errors);
const adapterOrigin = validateOrigin('ORIGIN', get('ORIGIN'), errors);
@@ -230,3 +232,60 @@ function validateSchedulerToken(value, errors) {
);
}
}
/**
* Validate the app-only TOTP encryption keyring without including configured
* values in an error. The first entry is the active write key.
*
* @param {string} value
* @param {string[]} errors
*/
function validateMfaTotpEncryptionKeys(value, errors) {
if (!value) return;
const entries = value.split(',').map((entry) => entry.trim());
if (
entries.length < 1 ||
entries.length > 3 ||
entries.some((entry) => !entry)
) {
errors.push(
'MFA_TOTP_ENCRYPTION_KEYS must contain 1 through 3 key entries.',
);
return;
}
const ids = new Set();
const keys = new Set();
for (const entry of entries) {
const separator = entry.indexOf(':');
const keyId = separator > 0 ? entry.slice(0, separator) : '';
const encodedKey = separator > 0 ? entry.slice(separator + 1) : '';
if (
!/^[A-Za-z0-9_-]{1,64}$/.test(keyId) ||
!/^[A-Za-z0-9_-]+$/.test(encodedKey) ||
encodedKey.length !== 43
) {
errors.push(
'MFA_TOTP_ENCRYPTION_KEYS entries must use key-id and canonical 32-byte Base64URL values.',
);
continue;
}
let decoded;
try {
decoded = Buffer.from(encodedKey, 'base64url');
} catch {
decoded = Buffer.alloc(0);
}
if (decoded.length !== 32 || decoded.toString('base64url') !== encodedKey) {
errors.push(
'MFA_TOTP_ENCRYPTION_KEYS entries must use key-id and canonical 32-byte Base64URL values.',
);
continue;
}
if (ids.has(keyId) || keys.has(encodedKey)) {
errors.push('MFA_TOTP_ENCRYPTION_KEYS must not repeat key IDs or keys.');
}
ids.add(keyId);
keys.add(encodedKey);
}
}