5fc07aed7a
Fourth slice of the app.js split. Moves TOTP (base32Decode, generateTOTP, parseOtpAuthUri) plus the TOTP-secret and custom-field AES-GCM wrappers to js/app.totp.js. Loads before app.js (pure declarations), after app.crypto.js (uses encryptPwd/decryptPwd). Also called by app.import.js and app.sync.js via shared global scope. - Byte-for-byte identical extraction; no duplicate const; syntax OK on all five app parts. - NEW: js/tests/totp.test.js — 13 tests including the 5 RFC 6238 Appendix B reference vectors (generateTOTP reads Date.now(), so each case stubs the sandbox clock to the vector's fixed time), base32 decode edge cases, and parseOtpAuthUri. Extraction AND new coverage in one slice. - Suite: 42 → 55 tests, all green. - Assets regenerated (manifest now embeds all 6 ordered JS files: argon2 → crypto → totp → import → app → sync); also fixes the previous import commit's not-yet-rebuilt manifest. - Delphi build artifacts (*.vrc, *.$manifest) gitignored. app.js: 11936 → 10138 lines (4 modules extracted, ~1800 lines). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
132 lines
5.3 KiB
JavaScript
132 lines
5.3 KiB
JavaScript
// ============================================================
|
|
// app.totp.js — TOTP + TOTP-secret/custom-field crypto (extracted §3.1)
|
|
// ============================================================
|
|
//
|
|
// RFC 6238 TOTP generation (base32Decode, generateTOTP, parseOtpAuthUri) plus
|
|
// the small AES-GCM wrappers for TOTP secrets and custom fields (they mirror
|
|
// encryptPwd/decryptPwd from app.crypto.js). Pure declarations, no top-level
|
|
// side effects → loads BEFORE app.js, AFTER app.crypto.js (uses encryptPwd/
|
|
// decryptPwd). Also used by app.import.js (encryptCustomFields) and
|
|
// app.sync.js (decryptTotpSecret/decryptCustomFields) via shared global scope.
|
|
//
|
|
// ============================================================
|
|
// TOTP (RFC 6238) — 6-digit time-based codes
|
|
// ============================================================
|
|
//
|
|
// Implementation is pure crypto.subtle (HMAC-SHA1) + a small base32
|
|
// decoder. No external library. The secret is stored encrypted with the
|
|
// vault's AES-GCM key (same flow as passwords), so the server never sees
|
|
// the plaintext base32 secret.
|
|
|
|
// Decode an RFC 4648 base32 string (Google Authenticator format) to bytes.
|
|
// Tolerates lowercase, spaces, and padding. Throws on invalid characters.
|
|
function base32Decode(s) {
|
|
const ALPH = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
|
const clean = String(s).toUpperCase().replace(/[\s=]/g, '');
|
|
let bits = 0, buffer = 0;
|
|
const out = [];
|
|
for (const ch of clean) {
|
|
const v = ALPH.indexOf(ch);
|
|
if (v < 0) throw new Error('Invalid base32 character: ' + ch);
|
|
buffer = (buffer << 5) | v;
|
|
bits += 5;
|
|
if (bits >= 8) {
|
|
bits -= 8;
|
|
out.push((buffer >> bits) & 0xFF);
|
|
}
|
|
}
|
|
return new Uint8Array(out);
|
|
}
|
|
|
|
// Generate a TOTP code per RFC 6238. Returns the 6-digit code as a string
|
|
// (zero-padded) along with how many seconds remain in the current 30s window.
|
|
// Throws if the secret can't be decoded.
|
|
async function generateTOTP(secretBase32, period, digits) {
|
|
period = period || 30;
|
|
digits = digits || 6;
|
|
const keyBytes = base32Decode(secretBase32);
|
|
// Counter = floor(unix_time / period), encoded as 8-byte big-endian.
|
|
const nowSec = Math.floor(Date.now() / 1000);
|
|
let counter = Math.floor(nowSec / period);
|
|
const counterBytes = new Uint8Array(8);
|
|
for (let i = 7; i >= 0; i--) {
|
|
counterBytes[i] = counter & 0xFF;
|
|
counter = Math.floor(counter / 256);
|
|
}
|
|
|
|
const cryptoKey = await crypto.subtle.importKey(
|
|
'raw', keyBytes,
|
|
{ name: 'HMAC', hash: 'SHA-1' },
|
|
false, ['sign']
|
|
);
|
|
const sigBuf = await crypto.subtle.sign('HMAC', cryptoKey, counterBytes);
|
|
const sig = new Uint8Array(sigBuf);
|
|
|
|
// Dynamic truncation: low nibble of last byte = offset into HMAC output.
|
|
const offset = sig[sig.length - 1] & 0x0F;
|
|
const truncated =
|
|
((sig[offset] & 0x7F) << 24) |
|
|
((sig[offset + 1] & 0xFF) << 16) |
|
|
((sig[offset + 2] & 0xFF) << 8) |
|
|
( sig[offset + 3] & 0xFF);
|
|
const mod = Math.pow(10, digits);
|
|
const code = String(truncated % mod).padStart(digits, '0');
|
|
|
|
return {
|
|
code: code,
|
|
period: period,
|
|
secondsLeft: period - (nowSec % period),
|
|
};
|
|
}
|
|
|
|
// Parse a Google Authenticator-style otpauth:// URI and extract the secret.
|
|
// Example: otpauth://totp/Example:alice@example.com?secret=JBSWY3DPEHPK3PXP&issuer=Example
|
|
// Returns the secret alone (we don't yet honor issuer/algorithm/digits/period
|
|
// overrides — assume SHA-1 / 6 digits / 30s, which covers ~all real services).
|
|
function parseOtpAuthUri(raw) {
|
|
raw = String(raw || '').trim();
|
|
if (!raw.toLowerCase().startsWith('otpauth://')) return null;
|
|
try {
|
|
const u = new URL(raw);
|
|
const sec = u.searchParams.get('secret');
|
|
return sec ? sec.trim() : null;
|
|
} catch (e) { return null; }
|
|
}
|
|
|
|
// Encrypt a TOTP secret with the vault key. Returns { encrypted, iv } in
|
|
// the same base64 shape as encryptPwd, ready to send to the server.
|
|
async function encryptTotpSecret(secretBase32) {
|
|
return await encryptPwd(secretBase32); // same crypto, just different field
|
|
}
|
|
async function decryptTotpSecret(encB64, ivB64) {
|
|
return await decryptPwd(encB64, ivB64);
|
|
}
|
|
|
|
// ---- Custom fields (per-entry encrypted JSON array) ----------------
|
|
//
|
|
// Stored as:
|
|
// vault_entries.custom_fields = base64 AES-GCM ciphertext of JSON
|
|
// vault_entries.custom_fields_iv = base64 12-byte IV
|
|
// Plaintext shape:
|
|
// [{ "label": "PIN", "value": "1234", "is_secret": true }, ...]
|
|
//
|
|
// Same crypto pipeline as encrypted_password (reuses encryptPwd /
|
|
// decryptPwd over the JSON string) so the master-pw rotation logic
|
|
// works without any special-casing — it just sees one more ciphertext
|
|
// blob per entry to re-encrypt.
|
|
async function encryptCustomFields(fieldsArray) {
|
|
if (!Array.isArray(fieldsArray) || fieldsArray.length === 0)
|
|
return { encrypted: '', iv: '' };
|
|
return await encryptPwd(JSON.stringify(fieldsArray));
|
|
}
|
|
async function decryptCustomFields(encB64, ivB64) {
|
|
if (!encB64 || !ivB64) return [];
|
|
const plain = await decryptPwd(encB64, ivB64);
|
|
if (plain === '[ERROR]' || !plain) return [];
|
|
try {
|
|
const arr = JSON.parse(plain);
|
|
return Array.isArray(arr) ? arr : [];
|
|
} catch (e) { return []; }
|
|
}
|
|
|