Files
dashcaddy/status/js/core/credentials.js
Hermes 84edb035e3
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s
[grade=B] feat(auth): onboard missing credentials into encrypted vault
2026-08-22 05:41:38 -07:00

402 lines
15 KiB
JavaScript

// ========== CREDENTIAL MANAGEMENT ==========
(function () {
// Inject skeleton modal — credential sections are built dynamically when opened
injectModal('token-management-modal', `
<div id="token-management-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 500px; max-width: 600px;">
<h3>\u{1F511} DNS Credentials</h3>
<p style="font-size: 0.85rem; color: var(--muted); margin: 0 0 16px;">
Enter Technitium DNS login credentials. Read-only accounts are used for logs; admin accounts for restarts, records, and updates.
</p>
<div id="dns-cred-sections"></div>
<div class="weather-modal-buttons modal-footer-bar">
<button id="token-clear-all" style="margin-right: auto; background: color-mix(in srgb, var(--bad-fg) 15%, transparent); border-color: var(--bad-fg); color: var(--bad-fg);">Clear All</button>
<button id="token-cancel">Cancel</button>
<button id="token-save" class="btn-accent">Save</button>
</div>
</div>
</div>
`);
// ── Helpers ──
function getDnsIds() {
return Object.keys(SITE.dnsServers || {});
}
function getDnsDisplayName(dnsId) {
const server = (SITE.dnsServers || {})[dnsId];
return server?.name || dnsId.toUpperCase();
}
async function requireSuccessfulDnsMutation(response, label) {
if (!response) throw new Error(`${label} failed: no response`);
let data;
try {
data = await response.json();
} catch (_) {
throw new Error(`${label} failed: invalid server response`);
}
if (!response.ok || data?.success !== true) {
throw new Error(data?.error || `${label} failed (${response.status || 'unknown status'})`);
}
return data;
}
/** Build per-server credential form sections from SITE.dnsServers */
function buildCredentialSections() {
const container = document.getElementById('dns-cred-sections');
if (!container) return;
container.innerHTML = '';
const dnsIds = getDnsIds();
if (dnsIds.length === 0) {
container.innerHTML = '<p style="color: var(--muted); text-align: center; padding: 20px;">No DNS servers configured.</p>';
return;
}
for (const id of dnsIds) {
container.insertAdjacentHTML('beforeend', `
<div class="token-section">
<h4 class="token-section-title">${getDnsDisplayName(id)}</h4>
<div class="token-grid">
<div class="token-field">
<label for="${id}-readonly-username">\u{1F4D6} Read-Only (Logs):</label>
<input type="text" id="${id}-readonly-username" placeholder="Username" autocomplete="off" style="margin-bottom: 4px;" />
<div class="token-input-row">
<input type="password" id="${id}-readonly-token" placeholder="Password" autocomplete="off" />
<button type="button" class="token-toggle" data-target="${id}-readonly-token">\u{1F441}</button>
</div>
</div>
<div class="token-field">
<label for="${id}-admin-username">\u{1F527} Admin:</label>
<input type="text" id="${id}-admin-username" placeholder="Username" autocomplete="off" style="margin-bottom: 4px;" />
<div class="token-input-row">
<input type="password" id="${id}-admin-token" placeholder="Password" autocomplete="off" />
<button type="button" class="token-toggle" data-target="${id}-admin-token">\u{1F441}</button>
</div>
</div>
</div>
<div class="token-status" id="${id}-token-status"></div>
</div>
`);
}
}
// ── Credential encryption ──
function getEncryptionKey() {
let key = safeSessionGet('dashcaddy-encryption-key');
if (key) return key;
const oldKey = safeGet('dashcaddy-encryption-key');
if (oldKey) {
safeSessionSet('dashcaddy-encryption-key', oldKey);
safeRemove('dashcaddy-encryption-key');
return oldKey;
}
const array = new Uint8Array(32);
crypto.getRandomValues(array);
key = Array.from(array, b => b.toString(16).padStart(2, '0')).join('');
safeSessionSet('dashcaddy-encryption-key', key);
return key;
}
const ENCRYPTION_KEY = getEncryptionKey();
function credentialEncrypt(text, key) {
if (!text) return '';
const iv = crypto.getRandomValues(new Uint8Array(8));
const ivHex = Array.from(iv, b => b.toString(16).padStart(2, '0')).join('');
const keyBytes = new TextEncoder().encode(key + ivHex);
let result = '';
for (let i = 0; i < text.length; i++) {
const charCode = text.charCodeAt(i)
^ keyBytes[i % keyBytes.length]
^ iv[i % iv.length]
^ ((i * 31 + 17) & 0xFF);
result += String.fromCharCode(charCode);
}
return ivHex + ':' + btoa(result);
}
function credentialDecrypt(encryptedText, key) {
if (!encryptedText) return '';
try {
const colonIdx = encryptedText.indexOf(':');
if (colonIdx === 16) {
const ivHex = encryptedText.substring(0, 16);
const iv = new Uint8Array(ivHex.match(/.{2}/g).map(h => parseInt(h, 16)));
const decoded = atob(encryptedText.substring(17));
const keyBytes = new TextEncoder().encode(key + ivHex);
let result = '';
for (let i = 0; i < decoded.length; i++) {
const charCode = decoded.charCodeAt(i)
^ keyBytes[i % keyBytes.length]
^ iv[i % iv.length]
^ ((i * 31 + 17) & 0xFF);
result += String.fromCharCode(charCode);
}
return result;
}
const decoded = atob(encryptedText);
let result = '';
for (let i = 0; i < decoded.length; i++) {
const charCode = decoded.charCodeAt(i) ^ key.charCodeAt(i % key.length);
result += String.fromCharCode(charCode);
}
return result;
} catch (e) {
return '';
}
}
// ── Credential storage ──
function getCredential(dnsId, tokenType, credType) {
const encrypted = safeGet(`${dnsId}-${tokenType}-${credType}-enc`);
return credentialDecrypt(encrypted, ENCRYPTION_KEY);
}
function setCredential(dnsId, tokenType, credType, value) {
const key = `${dnsId}-${tokenType}-${credType}-enc`;
if (value) {
safeSet(key, credentialEncrypt(value, ENCRYPTION_KEY));
} else {
safeRemove(key);
}
}
function getToken(dnsId, tokenType) {
return getCredential(dnsId, tokenType, 'token');
}
function getUsername(dnsId, tokenType) {
return getCredential(dnsId, tokenType, 'username');
}
function setToken(dnsId, tokenType, token) {
setCredential(dnsId, tokenType, 'token', token);
}
function setUsername(dnsId, tokenType, username) {
setCredential(dnsId, tokenType, 'username', username);
}
function getAllCredentials() {
const result = {};
for (const dnsId of getDnsIds()) {
result[dnsId] = {
readonly: { username: getUsername(dnsId, 'readonly'), token: getToken(dnsId, 'readonly') },
admin: { username: getUsername(dnsId, 'admin'), token: getToken(dnsId, 'admin') }
};
}
return result;
}
function clearAllCredentials() {
getDnsIds().forEach(dnsId => {
['readonly', 'admin'].forEach(tokenType => {
['token', 'username'].forEach(credType => {
safeRemove(`${dnsId}-${tokenType}-${credType}-enc`);
});
});
safeRemove(`${dnsId}-token-enc`);
safeRemove(`${dnsId}-username-enc`);
});
}
function getStoredCredentials(dnsId) {
const readonlyToken = getToken(dnsId, 'readonly');
const readonlyUsername = getUsername(dnsId, 'readonly');
const adminToken = getToken(dnsId, 'admin');
const adminUsername = getUsername(dnsId, 'admin');
const oldToken = credentialDecrypt(safeGet(`${dnsId}-token-enc`), ENCRYPTION_KEY);
const oldUsername = credentialDecrypt(safeGet(`${dnsId}-username-enc`), ENCRYPTION_KEY);
return {
username: adminUsername || readonlyUsername || oldUsername,
token: adminToken || readonlyToken || oldToken,
readonlyToken: readonlyToken || oldToken,
readonlyUsername: readonlyUsername || oldUsername,
adminToken: adminToken || oldToken,
adminUsername: adminUsername || oldUsername
};
}
// ── Modal handlers ──
// Open modal — build sections dynamically, populate from stored creds
document.getElementById('manage-tokens')?.addEventListener('click', () => {
buildCredentialSections();
const modal = document.getElementById('token-management-modal');
const creds = getAllCredentials();
getDnsIds().forEach(dnsId => {
const c = creds[dnsId];
document.getElementById(`${dnsId}-readonly-username`).value = c.readonly.username;
document.getElementById(`${dnsId}-readonly-token`).value = c.readonly.token;
document.getElementById(`${dnsId}-admin-username`).value = c.admin.username;
document.getElementById(`${dnsId}-admin-token`).value = c.admin.token;
document.getElementById(`${dnsId}-token-status`).textContent = '';
});
modal.classList.add('show');
});
// Toggle password visibility + backdrop close (event delegation for dynamic elements)
document.getElementById('token-management-modal')?.addEventListener('click', (e) => {
const btn = e.target.closest('.token-toggle');
if (btn) {
const targetId = btn.dataset.target;
const input = document.getElementById(targetId);
if (input.type === 'password') {
input.type = 'text';
btn.textContent = '\u{1F648}';
} else {
input.type = 'password';
btn.textContent = '\u{1F441}';
}
return;
}
if (e.target.id === 'token-management-modal') {
e.target.classList.remove('show');
}
});
// Save credentials
document.getElementById('token-save')?.addEventListener('click', async () => {
const dnsIds = getDnsIds();
// Build per-server credentials payload for backend sync
const servers = {};
let hasAnyCreds = false;
dnsIds.forEach(dnsId => {
const entry = {};
const roUser = document.getElementById(`${dnsId}-readonly-username`).value.trim();
const roPass = document.getElementById(`${dnsId}-readonly-token`).value.trim();
const adminUser = document.getElementById(`${dnsId}-admin-username`).value.trim();
const adminPass = document.getElementById(`${dnsId}-admin-token`).value.trim();
if (roUser && roPass) {
entry.readonly = { username: roUser, password: roPass };
hasAnyCreds = true;
}
if (adminUser && adminPass) {
entry.admin = { username: adminUser, password: adminPass };
hasAnyCreds = true;
}
if (Object.keys(entry).length > 0) {
servers[dnsId] = entry;
}
});
if (hasAnyCreds) {
dnsIds.forEach(dnsId => {
if (servers[dnsId]) {
document.getElementById(`${dnsId}-token-status`).textContent = 'Verifying...';
document.getElementById(`${dnsId}-token-status`).className = 'token-status';
}
});
try {
const res = await secureFetch('/api/v1/dns/credentials', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ servers })
});
const data = await requireSuccessfulDnsMutation(res, 'DNS credential save');
if (data.results) {
const failed = Object.keys(servers).filter(dnsId => data.results[dnsId]?.success !== true);
if (failed.length) {
const details = failed.map(dnsId => data.results[dnsId]?.error || `${dnsId} failed`).join('; ');
throw new Error(details);
}
}
// Cache locally only after the encrypted server vault confirms success.
dnsIds.forEach(dnsId => {
setUsername(dnsId, 'readonly', document.getElementById(`${dnsId}-readonly-username`).value.trim());
setToken(dnsId, 'readonly', document.getElementById(`${dnsId}-readonly-token`).value.trim());
setUsername(dnsId, 'admin', document.getElementById(`${dnsId}-admin-username`).value.trim());
setToken(dnsId, 'admin', document.getElementById(`${dnsId}-admin-token`).value.trim());
});
dnsIds.forEach(dnsId => {
const statusEl = document.getElementById(`${dnsId}-token-status`);
if (!servers[dnsId]) { statusEl.textContent = ''; return; }
const result = data.results?.[dnsId];
statusEl.textContent = result?.partial ? '\u2713 ' + result.partial : '\u2713 Verified & saved';
statusEl.className = 'token-status success';
});
} catch (e) {
console.error('Failed to sync DNS credentials to backend:', e);
dnsIds.forEach(dnsId => {
if (servers[dnsId]) {
document.getElementById(`${dnsId}-token-status`).textContent = '\u2717 ' + (e.message || 'Save failed');
document.getElementById(`${dnsId}-token-status`).className = 'token-status error';
}
});
}
} else {
dnsIds.forEach(dnsId => {
document.getElementById(`${dnsId}-token-status`).textContent = '';
});
}
// Auto-close after delay if all succeeded
setTimeout(() => {
const allGood = dnsIds.every(dnsId => {
const status = document.getElementById(`${dnsId}-token-status`)?.textContent;
return !status || status.includes('\u2713');
});
if (allGood) closeModal('token-management-modal');
}, 1500);
});
document.getElementById('token-cancel')?.addEventListener('click', () => {
closeModal('token-management-modal');
});
document.getElementById('token-clear-all')?.addEventListener('click', async () => {
if (confirm('Clear all stored DNS credentials? This cannot be undone.')) {
try {
const response = await secureFetch('/api/v1/dns/credentials', { method: 'DELETE' });
await requireSuccessfulDnsMutation(response, 'DNS credential removal');
clearAllCredentials();
getDnsIds().forEach(dnsId => {
document.getElementById(`${dnsId}-readonly-username`).value = '';
document.getElementById(`${dnsId}-readonly-token`).value = '';
document.getElementById(`${dnsId}-admin-username`).value = '';
document.getElementById(`${dnsId}-admin-token`).value = '';
document.getElementById(`${dnsId}-token-status`).textContent = '\u2713 Cleared';
document.getElementById(`${dnsId}-token-status`).className = 'token-status success';
});
} catch (e) {
getDnsIds().forEach(dnsId => {
document.getElementById(`${dnsId}-token-status`).textContent = '\u2717 ' + (e.message || 'Clear failed');
document.getElementById(`${dnsId}-token-status`).className = 'token-status error';
});
}
}
});
// ── Window exports ──
window.getToken = getToken;
window.getUsername = getUsername;
window.setToken = setToken;
window.setUsername = setUsername;
window.getAllCredentials = getAllCredentials;
window.getCredential = getCredential;
window.setCredential = setCredential;
window.getEncryptionKey = getEncryptionKey;
window.getDnsIds = getDnsIds;
window.getDnsDisplayName = getDnsDisplayName;
})();