idk
This commit is contained in:
parent
3c6df0e32f
commit
dcf77988c6
10
app.html
Normal file
10
app.html
Normal 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
167
app.js
Normal 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
428
copilot.html
Normal 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
15
deno.json
Normal file
@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"checkJs": true,
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"dom.asynciterable",
|
||||
"deno.ns"
|
||||
],
|
||||
"types": ["./types.d.ts"]
|
||||
},
|
||||
"imports": {
|
||||
"entry": "./app.js"
|
||||
}
|
||||
}
|
||||
@ -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
48
types.d.ts
vendored
Normal 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
|
||||
};
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user