This commit is contained in:
Seth Trowbridge 2026-03-10 13:58:00 -04:00
parent 3c6df0e32f
commit dcf77988c6
6 changed files with 669 additions and 1 deletions

10
app.html Normal file
View File

@ -0,0 +1,10 @@
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<script type="module" src="app.js"></script>
</body>
</html>

167
app.js Normal file
View File

@ -0,0 +1,167 @@
/// burn vanjs into codebase
{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;}
///
const $ = van.tags;
const writeKey = "vault";
const password = van.state("");
const encrypt =(secret)=>
{
const serialized = JSON.stringify(vault.val);
// todo add actual encryption here using secret
const encrypted = serialized+"|"+secret;
//
localStorage.setItem(writeKey, encrypted);
vaultSerialized.val = encrypted;
};
const decrypt =(secret)=>
{
try
{
const encrypted = localStorage.getItem(writeKey)||"";
vaultSerialized.val = encrypted;
// todo add actual decryption here using secret
const [decrypted, actual] = encrypted.split("|");
if(actual !== secret){return false;}
//
vault.val = JSON.parse(decrypted);
return true;
}
catch(e)
{
console.log("decrypt error", e);
return false;
}
};
/** @typedef {[domain:string, username:string, password:string, codegen:string, timestamp:number, custom_kvp:Record<string, string>]} CredSet */
const vault = van.state(/**@type{CredSet[]}*/([]));
const vaultSerialized = van.state("");
let attempts = 0;
const preexisting = van.state(localStorage.getItem(writeKey).length>1 ? true : false);
const accessError = van.state(false);
van.derive(()=>{
if(preexisting.val)
{
const success = decrypt(password.val);
if(success)
{
preexisting.val = false;
accessError.val = false;
}
else
{
if(attempts > 0)
{
accessError.val = true;
}
return;
}
}
encrypt(password.val);
console.log("password change");
});
/** @type {(credSet:CredSet)=>void} */
const vaultAdd =(credSet)=>{
const clone = [...vault.rawVal];
let i;
for(i=0; i<clone.length; i++)
{
if(credSet[0] < clone[i][0])
{
break;
}
}
clone.splice(i, 0, credSet);
vault.val = clone;
};
const Components = {
Form()
{
/** @type {(label:string, control:HTMLInputElement)=>HTMLFieldSetElement} */
const block =(label, control)=>
{
control.id = label;
return $.fieldset(
$.label({for:label}, label),
control
)
};
const inputSite = $.input();
const inputUser = $.input();
const inputPass = $.input();
const inputCode = $.input();
return $.form(
{
onsubmit(e){
e.preventDefault();
vaultAdd([inputSite.value, inputUser.value, inputPass.value, inputCode.value, new Date().getTime(), {}])
}
},
block("Site", inputSite),
block("User", inputUser),
block("Pass", inputPass),
block("Code", inputCode),
$.button("Add")
)
},
Root()
{
const input = $.input({id:"passwordField", value:password.val});
return $.div(
$.label({for:input.id}, "Password"),
()=>input,
$.strong(password.val),
$.button({onclick(){
attempts++;
password.val = input.value;
}}, "Set Password"),
$.hr(),
$.em(preexisting.val),
accessError.val ? $.strong("Password did not decrypt existing session.") : null,
preexisting.val ? $.div(
$.p("Existing session found. Unlock it with the right password, or destroy data."),
$.button({onclick(){attempts = 0; accessError.val = false; preexisting.val = false;}}, "Destroy Data")
) : null,
(!preexisting.val && password.val) ? Components.Vault() : null,
$.hr(),
$.pre(vaultSerialized.val)
);
},
Vault()
{
return $.div(
$.h2("Vault"),
Components.Form,
vault.val.map((cred)=>{
return $.details(
$.summary(cred[0]),
$.p(
$.strong("User Name:"),
cred[1]
)
)
})
);
}
}
van.add(document.body, Components.Root);

428
copilot.html Normal file
View File

@ -0,0 +1,428 @@
<!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>

15
deno.json Normal file
View File

@ -0,0 +1,15 @@
{
"compilerOptions": {
"checkJs": true,
"lib": [
"dom",
"dom.iterable",
"dom.asynciterable",
"deno.ns"
],
"types": ["./types.d.ts"]
},
"imports": {
"entry": "./app.js"
}
}

View File

@ -95,6 +95,7 @@
<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>
@ -129,7 +130,6 @@
<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>

48
types.d.ts vendored Normal file
View File

@ -0,0 +1,48 @@
export {}
declare global
{
namespace Van {
interface State<T> {
val: T
readonly oldVal: T
readonly rawVal: T
}
// Defining readonly view of State<T> for covariance.
// Basically we want StateView<string> to implement StateView<string | number>
type StateView<T> = Readonly<State<T>>
type Val<T> = State<T> | T
type Primitive = string | number | boolean | bigint
// deno-lint-ignore no-explicit-any
type PropValue = Primitive | ((e: any) => void) | null
type PropValueOrDerived = PropValue | StateView<PropValue> | (() => PropValue)
type Props = Record<string, PropValueOrDerived> & { class?: PropValueOrDerived; is?: string }
type PropsWithKnownKeys<ElementType> = Partial<{[K in keyof ElementType]: PropValueOrDerived}>
type ValidChildDomValue = Primitive | Node | null | undefined
type BindingFunc = ((dom?: Node) => ValidChildDomValue) | ((dom?: Element) => Element)
type ChildDom = ValidChildDomValue | StateView<Primitive | null | undefined> | BindingFunc | readonly ChildDom[]
type TagFunc<Result> = (first?: Props & PropsWithKnownKeys<Result> | ChildDom, ...rest: readonly ChildDom[]) => Result
type Tags = Readonly<Record<string, TagFunc<Element>>> & {
[K in keyof HTMLElementTagNameMap]: TagFunc<HTMLElementTagNameMap[K]>
}
}
const van:{
readonly state: <T>(initVal: T, HMRKey?:string)=> Van.State<T>
readonly derive: <T>(f: () => T) => Van.State<T>
readonly add: (dom: Element, ...children: readonly Van.ChildDom[]) => Element
readonly tags: Van.Tags & ((namespaceURI: string) => Readonly<Record<string, Van.TagFunc<Element>>>)
readonly hydrate: <T extends Node>(dom: T, f: (dom: T) => T | null | undefined) => T
};
}