UNPKG

pagecrypt

Version:

Easily add client-side password-protection to your Single Page Applications, static websites and HTML files.

93 lines (89 loc) 7.72 kB
// src/base64.ts function stringify(data) { let str = ""; for (let i = 0; i < data.length; i++) { str += String.fromCharCode(data[i]); } return btoa(str); } // src/crypto.ts async function loadCrypto() { if (globalThis?.crypto) { return globalThis.crypto; } else if (window?.crypto) { return window.crypto; } else { return (await import("crypto")).webcrypto; } } var crypto = await loadCrypto(); var crypto_default = crypto; // src/decrypt-template.html var decrypt_template_default = '<!DOCTYPE html><html><head><meta charset=utf-8><meta name=viewport content="width=device-width,initial-scale=1"><meta name=robots content="noindex, nofollow"><title>Protected Page</title><script type=module>function K(e){return Uint8Array.from(atob(e),r=>r.charCodeAt(0))}function d(e){const r=document.querySelector(e);if(r)return r;throw new Error(`No element found with selector: "${e}"`)}const s=d("input"),f=d("header"),E=d("#msg"),p=d("form"),l=d("#load");let v,S,b,L;document.addEventListener("DOMContentLoaded",async()=>{const e=d("pre[data-i]");if(!e.innerText){s.disabled=!0,m("No encrypted payload.");return}L=Number(e.dataset.i);const r=K(e.innerText);if(v=r.slice(0,32),S=r.slice(32,48),b=r.slice(48),location.hash){const o=location.href.split("#");s.value=o[1],history.replaceState(null,"",o[0])}sessionStorage.k||s.value?await x():(u(l),y(p),f.classList.replace("hidden","flex"),s.focus())});var h,g;const c=((h=window.crypto)==null?void 0:h.subtle)||((g=window.crypto)==null?void 0:g.webkitSubtle);c||(m("SubtleCrypto is missing"),s.disabled=!0);function y(e){e.classList.remove("hidden")}function u(e){e.classList.add("hidden")}function m(e){E.innerText=e,f.classList.add("red")}p.addEventListener("submit",async e=>{e.preventDefault(),await x()});async function O(e){return new Promise(r=>setTimeout(r,e))}async function x(){l.lastElementChild.innerText="Decrypting...",u(f),u(p),y(l),await O(60);try{const e=await C({salt:v,iv:S,ciphertext:b,iterations:L},s.value);document.write(e),document.close()}catch(e){u(l),y(p),f.classList.replace("hidden","flex"),sessionStorage.k?sessionStorage.removeItem("k"):m("Wrong password."),s.value="",s.focus()}}async function A(e,r,o){const i=new TextEncoder,t=await c.importKey("raw",i.encode(r),"PBKDF2",!1,["deriveKey"]);return await c.deriveKey({name:"PBKDF2",salt:e,iterations:o,hash:"SHA-256"},t,{name:"AES-GCM",length:256},!0,["decrypt"])}async function N(e){return c.importKey("jwk",e,"AES-GCM",!0,["decrypt"])}async function C({salt:e,iv:r,ciphertext:o,iterations:i},t){const n=new TextDecoder,a=sessionStorage.k?await N(JSON.parse(sessionStorage.k)):await A(e,t,i),w=new Uint8Array(await c.decrypt({name:"AES-GCM",iv:r},a,o));if(!w)throw"Malformed data";return sessionStorage.k=JSON.stringify(await c.exportKey("jwk",a)),n.decode(w)}</script><style>*,:before,:after{box-sizing:border-box;border:0}html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,Noto Sans,sans-serif}body{margin:0;line-height:inherit}a{color:inherit;text-decoration:inherit}button,input{font-family:inherit;font-size:100%;line-height:inherit;color:inherit;margin:0;padding:0}button{text-transform:none}button,[type=button],[type=reset],[type=submit]{-webkit-appearance:button}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}p{margin:0}input::-moz-placeholder,input:-ms-input-placeholder,input::placeholder{opacity:1}:disabled{cursor:default}svg{display:block;vertical-align:middle}[hidden]{display:none}:root{--gray-800: #292524;--gray-700: #433f3b}html,body{color:#fff;font-weight:300}main{background:#000;height:100vh;letter-spacing:.025em;padding:4rem 1rem 1rem}.box{max-width:24rem;width:100%;background:var(--gray-800);padding:1rem;border-radius:.125rem;margin:0 auto;height:170px}header{align-items:center;margin-bottom:1rem;gap:.5rem}#pwd{font-weight:200;border-radius:.125rem;background:var(--gray-800);border:1px solid var(--gray-700);padding:.5rem 1rem;width:100%;color:#fff}#pwd:focus{outline:2px solid transparent;outline-offset:2px}.hidden{display:none!important}.flex{display:flex}#load{display:flex;align-items:center;justify-content:center;height:100%}.red{color:#dc2626}.spinner{pointer-events:none;width:1.5rem;height:1.5rem;border:3px solid transparent;border-color:#fff;border-right-width:2px;border-radius:50%;-webkit-animation:spin .5s linear infinite;animation:spin .5s linear infinite;margin-right:.5rem}#load p:last-child{font-size:1.125rem;line-height:1.75rem}[type=submit]{border-radius:.125rem;color:#000;background:#fff;width:100%;padding:.5rem 0;margin-top:1rem;cursor:pointer}@keyframes spin{to{transform:rotate(360deg)}}#locked{width:1.5rem;height:1.5rem}#msg{font-size:.875rem;line-height:1.25rem}@media (min-width: 475px){main{padding-top:10rem}#msg,a{font-size:1rem;line-height:1.5rem}}</style></head><body><main><div class=box><div id=load><p class=spinner></p><p>Loading...</p></div><header class=hidden><svg id=locked fill=currentColor viewBox="0 0 20 20" xmlns=http://www.w3.org/2000/svg><path fill-rule=evenodd d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z" clip-rule=evenodd></path></svg><p id=msg>This page is password protected.</p></header><form class=hidden><input type=password id=pwd name=pwd aria-label=Password autofocus> <input type=submit value=Submit></form></div></main><encrypted-payload></encrypted-payload></body></html>'; // src/core.ts async function getEncryptedPayload(content, password, iterations) { if (iterations < 2e6) { console.warn( `[pagecrypt] WARNING: The specified number of password iterations (${iterations}) is not secure. If possible, use at least 2_000_000 or more.` ); } const encoder = new TextEncoder(); const salt = crypto_default.getRandomValues(new Uint8Array(32)); const baseKey = await crypto_default.subtle.importKey( "raw", encoder.encode(password), "PBKDF2", false, ["deriveKey"] ); const key = await crypto_default.subtle.deriveKey( { name: "PBKDF2", salt, iterations, hash: "SHA-256" }, baseKey, { name: "AES-GCM", length: 256 }, false, ["encrypt"] ); const iv = crypto_default.getRandomValues(new Uint8Array(16)); const ciphertext = new Uint8Array( await crypto_default.subtle.encrypt( { name: "AES-GCM", iv }, key, encoder.encode(content) ) ); const totalLength = salt.length + iv.length + ciphertext.length; const mergedData = new Uint8Array(totalLength); mergedData.set(salt); mergedData.set(iv, salt.length); mergedData.set(ciphertext, salt.length + iv.length); return stringify(mergedData); } async function encryptHTML(inputHTML, password, iterations = 2e6) { return decrypt_template_default.replace( "<encrypted-payload></encrypted-payload>", `<pre class="hidden" data-i="${iterations.toExponential()}">${await getEncryptedPayload( inputHTML, password, iterations )}</pre>` ); } function generatePassword(length = 80, characters = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz") { if (characters.length > 255) { throw new Error("[pagecrypt] Max character set length is 255"); } return Array.from({ length }, (_) => getRandomCharacter(characters)).join( "" ); } function getRandomCharacter(characters) { let randomNumber; do { randomNumber = crypto_default.getRandomValues(new Uint8Array(1))[0]; } while (randomNumber >= 256 - 256 % characters.length); return characters[randomNumber % characters.length]; } export { encryptHTML, generatePassword };