mirror of
https://github.com/abort-retry-ignore/joplock.git
synced 2026-04-28 01:49:30 +00:00
150 lines
4.1 KiB
JavaScript
150 lines
4.1 KiB
JavaScript
const crypto = require('crypto');
|
|
const qrImage = require('qr-image');
|
|
|
|
const base32Alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
|
|
|
const normalizeSeed = seed => `${seed || ''}`.trim().toUpperCase().replace(/\s+/g, '');
|
|
|
|
const base32Decode = value => {
|
|
const normalized = normalizeSeed(value).replace(/=+$/g, '');
|
|
let bits = '';
|
|
for (const char of normalized) {
|
|
const index = base32Alphabet.indexOf(char);
|
|
if (index < 0) throw new Error('Invalid TOTP seed');
|
|
bits += index.toString(2).padStart(5, '0');
|
|
}
|
|
const bytes = [];
|
|
for (let i = 0; i + 8 <= bits.length; i += 8) {
|
|
bytes.push(Number.parseInt(bits.slice(i, i + 8), 2));
|
|
}
|
|
return Buffer.from(bytes);
|
|
};
|
|
|
|
const base32Encode = buffer => {
|
|
let bits = '';
|
|
for (const byte of buffer) bits += byte.toString(2).padStart(8, '0');
|
|
let result = '';
|
|
for (let i = 0; i < bits.length; i += 5) {
|
|
const chunk = bits.slice(i, i + 5).padEnd(5, '0');
|
|
result += base32Alphabet[Number.parseInt(chunk, 2)];
|
|
}
|
|
return result;
|
|
};
|
|
|
|
const generateSeed = () => {
|
|
// 20 bytes = 160 bits, standard for TOTP
|
|
const bytes = crypto.randomBytes(20);
|
|
return base32Encode(bytes);
|
|
};
|
|
|
|
const hotp = (secret, counter) => {
|
|
const buf = Buffer.alloc(8);
|
|
buf.writeUInt32BE(Math.floor(counter / 0x100000000), 0);
|
|
buf.writeUInt32BE(counter % 0x100000000, 4);
|
|
const digest = crypto.createHmac('sha1', secret).update(buf).digest();
|
|
const offset = digest[digest.length - 1] & 0x0f;
|
|
const binary = ((digest[offset] & 0x7f) << 24) | ((digest[offset + 1] & 0xff) << 16) | ((digest[offset + 2] & 0xff) << 8) | (digest[offset + 3] & 0xff);
|
|
return `${binary % 1000000}`.padStart(6, '0');
|
|
};
|
|
|
|
// Verify TOTP code against arbitrary seed
|
|
const verifyWithSeed = (seed, code, now = Date.now()) => {
|
|
if (!seed) return false;
|
|
const token = `${code || ''}`.replace(/\s+/g, '');
|
|
if (!/^\d{6}$/.test(token)) return false;
|
|
try {
|
|
const secret = base32Decode(seed);
|
|
const counter = Math.floor(now / 30000);
|
|
for (let offset = -1; offset <= 1; offset++) {
|
|
if (hotp(secret, counter + offset) === token) return true;
|
|
}
|
|
} catch {
|
|
return false;
|
|
}
|
|
return false;
|
|
};
|
|
|
|
// Generate otpauth URI for arbitrary seed
|
|
const otpauthUri = (seed, accountLabel, issuer = 'Joplock') => {
|
|
if (!seed) return '';
|
|
const normalizedSeed = normalizeSeed(seed);
|
|
const label = encodeURIComponent(`${issuer}:${accountLabel}`);
|
|
return `otpauth://totp/${label}?secret=${normalizedSeed}&issuer=${encodeURIComponent(issuer)}`;
|
|
};
|
|
|
|
// Generate QR code as SVG data URL (synchronous for template use)
|
|
let _qrCache = new Map();
|
|
const qrCodeDataUrl = (text) => {
|
|
if (!text) return '';
|
|
if (_qrCache.has(text)) return _qrCache.get(text);
|
|
let svg = '';
|
|
try {
|
|
svg = qrImage.imageSync(text, { type: 'svg', margin: 2 });
|
|
} catch {
|
|
return '';
|
|
}
|
|
const dataUrl = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
|
|
_qrCache.set(text, dataUrl);
|
|
// Limit cache size
|
|
if (_qrCache.size > 100) {
|
|
const first = _qrCache.keys().next().value;
|
|
_qrCache.delete(first);
|
|
}
|
|
return dataUrl;
|
|
};
|
|
|
|
const createMfaService = options => {
|
|
const seed = normalizeSeed(options.seed);
|
|
const issuer = options.issuer || 'Joplock';
|
|
const enabled = !!seed;
|
|
const secret = enabled ? base32Decode(seed) : null;
|
|
|
|
return {
|
|
enabled() {
|
|
return enabled;
|
|
},
|
|
|
|
issuer() {
|
|
return issuer;
|
|
},
|
|
|
|
otpauthUri(accountLabel) {
|
|
if (!enabled) return '';
|
|
const label = encodeURIComponent(`${issuer}:${accountLabel}`);
|
|
return `otpauth://totp/${label}?secret=${seed}&issuer=${encodeURIComponent(issuer)}`;
|
|
},
|
|
|
|
verify(code, now = Date.now()) {
|
|
if (!enabled) return true;
|
|
const token = `${code || ''}`.replace(/\s+/g, '');
|
|
if (!/^\d{6}$/.test(token)) return false;
|
|
const counter = Math.floor(now / 30000);
|
|
for (let offset = -1; offset <= 1; offset++) {
|
|
if (hotp(secret, counter + offset) === token) return true;
|
|
}
|
|
return false;
|
|
},
|
|
|
|
maskedSeed() {
|
|
if (!enabled) return '';
|
|
return seed;
|
|
},
|
|
|
|
qrDataUrl(accountLabel) {
|
|
if (!enabled) return '';
|
|
return qrCodeDataUrl(this.otpauthUri(accountLabel));
|
|
},
|
|
};
|
|
};
|
|
|
|
module.exports = {
|
|
base32Decode,
|
|
base32Encode,
|
|
createMfaService,
|
|
generateSeed,
|
|
hotp,
|
|
normalizeSeed,
|
|
otpauthUri,
|
|
qrCodeDataUrl,
|
|
verifyWithSeed,
|
|
};
|