// ============================================================ // 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 []; } }