This commit is contained in:
Seth Trowbridge 2026-03-09 09:22:39 -04:00
commit 3c6df0e32f

571
index.html Normal file
View File

@ -0,0 +1,571 @@
<!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">
<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>
<p class="small muted">Each entry follows: <code>CredSet = [username, password, codegen, custom_kvp]</code></p>
<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>