Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 30 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,33 @@
# 🔐 PageCrypt - Password Protected Single Page Applications and HTML files

This fork adds the ability to encrypt entire directories to password-protect an entire static site hosted on a static site host, e.g. Amazon S3 or Google Cloud Storage or Github Pages.

It does so by caching the password entered the first time, to use it again for all other pages a user may visit, and caching the derived keys per file in localStorage to speed up decryption.

## Installation:

```sh
npm i -D https://github.com/souramoo/pagecrypt/releases/download/6.2.1/pagecrypt-6.2.1.tgz
```

## Usage for whole directories:

Assuming you have a directory `src/` to encrypt and an empty target directory `dist/`...

```sh
PASSWORD=hunter2
dir=$(pwd)
cd src
find . -name "*.html" -print -exec npx pagecrypt {} ${dir}/dist/{} ${PASSWORD} \;
cd ..
```

You should now be able to publish the contents of `dist/` :)

This should also work in cloud CI workflows to automatically password protect deployments.

# Original description

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

Inspired by [MaxLaumeister/PageCrypt](https://github.com/MaxLaumeister/PageCrypt), but rewritten to use native `Web Crypto API` and greatly improve UX + security. Thanks for sharing an excellent starting point to create this tool!
Expand All @@ -9,7 +37,7 @@ Inspired by [MaxLaumeister/PageCrypt](https://github.com/MaxLaumeister/PageCrypt
**NOTE: Make sure you are using Node.js v16 or newer.**

```sh
npm i -D pagecrypt
npm i -D https://github.com/souramoo/pagecrypt/releases/download/6.2.0/pagecrypt-6.2.0.tgz
```

There are 4 different ways to use `pagecrypt`:
Expand Down Expand Up @@ -175,7 +203,7 @@ Since this magic link feature is using the [URI Fragment](https://en.m.wikipedia

- Most importantly, think twice about what kinds of sites and apps you publish to the open internet, even if they are encrypted.
- If you use the magic link to login, beware that the password remains as a history entry! Feel free to submit a PR if you know a workaround for this!
- Also keep in mind that the `sessionStorage` saves the encryption key (which is derived from the password) until the browser is restarted. This is what allows the rapid page reloads during the same session - at the cost of decreasing the security on your local device.
- Also keep in mind that the `localStorage` saves the encryption key (which is derived from the password). This is what allows the rapid page reloads during the same session - at the cost of decreasing the security on your local device.
- Only share magic links via secure channels, such as E2E-encrypted chats and emails.
- `pagecrypt` only encrypts the contents of a single HTML file, so try to inline as much JS, CSS and other sensitive assets into this HTML file as possible. If you're unable to inline all sensitive assets, you can hide your other assets by placing them on another server, and then only reference the external resources within the `pagecrypt` protected HTML file instead. Of course, these could in turn be protected or hidden if you need to. If executed correctly, this allows you to completely hide what your webpage or app is about by only deploying a single HTML file to the public web. Neat!

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "pagecrypt",
"version": "6.1.1",
"version": "6.2.1",
"description": "Easily add client-side password-protection to your Single Page Applications and HTML files.",
"main": "src/index.ts",
"type": "module",
Expand Down
2 changes: 1 addition & 1 deletion src/decrypt-template.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow">
<title>Protected Page</title>
<script type="module">var i={};Object.defineProperty(i,"__esModule",{value:!0});function y(r,e,t){var c;if(t===void 0&&(t={}),!e.codes){e.codes={};for(var s=0;s<e.chars.length;++s)e.codes[e.chars[s]]=s}if(!t.loose&&r.length*e.bits&7)throw new SyntaxError("Invalid padding");for(var a=r.length;r[a-1]==="=";)if(--a,!t.loose&&!((r.length-a)*e.bits&7))throw new SyntaxError("Invalid padding");for(var o=new((c=t.out)!=null?c:Uint8Array)(a*e.bits/8|0),n=0,u=0,l=0,f=0;f<a;++f){var E=e.codes[r[f]];if(E===void 0)throw new SyntaxError("Invalid character "+r[f]);u=u<<e.bits|E,n+=e.bits,n>=8&&(n-=8,o[l++]=255&u>>n)}if(n>=e.bits||255&u<<8-n)throw new SyntaxError("Unexpected end of data");return o}function h(r,e,t){t===void 0&&(t={});for(var c=t,s=c.pad,a=s===void 0?!0:s,o=(1<<e.bits)-1,n="",u=0,l=0,f=0;f<r.length;++f)for(l=l<<8|255&r[f],u+=8;u>e.bits;)u-=e.bits,n+=e.chars[o&l>>u];if(u&&(n+=e.chars[o&l<<e.bits-u]),a)for(;n.length*e.bits&7;)n+="=";return n}var L={chars:"0123456789ABCDEF",bits:4},K={chars:"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567",bits:5},O={chars:"0123456789ABCDEFGHIJKLMNOPQRSTUV",bits:5},$={chars:"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/",bits:6},P={chars:"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_",bits:6},F={parse:function(e,t){return y(e.toUpperCase(),L,t)},stringify:function(e,t){return h(e,L,t)}},I={parse:function(e,t){return t===void 0&&(t={}),y(t.loose?e.toUpperCase().replace(/0/g,"O").replace(/1/g,"L").replace(/8/g,"B"):e,K,t)},stringify:function(e,t){return h(e,K,t)}},k={parse:function(e,t){return y(e,O,t)},stringify:function(e,t){return h(e,O,t)}},B={parse:function(e,t){return y(e,$,t)},stringify:function(e,t){return h(e,$,t)}},G={parse:function(e,t){return y(e,P,t)},stringify:function(e,t){return h(e,P,t)}},H={parse:y,stringify:h};i.base16=F;i.base32=I;i.base32hex=k;i.base64=B;i.base64url=G;i.codec=H;i.base16;i.base32;i.base32hex;const J=i.base64;i.base64url;i.codec;function b(r){const e=document.querySelector(r);if(e)return e;throw new Error(`No element found with selector: "${r}"`)}const d=b("input"),m=b("header"),j=b("#msg"),g=b("form"),v=b("#load");let N,D,M,T;document.addEventListener("DOMContentLoaded",async()=>{const r=b("pre[data-i]");if(!r.innerText){d.disabled=!0,S("No encrypted payload.");return}T=Number(r.dataset.i);const e=J.parse(r.innerText);if(N=e.slice(0,32),D=e.slice(32,32+16),M=e.slice(32+16),location.hash){const t=new URL(window.location.href);d.value=t.hash.slice(1),t.hash="",history.replaceState(null,"",t.toString())}sessionStorage.k||d.value?await U():(w(v),x(g),m.classList.replace("hidden","flex"),d.focus())});var A,C;const p=((A=window.crypto)==null?void 0:A.subtle)||((C=window.crypto)==null?void 0:C.webkitSubtle);p||(S("Please use a modern browser."),d.disabled=!0);function x(r){r.classList.remove("hidden")}function w(r){r.classList.add("hidden")}function S(r){j.innerText=r,m.classList.add("red")}g.addEventListener("submit",async r=>{r.preventDefault(),await U()});async function R(r){return new Promise(e=>setTimeout(e,r))}async function U(){v.lastElementChild.innerText="Decrypting...",w(m),w(g),x(v),await R(60);try{const r=await V({salt:N,iv:D,ciphertext:M,iterations:T},d.value);document.write(r),document.close()}catch(r){w(v),x(g),m.classList.replace("hidden","flex"),sessionStorage.k?sessionStorage.removeItem("k"):S("Wrong password."),d.value="",d.focus()}}async function q(r,e,t){const c=new TextEncoder,s=await p.importKey("raw",c.encode(e),"PBKDF2",!1,["deriveKey"]);return await p.deriveKey({name:"PBKDF2",salt:r,iterations:t,hash:"SHA-256"},s,{name:"AES-GCM",length:256},!0,["decrypt"])}async function Q(r){return p.importKey("jwk",r,"AES-GCM",!0,["decrypt"])}async function V({salt:r,iv:e,ciphertext:t,iterations:c},s){const a=new TextDecoder,o=sessionStorage.k?await Q(JSON.parse(sessionStorage.k)):await q(r,s,c),n=new Uint8Array(await p.decrypt({name:"AES-GCM",iv:e},o,t));if(!n)throw"Malformed data";return sessionStorage.k=JSON.stringify(await p.exportKey("jwk",o)),a.decode(n)}</script>
<script type="module">var c={};Object.defineProperty(c,"__esModule",{value:!0});function w(t,e,r){var o;if(r===void 0&&(r={}),!e.codes){e.codes={};for(var a=0;a<e.chars.length;++a)e.codes[e.chars[a]]=a}if(!r.loose&&t.length*e.bits&7)throw new SyntaxError("Invalid padding");for(var n=t.length;t[n-1]==="=";)if(--n,!r.loose&&!((t.length-n)*e.bits&7))throw new SyntaxError("Invalid padding");for(var l=new((o=r.out)!=null?o:Uint8Array)(n*e.bits/8|0),i=0,s=0,u=0,f=0;f<n;++f){var E=e.codes[t[f]];if(E===void 0)throw new SyntaxError("Invalid character "+t[f]);s=s<<e.bits|E,i+=e.bits,i>=8&&(i-=8,l[u++]=255&s>>i)}if(i>=e.bits||255&s<<8-i)throw new SyntaxError("Unexpected end of data");return l}function y(t,e,r){r===void 0&&(r={});for(var o=r,a=o.pad,n=a===void 0?!0:a,l=(1<<e.bits)-1,i="",s=0,u=0,f=0;f<t.length;++f)for(u=u<<8|255&t[f],s+=8;s>e.bits;)s-=e.bits,i+=e.chars[l&u>>s];if(s&&(i+=e.chars[l&u<<e.bits-s]),n)for(;i.length*e.bits&7;)i+="=";return i}var L={chars:"0123456789ABCDEF",bits:4},I={chars:"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567",bits:5},O={chars:"0123456789ABCDEFGHIJKLMNOPQRSTUV",bits:5},A={chars:"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/",bits:6},K={chars:"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_",bits:6},F={parse:function(e,r){return w(e.toUpperCase(),L,r)},stringify:function(e,r){return y(e,L,r)}},B={parse:function(e,r){return r===void 0&&(r={}),w(r.loose?e.toUpperCase().replace(/0/g,"O").replace(/1/g,"L").replace(/8/g,"B"):e,I,r)},stringify:function(e,r){return y(e,I,r)}},G={parse:function(e,r){return w(e,O,r)},stringify:function(e,r){return y(e,O,r)}},H={parse:function(e,r){return w(e,A,r)},stringify:function(e,r){return y(e,A,r)}},J={parse:function(e,r){return w(e,K,r)},stringify:function(e,r){return y(e,K,r)}},j={parse:w,stringify:y};c.base16=F;c.base32=B;c.base32hex=G;c.base64=H;c.base64url=J;c.codec=j;c.base16;c.base32;c.base32hex;const R=c.base64;c.base64url;c.codec;function h(t){const e=document.querySelector(t);if(e)return e;throw new Error(`No element found with selector: "${t}"`)}const d=h("input"),m=h("header"),q=h("#msg"),g=h("form"),b=h("#load");let U,M,N,D;document.addEventListener("DOMContentLoaded",async()=>{const t=h("pre[data-i]");if(!t.innerText){d.disabled=!0,x("No encrypted payload.");return}D=Number(t.dataset.i);const e=R.parse(t.innerText);U=e.slice(0,32),M=e.slice(32,32+16),N=e.slice(32+16);const r=localStorage.getItem("pwd");if(r&&(d.value=r),location.hash){const o=new URL(window.location.href);d.value=o.hash.slice(1),o.hash="",history.replaceState(null,"",o.toString())}localStorage.getItem(window.location.href)||d.value?await T():(v(b),S(g),m.classList.replace("hidden","flex"),d.focus())});var C,P;const p=((C=window.crypto)==null?void 0:C.subtle)||((P=window.crypto)==null?void 0:P.webkitSubtle);p||(x("Please use a modern browser."),d.disabled=!0);function S(t){t.classList.remove("hidden")}function v(t){t.classList.add("hidden")}function x(t){q.innerText=t,m.classList.add("red")}g.addEventListener("submit",async t=>{t.preventDefault(),await T()});async function Q(t){return new Promise(e=>setTimeout(e,t))}async function T(){b.lastElementChild.innerText="Decrypting...",v(m),v(g),S(b),await Q(60);try{const t=await W({salt:U,iv:M,ciphertext:N,iterations:D},d.value);document.write(t),document.close()}catch(t){v(b),S(g),m.classList.replace("hidden","flex");let e=!1;localStorage.getItem("pwd")&&(localStorage.removeItem("pwd"),e=!0),localStorage.getItem(window.location.href)&&(localStorage.removeItem(window.location.href),e=!0),e||x("Wrong password."),d.value="",d.focus()}}async function $(t,e,r){const o=new TextEncoder,a=await p.importKey("raw",o.encode(e),"PBKDF2",!1,["deriveKey"]);return await p.deriveKey({name:"PBKDF2",salt:t,iterations:r,hash:"SHA-256"},a,{name:"AES-GCM",length:256},!0,["decrypt"])}async function V(t){return p.importKey("jwk",t,"AES-GCM",!0,["decrypt"])}async function W({salt:t,iv:e,ciphertext:r,iterations:o},a){const n=new TextDecoder;let l=localStorage.getItem(window.location.href),i=null,s=new Uint8Array;try{if(i=l?await V(JSON.parse(l)):await $(t,a,o),s=new Uint8Array(await p.decrypt({name:"AES-GCM",iv:e},i,r)),!s)throw"Malformed data"}catch(u){localStorage.removeItem(window.location.href),i=await $(t,a,o),s=new Uint8Array(await p.decrypt({name:"AES-GCM",iv:e},i,r))}return localStorage.setItem(window.location.href,JSON.stringify(await p.exportKey("jwk",i))),localStorage.setItem("pwd",a),n.decode(s)}</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}img,video{max-width:100%;height:auto}[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>
Expand Down
3 changes: 3 additions & 0 deletions test/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,8 @@
"test:cli-iterations": "pagecrypt test.html out-cli-iterations.html eRx1sD0LrHTNubycv1IYgyNqU3Qc9GKPGcl3XT63JG7djgMxU9etkVNcK5Hak5GWDzm4mx6AQFlpOPsY --iterations 3e6",
"test:cli-gen-iterations": "pagecrypt test.html out-cli-gen-iterations.html eRx1sD0LrHTNubycv1IYgyNqU3Qc9GKPGcl3XT63JG7djgMxU9etkVNcK5Hak5GWDzm4mx6AQFlpOPsY --generate-password 59 --iterations 2500000",
"test:verify": "vite"
},
"dependencies": {
"pagecrypt": "file:../dist/pagecrypt-6.2.1.tgz"
}
}
55 changes: 43 additions & 12 deletions web/decrypt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ document.addEventListener('DOMContentLoaded', async () => {
iv = bytes.slice(32, 32 + 16)
ciphertext = bytes.slice(32 + 16)

const pwdOld = localStorage.getItem('pwd')
if (pwdOld) {
pwd.value = pwdOld
}
/**
* Allow passwords to be automatically provided via the URI Fragment.
* This greatly improves UX by clicking links instead of having to copy and paste the password manually.
Expand All @@ -44,7 +48,7 @@ document.addEventListener('DOMContentLoaded', async () => {
history.replaceState(null, '', url.toString())
}

if (sessionStorage.k || pwd.value) {
if (localStorage.getItem(window.location.href) || pwd.value) {
await decrypt()
} else {
hide(load)
Expand Down Expand Up @@ -107,10 +111,20 @@ async function decrypt() {
show(form)
header.classList.replace('hidden', 'flex')

if (sessionStorage.k) {
let automatic = false

if (localStorage.getItem('pwd')) {
// Delete invalid password
localStorage.removeItem('pwd')
automatic = true
}
if (localStorage.getItem(window.location.href)) {
// Delete invalid key
sessionStorage.removeItem('k')
} else {
localStorage.removeItem(window.location.href)
automatic = true
}

if (!automatic) {
// Only show when user actually entered a password themselves.
error('Wrong password.')
}
Expand Down Expand Up @@ -162,17 +176,34 @@ async function decryptFile(
) {
const decoder = new TextDecoder()

const key = sessionStorage.k
? await importKey(JSON.parse(sessionStorage.k))
: await deriveKey(salt, password, iterations)
let k = localStorage.getItem(window.location.href)
let key: CryptoKey | null
let data = new Uint8Array()

const data = new Uint8Array(
await subtle.decrypt({ name: 'AES-GCM', iv }, key, ciphertext),
)
if (!data) throw 'Malformed data'
try {
key = k
? await importKey(JSON.parse(k))
: await deriveKey(salt, password, iterations)
data = new Uint8Array(
await subtle.decrypt({ name: 'AES-GCM', iv }, key, ciphertext),
)
if (!data) throw 'Malformed data'
} catch (e) {
// Delete invalid key and try a saved password
localStorage.removeItem(window.location.href)
key = await deriveKey(salt, password, iterations)

data = new Uint8Array(
await subtle.decrypt({ name: 'AES-GCM', iv }, key, ciphertext),
)
}

// If no exception were thrown, decryption succeded and we can save the key.
sessionStorage.k = JSON.stringify(await subtle.exportKey('jwk', key))
localStorage.setItem(
window.location.href,
JSON.stringify(await subtle.exportKey('jwk', key)),
)
localStorage.setItem('pwd', password)

return decoder.decode(data)
}