cred-vault/index.html
2026-03-10 13:58:00 -04:00

571 lines
22 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<title>Local CredVault (No Cloud, No Build)</title>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<style>
:root {
color-scheme: light dark;
--bg: #0b1220;
--card: #111827;
--soft: #1f2937;
--text: #e5e7eb;
--muted: #9ca3af;
--accent: #60a5fa;
--danger: #ef4444;
--ok: #34d399;
--warn: #f59e0b;
--border: #2a3444;
}
* { box-sizing: border-box; }
body {
margin: 0; font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, Helvetica Neue, Arial;
background: linear-gradient(180deg, #0b1220, #0d1322);
color: var(--text);
}
header {
padding: 18px 16px;
border-bottom: 1px solid var(--border);
position: sticky; top: 0; backdrop-filter: blur(6px);
background: rgba(11,18,32,.8);
z-index: 10;
}
header h1 { margin: 0 0 6px 0; font-size: 18px; }
header p { margin: 0; color: var(--muted); font-size: 13px; }
main { max-width: 1100px; margin: 0 auto; padding: 20px 16px 64px; }
.grid { display: grid; gap: 14px; }
@media (min-width: 960px) {
.grid-2 { grid-template-columns: 1.1fr .9fr; }
}
section.card {
background: linear-gradient(180deg, var(--card), #0e1627);
border: 1px solid var(--border);
border-radius: 14px;
padding: 14px;
box-shadow: 0 6px 24px rgba(0,0,0,.25);
}
h2 { margin: 0 0 10px 0; font-size: 16px; }
.row { display: grid; grid-template-columns: 140px 1fr; gap: 10px; align-items: center; margin: 8px 0; }
.row > label { color: var(--muted); font-size: 12px; }
input[type="text"], input[type="password"], textarea, input[type="number"] {
width: 100%; padding: 10px 12px; border-radius: 10px; background: #0b1629; border: 1px solid #213049;
color: var(--text); outline: none; font-size: 14px;
}
textarea { min-height: 110px; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, Monaco, "Liberation Mono", "Courier New", monospace; }
.actions { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 10px; }
button {
background: linear-gradient(180deg, #1f2b41, #1a2436);
color: var(--text); border: 1px solid #2a3a56; border-radius: 10px; padding: 9px 12px;
cursor: pointer; font-weight: 600; font-size: 14px;
}
button:hover { border-color: #3b537e; }
.btn-accent { background: linear-gradient(180deg, #2a4a85, #1c3360); border-color: #3c5da0; }
.btn-danger { background: linear-gradient(180deg, #5a1a1a, #451313); border-color: #6f2323; }
.btn-soft { background: linear-gradient(180deg, #233148, #1a2436); border-color: #2f405f; }
.muted { color: var(--muted); }
.list { display: grid; gap: 10px; }
.cred {
border: 1px solid var(--border); border-radius: 12px; padding: 12px;
background: linear-gradient(180deg, #0d1729, #0b1524);
}
.cred-head { display: flex; gap: 8px; align-items: center; justify-content: space-between; }
.site { font-weight: 700; font-size: 15px; }
.kv { display: grid; grid-template-columns: 120px 1fr auto; gap: 10px; align-items: center; margin-top: 8px; }
.code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, Monaco, monospace; font-weight: 700; font-size: 20px; letter-spacing: 2px; }
.tag { display: inline-flex; align-items: center; gap: 6px; padding: 2px 8px; border: 1px solid #28406b; border-radius: 999px; color: #a7c4ff; background: #0b1a33; font-size: 11px; }
.progress { height: 4px; background: #111826; border-radius: 999px; overflow: hidden; }
.bar { height: 100%; background: linear-gradient(90deg, #2cc5ff, #60a5fa); width: 0%; transition: width .2s linear; }
.sep { height: 1px; background: var(--border); margin: 10px 0; }
.small { font-size: 12px; }
.danger { color: var(--danger); }
.ok { color: var(--ok); }
.warn { color: var(--warn); }
.pill { border: 1px solid var(--border); border-radius: 999px; padding: 2px 8px; font-size: 11px; color: var(--muted); }
.right { text-align: right; }
details > summary { cursor: pointer; color: var(--muted); margin: 6px 0; }
.flex { display: flex; gap: 8px; align-items: center; }
</style>
</head>
<body>
<header>
<h1>Local CredVault — No Cloud, No Build</h1>
<p class="small">Stores credentials in-memory. Encrypt/decrypt to share/backup. TOTP built-in (SHA1, 6 digits, 30s).</p>
</header>
<main class="grid grid-2">
<!-- Left: Editor & List -->
<section class="card">
<h1>Your Credentials</h1>
<h2>Add or Update Credential</h2>
<div class="row">
<label for="site">Site Key</label>
<input id="site" type="text" placeholder="e.g., github.com">
</div>
<div class="row">
<label for="username">Username</label>
<input id="username" type="text" placeholder="username or email">
</div>
<div class="row">
<label for="password">Password</label>
<div class="flex" style="width:100%">
<input id="password" type="password" placeholder="••••••••" style="flex:1">
<button class="btn-soft" id="togglePass">Show</button>
</div>
</div>
<div class="row">
<label for="codegen">TOTP Secret (Base32)</label>
<div class="flex" style="width:100%">
<input id="codegen" type="password" placeholder="Base32 secret like JBSW... (optional)" style="flex:1">
<button class="btn-soft" id="toggleSecret">Show</button>
</div>
</div>
<div class="row">
<label for="custom">Custom KVP (JSON)</label>
<textarea id="custom" placeholder='{"note":"personal","group":"dev"}'></textarea>
</div>
<div class="actions">
<button class="btn-accent" id="addBtn">Add / Update</button>
<button class="btn-danger" id="clearForm">Clear Form</button>
</div>
<div class="sep"></div>
<h2>Vault</h2>
<div id="vaultList" class="list"></div>
</section>
<!-- Right: Encrypt / Decrypt -->
<section class="card">
<h2>Encrypt & Save</h2>
<div class="row">
<label for="encPass">Master Password</label>
<input id="encPass" type="password" placeholder="Choose a strong password">
</div>
<div class="actions">
<button class="btn-accent" id="encryptBtn">Encrypt Vault</button>
<button class="btn-soft" id="copyBlobBtn">Copy Encrypted Blob</button>
</div>
<div class="row">
<label for="blobOut">Encrypted Blob</label>
<textarea id="blobOut" placeholder="Click 'Encrypt Vault' to produce a blob here" readonly></textarea>
</div>
<div class="sep"></div>
<h2>Load & Decrypt</h2>
<div class="row">
<label for="decPass">Master Password</label>
<input id="decPass" type="password" placeholder="Password used to encrypt">
</div>
<div class="row">
<label for="blobIn">Encrypted Blob</label>
<textarea id="blobIn" placeholder="Paste encrypted blob here"></textarea>
</div>
<div class="actions">
<button class="btn-accent" id="decryptBtn">Decrypt & Load</button>
<button class="btn-danger" id="wipeBtn" title="Clears in-memory vault (not the blob)">Wipe In-Memory Vault</button>
</div>
<details>
<summary>Encryption Details</summary>
<ul class="small">
<li>Key Derivation: PBKDF2 (SHA256), default 200,000 iterations</li>
<li>Symmetric Cipher: AESGCM (256bit), 12byte IV</li>
<li>Envelope contains: version, kdf, iterations, salt(b64), iv(b64), cipher(b64)</li>
<li>No cloud; no storage. Refreshing the page clears memory.</li>
</ul>
</details>
</section>
</main>
<script>
/************** Types (for reference) *****************
* type CredSet = [username: string, password: string, codegen: string, custom_kvp: Record<string, string>];
* type CredVault = Record<string, CredSet>;
*****************************************************/
// In-memory vault
/** @type {Record<string, [string, string, string, Record<string,string>]>} */
let VAULT = Object.create(null);
// ===== Utilities =====
const enc = new TextEncoder();
const dec = new TextDecoder();
function $(id) { return document.getElementById(id); }
function zeroPad(num, len) {
let s = String(num);
while (s.length < len) s = "0" + s;
return s;
}
function bytesToBase64(arr) {
let bin = "";
const bytes = new Uint8Array(arr);
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
return btoa(bin);
}
function base64ToBytes(b64) {
const bin = atob(b64);
const len = bin.length;
const bytes = new Uint8Array(len);
for (let i = 0; i < len; i++) bytes[i] = bin.charCodeAt(i);
return bytes;
}
// Base32 (RFC 4648) decoder for TOTP secrets
const B32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
/** @param {string} s */
function base32Decode(s) {
s = s.toUpperCase().replace(/=+$/,"").replace(/\s+/g,"");
let bits = "";
for (let i = 0; i < s.length; i++) {
const idx = B32_ALPHABET.indexOf(s[i]);
if (idx === -1) { throw new Error("Invalid Base32 character"); }
bits += idx.toString(2).padStart(5, "0");
}
const bytes = [];
for (let i = 0; i + 8 <= bits.length; i += 8) {
bytes.push(parseInt(bits.slice(i, i + 8), 2));
}
return new Uint8Array(bytes);
}
// TOTP generation (RFC 6238 default SHA-1, 6 digits, 30s)
async function generateTOTP(secretBase32, timeStep=30, digits=6, algo="SHA-1") {
try {
const keyBytes = base32Decode(secretBase32);
const counter = Math.floor(Date.now() / 1000 / timeStep);
const msg = new ArrayBuffer(8);
const v = new DataView(msg);
// 8-byte big-endian counter
v.setUint32(4, counter, false); // high 4 bytes 0, low 4 bytes counter
const cryptoKey = await crypto.subtle.importKey(
"raw", keyBytes, { name: "HMAC", hash: { name: algo } }, false, ["sign"]
);
const mac = await crypto.subtle.sign("HMAC", cryptoKey, msg);
const macBytes = new Uint8Array(mac);
const offset = macBytes[macBytes.length - 1] & 0x0f;
const code = ((macBytes[offset] & 0x7f) << 24) |
((macBytes[offset + 1] & 0xff) << 16) |
((macBytes[offset + 2] & 0xff) << 8) |
(macBytes[offset + 3] & 0xff);
const hotp = code % (10 ** digits);
return zeroPad(hotp, digits);
} catch (e) {
return ""; // invalid secret or crypto unavailable
}
}
function secondsToNextStep(step=30) {
const now = Math.floor(Date.now() / 1000);
return step - (now % step);
}
// Crypto: KDF (PBKDF2-SHA256), AES-GCM
async function deriveKey(password, salt, iterations=200000) {
const baseKey = await crypto.subtle.importKey(
"raw", enc.encode(password), "PBKDF2", false, ["deriveKey"]
);
return await crypto.subtle.deriveKey(
{ name: "PBKDF2", salt, iterations, hash: "SHA-256" },
baseKey,
{ name: "AES-GCM", length: 256 },
false,
["encrypt", "decrypt"]
);
}
async function encryptVault(password, iterations=200000) {
const salt = crypto.getRandomValues(new Uint8Array(16));
const iv = crypto.getRandomValues(new Uint8Array(12));
const key = await deriveKey(password, salt, iterations);
const plaintext = enc.encode(JSON.stringify(VAULT));
const cipher = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, plaintext);
const envelope = {
version: 1,
kdf: "PBKDF2-SHA256",
iterations,
salt: bytesToBase64(salt),
iv: bytesToBase64(iv),
cipher: bytesToBase64(new Uint8Array(cipher))
};
return JSON.stringify(envelope, null, 2);
}
async function decryptVault(password, blobText) {
const env = JSON.parse(blobText);
if (!env || env.version !== 1) throw new Error("Unsupported or invalid blob");
const salt = base64ToBytes(env.salt);
const iv = base64ToBytes(env.iv);
const cipher = base64ToBytes(env.cipher);
const key = await deriveKey(password, salt, env.iterations);
const plainBuf = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, cipher);
const txt = dec.decode(plainBuf);
/** @type {Record<string, [string,string,string,Record<string,string>]>} */
const obj = JSON.parse(txt);
return obj;
}
// ===== UI Wiring =====
const siteEl = $("site");
const usernameEl = $("username");
const passwordEl = $("password");
const codegenEl = $("codegen");
const customEl = $("custom");
const togglePass = $("togglePass");
const toggleSecret = $("toggleSecret");
togglePass.addEventListener("click", (e) => {
e.preventDefault();
const type = passwordEl.getAttribute("type") === "password" ? "text" : "password";
passwordEl.setAttribute("type", type);
togglePass.textContent = type === "password" ? "Show" : "Hide";
});
toggleSecret.addEventListener("click", (e) => {
e.preventDefault();
const type = codegenEl.getAttribute("type") === "password" ? "text" : "password";
codegenEl.setAttribute("type", type);
toggleSecret.textContent = type === "password" ? "Show" : "Hide";
});
$("addBtn").addEventListener("click", () => {
const site = siteEl.value.trim();
if (!site) { alert("Site key is required (e.g., github.com)"); return; }
const username = usernameEl.value;
const password = passwordEl.value;
const codegen = codegenEl.value.trim();
let custom = {};
if (customEl.value.trim()) {
try { custom = JSON.parse(customEl.value); }
catch (e) { alert("Custom KVP must be valid JSON"); return; }
}
VAULT[site] = [username, password, codegen, custom];
renderVault();
// keep form in place for further edits (or clear if you want)
});
$("clearForm").addEventListener("click", () => {
siteEl.value = "";
usernameEl.value = "";
passwordEl.value = "";
codegenEl.value = "";
customEl.value = "";
});
$("encryptBtn").addEventListener("click", async () => {
const pw = $("encPass").value;
if (!pw) { alert("Enter a master password to encrypt"); return; }
try {
$("blobOut").value = "Encrypting...";
const blob = await encryptVault(pw);
$("blobOut").value = blob;
} catch (e) {
$("blobOut").value = `Error: ${e.message}`;
}
});
$("copyBlobBtn").addEventListener("click", async () => {
const val = $("blobOut").value.trim();
if (!val) { alert("Nothing to copy."); return; }
try {
await navigator.clipboard.writeText(val);
alert("Encrypted blob copied to clipboard.");
} catch (e) {
alert("Copy failed: " + e.message);
}
});
$("decryptBtn").addEventListener("click", async () => {
const pw = $("decPass").value;
const blobText = $("blobIn").value.trim();
if (!pw || !blobText) { alert("Provide both encrypted blob and password."); return; }
try {
const obj = await decryptVault(pw, blobText);
// Validate structure roughly
if (typeof obj !== "object" || Array.isArray(obj)) throw new Error("Decrypted data not a vault object");
// Replace in-memory vault
VAULT = Object.create(null);
for (const [k,v] of Object.entries(obj)) {
if (!Array.isArray(v) || v.length !== 4) continue;
const [u,p,c,custom] = v;
VAULT[k] = [String(u||""), String(p||""), String(c||""), typeof custom === "object" && custom ? custom : {}];
}
renderVault();
alert("Decryption successful. Vault restored in memory.");
} catch (e) {
alert("Decrypt failed: " + e.message);
}
});
$("wipeBtn").addEventListener("click", () => {
if (!confirm("This clears the in-memory vault. Continue?")) return;
VAULT = Object.create(null);
renderVault();
});
function renderVault() {
const list = $("vaultList");
list.innerHTML = "";
const sites = Object.keys(VAULT).sort();
if (sites.length === 0) {
list.innerHTML = `<p class="muted small">No entries yet. Add one above.</p>`;
return;
}
for (const site of sites) {
const [u, p, c, custom] = VAULT[site];
const card = document.createElement("div");
card.className = "cred";
card.innerHTML = `
<div class="cred-head">
<div class="flex">
<span class="site">${escapeHtml(site)}</span>
${c ? `<span class="tag">TOTP</span>` : ``}
</div>
<div class="actions">
<button class="btn-soft" data-act="edit">Edit</button>
<button class="btn-danger" data-act="delete">Delete</button>
</div>
</div>
<div class="kv">
<div class="muted small">Username</div>
<div>${escapeHtml(u || "")}</div>
<div class="right"><button class="btn-soft" data-act="copy-username">Copy</button></div>
<div class="muted small">Password</div>
<div><input type="password" value="${escapeHtmlAttr(p || "")}" style="width:100%"></div>
<div class="right">
<button class="btn-soft" data-act="toggle-password">Show</button>
<button class="btn-soft" data-act="copy-password">Copy</button>
</div>
<div class="muted small">TOTP Code</div>
<div class="code" data-role="totp">${c ? "…" : `<span class="muted">—</span>`}</div>
<div class="right">
<span class="pill small" data-role="ttl"> </span>
<button class="btn-soft" data-act="copy-totp" ${c ? "" : "disabled"}>Copy</button>
</div>
<div class="muted small">Custom</div>
<div><pre class="small" style="margin:0;white-space:pre-wrap">${escapeHtml(JSON.stringify(custom || {}, null, 2))}</pre></div>
<div></div>
</div>
<div class="progress" ${c ? "" : 'style="opacity:.25"'}><div class="bar" data-role="bar"></div></div>
`;
// events
card.querySelector('[data-act="edit"]').addEventListener("click", () => {
siteEl.value = site;
usernameEl.value = u || "";
passwordEl.value = p || "";
codegenEl.value = c || "";
customEl.value = JSON.stringify(custom || {}, null, 2);
window.scrollTo({ top: 0, behavior: "smooth" });
});
card.querySelector('[data-act="delete"]').addEventListener("click", () => {
if (!confirm(`Delete "${site}"?`)) return;
delete VAULT[site];
renderVault();
});
card.querySelector('[data-act="copy-username"]').addEventListener("click", async () => {
await navigator.clipboard.writeText(u || "");
toast("Username copied.");
});
const pwInput = card.querySelector('input[type="password"], input[type="text"]');
card.querySelector('[data-act="toggle-password"]').addEventListener("click", (e) => {
const btn = e.currentTarget;
const t = pwInput.getAttribute("type") === "password" ? "text" : "password";
pwInput.setAttribute("type", t);
btn.textContent = t === "password" ? "Show" : "Hide";
});
card.querySelector('[data-act="copy-password"]').addEventListener("click", async () => {
await navigator.clipboard.writeText(p || "");
toast("Password copied.");
});
card.querySelector('[data-act="copy-totp"]').addEventListener("click", async () => {
const codeEl = card.querySelector('[data-role="totp"]');
const txt = (codeEl.textContent || "").trim();
if (txt && /^\d{6,8}$/.test(txt)) {
await navigator.clipboard.writeText(txt);
toast("TOTP copied.");
}
});
// attach totp updater refs
card.dataset.site = site;
card.dataset.secret = c || "";
card._totpEl = card.querySelector('[data-role="totp"]');
card._ttlEl = card.querySelector('[data-role="ttl"]');
card._barEl = card.querySelector('[data-role="bar"]');
$("vaultList").appendChild(card);
}
// kick the updater (will continue ticking)
tickTOTP();
}
function escapeHtml(s) {
return String(s ?? "").replace(/[&<>"']/g, (c) => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
}[c]));
}
function escapeHtmlAttr(s) {
return escapeHtml(s).replace(/"/g, "&quot;");
}
let _tickHandle = null;
async function tickTOTP() {
if (_tickHandle) return; // already ticking
async function loop() {
const list = $("vaultList");
const cards = list.querySelectorAll(".cred");
for (const card of cards) {
const secret = card.dataset.secret || "";
const totpEl = card._totpEl;
const ttlEl = card._ttlEl;
const barEl = card._barEl;
if (secret) {
const code = await generateTOTP(secret, 30, 6, "SHA-1");
const secs = secondsToNextStep(30);
if (code) {
totpEl.textContent = code;
ttlEl.textContent = `${secs}s`;
const pct = Math.floor(((30 - secs) / 30) * 100);
if (barEl) barEl.style.width = pct + "%";
ttlEl.style.color = secs <= 5 ? "var(--warn)" : "var(--muted)";
totpEl.style.color = secs <= 5 ? "var(--warn)" : "var(--text)";
} else {
totpEl.innerHTML = `<span class="danger small">Invalid secret</span>`;
ttlEl.textContent = "";
if (barEl) barEl.style.width = "0%";
}
} else {
if (totpEl) totpEl.innerHTML = `<span class="muted">—</span>`;
if (ttlEl) ttlEl.textContent = "";
if (barEl) barEl.style.width = "0%";
}
}
_tickHandle = setTimeout(loop, 500);
}
loop();
}
function toast(msg) {
// Minimal non-intrusive alert replacement
console.log(msg);
}
// Initial render
renderVault();
// Warn on unload (since it's in-memory-only)
window.addEventListener("beforeunload", (e) => {
if (Object.keys(VAULT).length > 0) {
e.preventDefault();
e.returnValue = "";
}
});
</script>
</body>
</html>