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

428 lines
17 KiB
HTML

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<title>CredVault (VanJS, in-memory, no storage)</title>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
</head>
<body>
<!-- ===== Burn VanJS into the codebase (from your snippet) ===== -->
<script>
{let e,t,r,o,n,s,l,i,f,h,w,a,u,d,c,_,S,g,y,b,m,v,j,x,O;l=Object.getPrototypeOf,f={},h=l(i={isConnected:1}),w=l(l),a=(e,t,r,o)=>((e??(o?setTimeout(r,o):queueMicrotask(r),new Set)).add(t)),u=(e,t,o)=>{let n=r;r=t;try{return e(o)}catch(e){return console.error(e),o}finally{r=n}},d=e=>e.filter(e=>e.t?.isConnected),c=e=>n=a(n,e,()=>{for(let e of n)e.o=d(e.o),e.l=d(e.l);n=s},1e3),_={get val(){return r?.i?.add(this),this.rawVal},get oldVal(){return r?.i?.add(this),this.h},set val(o){r?.u?.add(this),o!==this.rawVal&&(this.rawVal=o,this.o.length+this.l.length?(t?.add(this),e=a(e,this,x)):this.h=o)}},S=e=>({__proto__:_,rawVal:e,h:e,o:[],l:[]}),g=(e,t)=>{let r={i:new Set,u:new Set},n={f:e},s=o;o=[];let l=u(e,r,t);l=(l??document).nodeType?l:new Text(l);for(let e of r.i)r.u.has(e)||(c(e),e.o.push(n));for(let e of o)e.t=l;return o=s,n.t=l},y=(e,t=S(),r)=>{let n={i:new Set,u:new Set},s={f:e,s:t};s.t=r??o?.push(s)??i,t.val=u(e,n,t.rawVal);for(let e of n.i)n.u.has(e)||(c(e),e.l.push(s));return t},b=(e,...t)=>{for(let r of t.flat(1/0)){let t=l(r??0),o=t===_?g(()=>r.val):t===w?g(r):r;o!=s&&e.append(o)}return e},m=(e,t,...r)=>{let[{is:o,...n},...i]=l(r[0]??0)===h?r:[{},...r],a=e?document.createElementNS(e,t,{is:o}):document.createElement(t,{is:o});for(let[e,r]of Object.entries(n)){let o=t=>t?Object.getOwnPropertyDescriptor(t,e)??o(l(t)):s,n=t+","+e,i=f[n]??=o(l(a))?.set??0,h=e.startsWith("on")?(t,r)=>{let o=e.slice(2);a.removeEventListener(o,r),a.addEventListener(o,t)}:i?i.bind(a):a.setAttribute.bind(a,e),u=l(r??0);e.startsWith("on")||u===w&&(r=y(r),u=_),u===_?g(()=>{h(r.val,r.h),a}):h(r)}return b(a,i)},v=e=>({get:(t,r)=>m.bind(s,e,r)}),j=(e,t)=>t?t!==e&&e.replaceWith(t):e.remove(),x=()=>{let r=0,o=[...e].filter(e=>e.rawVal!==e.h);do{t=new Set;for(let e of new Set(o.flatMap(e=>e.l=d(e.l))))y(e.f,e.s,e.t),e.t=s}while(++r<100&&(o=[...t]).length);let n=[...e].filter(e=>e.rawVal!==e.h);e=s;for(let e of new Set(n.flatMap(e=>e.o=d(e.o))))j(e.t,g(e.f,e.t)),e.t=s;for(let e of n)e.h=e.rawVal},O={tags:new Proxy(e=>new Proxy(m,v(e)),v()),hydrate:(e,t)=>j(e,g(t,e)),add:b,state:S,derive:y},window.van=O;}
</script>
<script type="module">
const $ = van.tags
/** @typedef {[domain:string, username:string, password:string, codegen:string, custom_kvp:Record<string,string>]} CredSet */
/** @typedef {Record<string, CredSet>} CredVault */
// -------- In-memory state --------
/** @type {import('./types').CredVault | any} */
const vault = van.state(/** @type {CredVault} */ ({}), "vault")
// Derived list of [domain, CredSet] sorted by domain
const entries = van.derive(() => Object.entries(vault.val).sort((a,b)=>a[0].localeCompare(b[0])))
// TOTP display state per-domain
/** @type {Map<string, {code: ReturnType<typeof van.state>, ttl: ReturnType<typeof van.state>}>>} */
const totpMap = new Map()
function ensureTotpState(domain) {
if (!totpMap.has(domain)) totpMap.set(domain, { code: van.state(""), ttl: van.state(0) })
return totpMap.get(domain)
}
// -------- Utilities: Base64 / Base32 / TOTP / Crypto --------
const enc = new TextEncoder()
const dec = new TextDecoder()
function bytesToBase64(arr) {
let bin = "", 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 out = new Uint8Array(bin.length)
for (let i=0;i<bin.length;i++) out[i] = bin.charCodeAt(i)
return out
}
// RFC 4648 Base32
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")
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)
}
function zeroPad(n, len) { let s = String(n); while (s.length < len) s = "0"+s; return s }
function secondsToNextStep(step=30) { const now = Math.floor(Date.now()/1000); return step - (now % step) }
async function generateTOTP(secretBase32, step=30, digits=6, algo="SHA-1") {
if (!secretBase32) return ""
try {
const keyBytes = base32Decode(secretBase32)
const counter = Math.floor(Date.now()/1000/step)
const msg = new ArrayBuffer(8); new DataView(msg).setUint32(4, counter, false)
const cryptoKey = await crypto.subtle.importKey("raw", keyBytes, { name:"HMAC", hash: { name: algo } }, false, ["sign"])
const mac = new Uint8Array(await crypto.subtle.sign("HMAC", cryptoKey, msg))
const off = mac[mac.length-1] & 0x0f
const code = ((mac[off] & 0x7f) << 24) | ((mac[off+1] & 0xff) << 16) | ((mac[off+2] & 0xff) << 8) | (mac[off+3] & 0xff)
return zeroPad(code % (10**digits), digits)
} catch { return "" }
}
// KDF + Symmetric crypto
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 plain = enc.encode(JSON.stringify(vault.val))
const cipher = new Uint8Array(await crypto.subtle.encrypt({ name:"AES-GCM", iv }, key, plain))
return JSON.stringify({
version: 1,
kdf: "PBKDF2-SHA256",
iterations,
salt: bytesToBase64(salt),
iv: bytesToBase64(iv),
cipher: bytesToBase64(cipher),
}, 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 key = await deriveKey(password, base64ToBytes(env.salt), env.iterations)
const plain = await crypto.subtle.decrypt({ name:"AES-GCM", iv: base64ToBytes(env.iv) }, key, base64ToBytes(env.cipher))
/** @type {CredVault} */
const obj = JSON.parse(dec.decode(plain))
return obj
}
// -------- QR: BarcodeDetector / otpauth parser --------
const hasBarcodeDetector = "BarcodeDetector" in window
/** @type {BarcodeDetector?} */
let detector = null
if (hasBarcodeDetector) {
try { detector = new BarcodeDetector({ formats:["qr_code"] }) } catch {}
}
function parseOtpAuth(uri) {
const u = new URL(uri)
if (u.protocol !== "otpauth:" || u.hostname.toLowerCase() !== "totp") throw new Error("Not an otpauth TOTP URL")
const label = decodeURIComponent(u.pathname.replace(/^\/+/,""))
const params = u.searchParams
const secret = (params.get("secret") || "").replace(/\s+/g,"")
const issuer = params.get("issuer") || ""
let account = label
const sep = label.indexOf(":")
if (sep !== -1) {
const maybeIssuer = label.slice(0,sep).trim()
const maybeAcc = label.slice(sep+1).trim()
if (!issuer && maybeIssuer) return { secret, issuer: maybeIssuer, account: maybeAcc }
account = maybeAcc
}
return { secret, issuer, account }
}
// -------- UI Components (unstyled) --------
function Form() {
const inputSite = $.input({ placeholder:"domain e.g., github.com", size:40 })
const inputUser = $.input({ placeholder:"username or email", size:40 })
const inputPass = $.input({ type:"password", placeholder:"password", size:40 })
const inputCode = $.input({ type:"password", placeholder:"TOTP secret (Base32)", size:40 })
const inputCustom = $.textarea({ rows:4, cols:60, placeholder:'{"note":"optional JSON"}' })
// QR dialog bits
/** @type {MediaStream | null} */ let stream = null
const video = $.video({ autoplay:true, muted:true, playsinline:true, width:320, height:240 })
const file = $.input({ type:"file", accept:"image/*", style:"display:none" })
async function startScan() {
if (!detector) {
alert("QR scanning not supported here. Try 'Upload Image' or paste the Base32 secret manually.")
return
}
try {
stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: "environment" }, audio: false })
video.srcObject = stream
await video.play()
scanLoop()
} catch (e) {
alert("Camera access failed. You can upload a screenshot of the QR or paste the secret.")
}
}
async function stopScan() {
if (video) video.pause()
if (stream) { stream.getTracks().forEach(t=>t.stop()); stream = null }
}
async function scanLoop() {
if (!detector || !video.isConnected || !video.srcObject) return
try {
const codes = await detector.detect(video)
if (codes && codes.length) {
await stopScan()
handleQrText(codes[0].rawValue)
(qrDialog).close()
return
}
} catch {}
requestAnimationFrame(scanLoop)
}
function handleQrText(text) {
try {
if (/^otpauth:/i.test(text)) {
const parsed = parseOtpAuth(text)
if (!parsed.secret) throw new Error("QR missing secret")
inputCode.value = parsed.secret
if (parsed.account && !inputUser.value) inputUser.value = parsed.account
if (parsed.issuer && !inputSite.value) inputSite.value = parsed.issuer.replace(/\s+/g,"").toLowerCase() + ".com"
alert(`Captured TOTP secret${parsed.issuer ? " ("+parsed.issuer+")" : ""}.`)
} else {
inputCode.value = text.trim()
alert("Captured QR text as secret.")
}
} catch (e) {
alert("Failed to parse QR: " + e.message)
}
}
file.onchange = async (e) => {
const f = e.target.files && e.target.files[0]
if (!f) return
if (!detector) { alert("QR detection not supported."); file.value=""; return }
const bitmap = await createImageBitmap(f)
const can = document.createElement("canvas")
can.width = bitmap.width; can.height = bitmap.height
can.getContext("2d").drawImage(bitmap,0,0)
try {
const codes = await detector.detect(can)
if (codes && codes.length) handleQrText(codes[0].rawValue)
else alert("No QR code detected in image.")
} catch (e2) {
alert("QR detect failed: " + e2.message)
}
file.value = ""
}
const qrDialog = $.dialog(
$.p("Scan a TOTP QR (otpauth://…)"),
video,
$.div(
$.button({ type:"button", onclick: startScan }, "Start camera"),
$.button({ type:"button", onclick: ()=>file.click() }, "Upload image"),
$.button({ type:"button", onclick: async ()=>{ await stopScan(); (qrDialog).close() } }, "Close")
)
)
return $.div(
$.h3("Add / Update Credential"),
$.form(
{ onsubmit(e) {
e.preventDefault()
const site = inputSite.value.trim()
if (!site) { alert("Domain is required"); return }
let custom = {}
const raw = (inputCustom.value || "").trim()
if (raw) {
try { custom = JSON.parse(raw) } catch { alert("Custom KVP must be valid JSON"); return }
}
const next = { ...vault.val }
/** @type {CredSet} */
const cred = [site, inputUser.value, inputPass.value, inputCode.value.trim(), custom]
next[site] = cred
vault.val = next
}
},
$.div($.label("Domain: "), inputSite),
$.div($.label("Username: "), inputUser),
$.div($.label("Password: "), inputPass, " ",
$.button({ type:"button", onclick(){ inputPass.type = inputPass.type==="password"?"text":"password" } }, "Show/Hide")
),
$.div($.label("TOTP Secret (Base32): "), inputCode, " ",
$.button({ type:"button", onclick(){ inputCode.type = inputCode.type==="password"?"text":"password" } }, "Show/Hide"),
" ",
$.button({ type:"button", onclick(){ (qrDialog).showModal() } }, "Scan QR")
),
$.div($.label("Custom KVP (JSON): "), $.br(), inputCustom),
$.div(
$.button({ type:"submit" }, "Add / Update"),
" ",
$.button({ type:"button", onclick(){
inputSite.value = ""; inputUser.value=""; inputPass.value=""; inputCode.value=""; inputCustom.value=""
}}, "Clear Form")
)
),
qrDialog,
file
)
}
function VaultList() {
return $.div(
$.h3("Vault"),
van.derive(() => {
const items = entries.val
if (!items.length) return $.p("(empty)")
return $.div(
...items.map(([domain, cred]) => Entry(domain, cred))
)
})
)
}
function Entry(domain, /** @type {CredSet} */ cred) {
const [_, username, password, secret, custom] = cred
const totp = ensureTotpState(domain)
const pwInput = $.input({ type:"password", value: password, size:40 })
return $.details(
$.summary(domain),
$.p($.strong("Username: "), username || ""),
$.p($.strong("Password: "), pwInput, " ",
$.button({ type:"button", onclick(){ pwInput.type = pwInput.type==="password"?"text":"password" } }, "Show/Hide"),
" ",
$.button({ type:"button", async onclick(){ await navigator.clipboard.writeText(pwInput.value || ""); alert("Password copied") } }, "Copy")
),
$.p($.strong("TOTP: "), van.derive(()=>totp.code.val || (secret ? "(…)" : "—")),
" ",
$.span(van.derive(()=> totp.ttl.val ? `(${totp.ttl.val}s)` : "")), " ",
$.button({ type:"button", disabled: !secret, async onclick(){
const code = totp.code.val
if (code) { await navigator.clipboard.writeText(code); alert("TOTP copied") }
} }, "Copy TOTP")
),
$.p($.strong("Secret(Base32): "), secret ? "••••••" : "—"),
$.p($.strong("Custom: "), JSON.stringify(custom || {})),
$.div(
$.button({ type:"button", onclick(){
// load into form (simple UX: fill the form at top)
const inputs = document.querySelectorAll("form input, form textarea")
/** poor-man targeting by placeholder text **/
const get = (sel)=>document.querySelector(sel)
const site = get('input[placeholder^="domain"]')
const user = get('input[placeholder^="username"]')
const pass = get('input[placeholder="password"]')
const code = get('input[placeholder^="TOTP"]')
const kvp = get('textarea')
site.value = domain
user.value = username
pass.value = password
code.value = secret
kvp.value = JSON.stringify(custom || {}, null, 2)
window.scrollTo({ top: 0, behavior: "smooth" })
}}, "Edit"),
" ",
$.button({ type:"button", onclick(){
if (!confirm(`Delete "${domain}"?`)) return
const next = { ...vault.val }
delete next[domain]
vault.val = next
}}, "Delete")
)
)
}
function CryptoPanel() {
const encPass = $.input({ type:"password", placeholder:"master password", size:40 })
const blobOut = $.textarea({ rows:10, cols:80, readonly:true })
const decPass = $.input({ type:"password", placeholder:"master password", size:40 })
const blobIn = $.textarea({ rows:10, cols:80, placeholder:"paste encrypted blob here" })
return $.div(
$.h3("Encrypt"),
$.div($.label("Master Password: "), encPass),
$.div($.button({ type:"button", async onclick(){
const pw = encPass.value
if (!pw) { alert("Enter a master password"); return }
try {
blobOut.value = "Encrypting..."
const blob = await encryptVault(pw)
blobOut.value = blob
} catch (e) {
blobOut.value = "Error: " + e.message
}
}}, "Encrypt Vault")),
$.div($.label("Encrypted Blob:"), $.br(), blobOut),
$.h3("Decrypt"),
$.div($.label("Master Password: "), decPass),
$.div($.label("Encrypted Blob:"), $.br(), blobIn),
$.div($.button({ type:"button", async onclick(){
const pw = decPass.value
const txt = (blobIn.value || "").trim()
if (!pw || !txt) { alert("Provide password and blob"); return }
try {
const obj = await decryptVault(pw, txt)
// validate and replace
/** @type {CredVault} */
const cleaned = {}
for (const [k,v] of Object.entries(obj)) {
if (Array.isArray(v) && v.length === 5) {
const [d,u,p,c,kv] = v
cleaned[String(k)] = [String(d||k), String(u||""), String(p||""), String(c||""), typeof kv==="object"&&kv?kv:{}]
}
}
vault.val = cleaned
alert("Decryption successful. Vault restored.")
} catch (e) {
alert("Decrypt failed: " + e.message)
}
}}, "Decrypt & Load")),
$.div($.button({ type:"button", onclick(){
if (!confirm("Clear in-memory vault?")) return
vault.val = {}
}}, "Wipe In-Memory Vault"))
)
}
function App() {
// TOTP ticker
setInterval(async () => {
const nowTtl = secondsToNextStep(30)
const entries = Object.entries(vault.val)
for (const [domain, [_d,_u,_p, secret]] of entries) {
const state = ensureTotpState(domain)
if (secret) {
state.ttl.val = nowTtl
const code = await generateTOTP(secret, 30, 6, "SHA-1")
state.code.val = code || ""
} else {
state.ttl.val = 0
state.code.val = ""
}
}
}, 500)
return $.div(
$.h2("CredVault (in-memory, VanJS)"),
$.p("No storage. Refreshing clears state. Use Encrypt/Decrypt below to backup/restore."),
Form(),
$.hr(),
VaultList(),
$.hr(),
CryptoPanel()
)
}
van.add(document.body, App)
</script>
</body>
</html>