diff --git a/static/api.js b/static/api.js new file mode 100644 index 0000000..1a2b00d --- /dev/null +++ b/static/api.js @@ -0,0 +1,52 @@ +/** Upload a password to the server + * @param {string} password Encryptes password + * @param {Promise} expiresIn Number of seconds in which the password will expire + */ +async function uploadPassword(password, expiresIn) { + const res = await fetch("/api/password", { + method: "POST", + body: JSON.stringify({ + password: password, + "expires-in": expiresIn, + }), + }); + if (!res.ok) { + const msg = await res.text(); + throw new Error(`Failed to upload password: ${res.status}: ${msg}`); + } + + const json = await res.json(); + return json.id; +} + +/** Check if the server knows about the password + * @param {string} id Password ID + * @returns {Promise} + */ +async function hasPassword(id) { + const res = await fetch(`/api/password/${id}`, { + method: "HEAD", + }); + if (res.status == 404) { + return false; + } + if (res.status == 204) { + return true; + } + + throw new Error(`Failed to check if password exists: ${res.status}: ${msg}`); +} +/** Get a password from the server + * @param {string} id Password ID + * @returns {Promise} + */ +async function getPassword(id) { + const res = await fetch(`/api/password/${id}`); + if (!res.ok) { + const msg = await res.text(); + throw new Error(`Failed to get password: ${res.status}: ${msg}`); + } + + const json = await res.json(); + return json.password; +} diff --git a/static/crypto.js b/static/crypto.js new file mode 100644 index 0000000..6727698 --- /dev/null +++ b/static/crypto.js @@ -0,0 +1,108 @@ +const ALGORITHM_NAME = "AES-GCM"; +const ALGORITHM_LENGTH = 256; +const IV_LENGTH = 16; + +const TEXT_ENCODER = new TextEncoder(); +const TEXT_DECODER = new TextDecoder(); + +/** Coverts an ArrayBuffer into a Base64 encoded string + * @param {ArrayBuffer} buffer + * @returns {string} + */ +function bufferToBase64(buffer) { + let binary = ""; + const bytes = new Uint8Array(buffer); + const len = bytes.byteLength; + for (let i = 0; i < len; i++) { + binary += String.fromCharCode(bytes[i]); + } + + return btoa(binary); +} + +/** Converts a Base64 encoded string into an ArrayBuffer + * @param {string} base64 + * @returns {ArrayBuffer} + */ +function base64ToBuffer(base64) { + const binaryString = atob(base64); + const len = binaryString.length; + const bytes = new Uint8Array(len); + for (let i = 0; i < len; i++) { + bytes[i] = binaryString.charCodeAt(i); + } + + return bytes.buffer; +} + +/** Generates an key and encrypts "password" using it + * @param {Promise} password + */ +async function encryptPassword(password) { + const iv = new Uint8Array(IV_LENGTH); + window.crypto.getRandomValues(iv); + + const key = await window.crypto.subtle.generateKey( + { + name: ALGORITHM_NAME, + length: ALGORITHM_LENGTH, + }, + true, + ["encrypt", "decrypt"], + ); + + const encryptedPassword = await window.crypto.subtle.encrypt( + { + name: ALGORITHM_NAME, + iv: iv, + }, + key, + TEXT_ENCODER.encode(password), + ); + + const exportedKey = await window.crypto.subtle.exportKey("raw", key); + + const encodedPassword = bufferToBase64(encryptedPassword); + const encodedKey = bufferToBase64(exportedKey); + const encodedIv = bufferToBase64(iv.buffer); + + return { + password: encodedPassword, + key: encodedKey, + iv: encodedIv, + }; +} + +/** Decodes to key and iv and decryptes the password + * @param {string} password + * @param {string} key + * @param {string} iv + * @returns {Promise} + */ +async function decryptPassword(password, key, iv) { + const decodedPassword = base64ToBuffer(password); + const decodedKey = base64ToBuffer(key); + const decodedIv = base64ToBuffer(iv); + + const cryptoKey = await window.crypto.subtle.importKey( + "raw", + decodedKey, + { + name: ALGORITHM_NAME, + length: ALGORITHM_LENGTH, + }, + true, + ["encrypt", "decrypt"], + ); + + const decryptedPassword = await window.crypto.subtle.decrypt( + { + name: ALGORITHM_NAME, + iv: decodedIv, + }, + cryptoKey, + decodedPassword, + ); + + return TEXT_DECODER.decode(decryptedPassword); +} diff --git a/static/index.html b/static/index.html index ff74b67..94e4025 100644 --- a/static/index.html +++ b/static/index.html @@ -7,11 +7,11 @@ PassED + + - -
- -
-
Enter your password
+
Enter Password
-
+
- - -
-
Password URL
- -
- - -
- - -
-
- - -
-
View Password
- -

- You may only reveal the password once. -

- -
-
- - -
-
Password
- - -
-
- - -
-
Loading...
-
-
- - -
-
Error
- - - -
- -
-
-
- - -
-
Password does not exist
- -

- The password you requested may have expired, been viewed - before or never even existed in the first place. -

- -
- -
-
-
+ + +
+
Password URL
+ +
+ + +
+ +
+ +
+
+
+ + +
+
View Password
+ + + +
+ +
+
+
+ + +
+
View Password
+ +

You may only reveal the password once.

+ +
+ + +
+
+
+ + +
+
Password does not exist
+ +

+ The password you requested may have expired, been viewed + before or never even existed in the first place. +

+ +
+ +
+
+
+ + +

Loading...

+
+ + +
+
Error
+ + + + +
+
diff --git a/static/index.js b/static/index.js index 5b4c9ae..910827f 100644 --- a/static/index.js +++ b/static/index.js @@ -1,269 +1,199 @@ -/* -Welcome to the magic behind PassED! -This file is responsible for encrypting passwords in the browser and to only send encrypted passwords to the server. - -- https://git.1e99.eu/1e99/passed -- https://discord.gg/NuGxJKtDKS -*/ - -const ALGORITHM_NAME = "AES-GCM"; -const ALGORITHM_LENGTH = 256; -const IV_LENGTH = 16; - -const TEXT_ENCODER = new TextEncoder(); -const TEXT_DECODER = new TextDecoder(); - -const ACTIVE_PAGE = "page-active"; - -/** Coverts an ArrayBuffer into a Base64 encoded string - * @param {ArrayBuffer} buffer - * @returns {string} - */ -function bufferToBase64(buffer) { - let binary = ""; - const bytes = new Uint8Array(buffer); - const len = bytes.byteLength; - for (let i = 0; i < len; i++) { - binary += String.fromCharCode(bytes[i]); - } - - return btoa(binary); -} - -/** Converts a Base64 encoded string into an ArrayBuffer - * @param {string} base64 - * @returns {ArrayBuffer} - */ -function base64ToBuffer(base64) { - const binaryString = atob(base64); - const len = binaryString.length; - const bytes = new Uint8Array(len); - for (let i = 0; i < len; i++) { - bytes[i] = binaryString.charCodeAt(i); - } - - return bytes.buffer; -} - -/** Generates an key and encrypts "password" using it - * @param {string} password - */ -async function encryptPassword(password) { - const iv = new Uint8Array(IV_LENGTH); - window.crypto.getRandomValues(iv); - - const key = await window.crypto.subtle.generateKey( - { - name: ALGORITHM_NAME, - length: ALGORITHM_LENGTH, - }, - true, - ["encrypt", "decrypt"], - ); - - const encryptedPassword = await window.crypto.subtle.encrypt( - { - name: ALGORITHM_NAME, - iv: iv, - }, - key, - TEXT_ENCODER.encode(password), - ); - - const exportedKey = await window.crypto.subtle.exportKey("raw", key); - - const encodedPassword = bufferToBase64(encryptedPassword); - const encodedKey = bufferToBase64(exportedKey); - const encodedIv = bufferToBase64(iv.buffer); - - return { - password: encodedPassword, - key: encodedKey, - iv: encodedIv, - }; -} - -/** Decodes to key and iv and decryptes the password - * @param {string} password - * @param {string} key - * @param {string} iv - */ -async function decryptPassword(password, key, iv) { - const decodedPassword = base64ToBuffer(password); - const decodedKey = base64ToBuffer(key); - const decodedIv = base64ToBuffer(iv); - - const cryptoKey = await window.crypto.subtle.importKey( - "raw", - decodedKey, - { - name: ALGORITHM_NAME, - length: ALGORITHM_LENGTH, - }, - true, - ["encrypt", "decrypt"], - ); - - const decryptedPassword = await window.crypto.subtle.decrypt( - { - name: ALGORITHM_NAME, - iv: decodedIv, - }, - cryptoKey, - decodedPassword, - ); - - return TEXT_DECODER.decode(decryptedPassword); -} - -function showError(error) { - const errorDialog = document.querySelector("dialog#error"); - const errorMessage = document.querySelector("textarea#error-message"); - - errorMessage.value = error; - errorDialog.showModal(); - console.error(error); -} - -function init() { - const passwordForm = document.querySelector("form#password"); - const loadingDialog = document.querySelector("dialog#loading"); - const urlDialog = document.querySelector("dialog#url"); - const urlCopy = document.querySelector("button#url-copy"); - const passwordUrl = document.querySelector("input#password-url"); - const shareAnother = document.querySelector("button#share-another"); - - passwordForm.addEventListener("submit", async (ev) => { - try { - loadingDialog.showModal(); +const loadingDialog = { + dialog: document.querySelector("dialog#loading-dialog"), + init() { + this.dialog.addEventListener("cancel", (ev) => { ev.preventDefault(); - const data = new FormData(ev.target); - - const encrypted = await encryptPassword(data.get("password")); - - const res = await fetch("/api/password", { - method: "POST", - body: JSON.stringify({ - password: encrypted.password, - "expires-in": parseInt(data.get("expires-in")), - }), - }); - if (!res.ok) { - const msg = await res.text(); - throw new Error(`Failed to upload password: ${res.status}: ${msg}`); - } - - const json = await res.json(); - - const params = new URLSearchParams(); - params.set("id", json.id); - params.set("key", encrypted.key); - params.set("iv", encrypted.iv); - - const url = new URL(window.location); - url.search = params.toString(); - passwordUrl.value = url.toString(); - - urlDialog.showModal(); - passwordUrl.select(); - } catch (error) { - showError(error); - } finally { - loadingDialog.close(); - } - }); - - loadingDialog.addEventListener("close", (ev) => ev.preventDefault()); - shareAnother.addEventListener("click", (ev) => { - passwordForm.reset(); - passwordUrl.value = ""; - urlDialog.close(); - }); - urlCopy.addEventListener("click", async (ev) => { - passwordUrl.select(); - passwordUrl.setSelectionRange(0, 99999); - await navigator.clipboard.writeText(passwordUrl.value); - }); -} - -async function confirmViewPassword(params) { - const confirmDialog = document.querySelector("dialog#confirm-view"); - const confirm = document.querySelector("button#confirm-view"); - const loadingDialog = document.querySelector("dialog#loading"); - const passwordNEDialog = document.querySelector("dialog#password-ne"); - - try { - loadingDialog.showModal(); - - const res = await fetch(`/api/password/${params.get("id")}`, { - method: "HEAD", }); - console.log(res.status); - if (res.status == 404) { - loadingDialog.close(); - passwordNEDialog.showModal(); - console.log("Return"); + }, + show() { + this.dialog.showModal(); + }, + close() { + this.dialog.close(); + }, +}; +const errorDialog = { + dialog: document.querySelector("dialog#error-dialog"), + error: document.querySelector("textarea#error"), + reload: document.querySelector("button#error-reload"), + init() { + this.dialog.addEventListener("close", (ev) => { + window.location.reload(); + }); + + this.reload.addEventListener("click", (ev) => { + window.location.href = "/"; + }); + }, + show(err) { + this.error.value = err; + console.error(err); + this.dialog.showModal(); + }, +}; + +const urlDialog = { + dialog: document.querySelector("dialog#url-dialog"), + url: document.querySelector("input#url"), + urlCopy: document.querySelector("button#url-copy"), + close: document.querySelector("button#url-close"), + init() { + this.dialog.addEventListener("close", (ev) => {}); + + this.urlCopy.addEventListener("click", (ev) => { + this.url.select(); + this.url.setSelectionRange(0, 99999); + navigator.clipboard.writeText(this.url.value); + }); + + this.close.addEventListener("click", (ev) => { + this.dialog.close(); + }); + }, + show(url) { + this.url.value = url; + this.dialog.showModal(); + }, +}; + +const notFoundDialog = { + dialog: document.querySelector("dialog#not-found"), + close: document.querySelector("button#not-found-close"), + init() { + this.dialog.addEventListener("close", (ev) => { + window.location.search = ""; + }); + + this.close.addEventListener("click", (ev) => { + window.location.search = ""; + }); + }, + show() { + this.dialog.showModal(); + }, +}; + +const viewDialog = { + dialog: document.querySelector("dialog#view-dialog"), + password: document.querySelector("textarea#view-password"), + close: document.querySelector("button#view-close"), + init() { + this.dialog.addEventListener("close", (ev) => { + window.location.search = ""; + }); + + this.close.addEventListener("click", (ev) => { + this.dialog.close(); + }); + }, + show(password) { + this.password.value = password; + this.dialog.showModal(); + }, +}; + +const confirmViewDialog = { + dialog: document.querySelector("dialog#confirm-view-dialog"), + cancel: document.querySelector("button#view-cancel"), + confirm: document.querySelector("button#view-confirm"), + resolve: null, + reject: null, + init() { + this.dialog.addEventListener("cancel", (ev) => { + this.dialog.close(); + this.resolve(false); + }); + + this.cancel.addEventListener("click", (ev) => { + this.dialog.close(); + this.resolve(false); + }); + + this.confirm.addEventListener("click", (ev) => { + this.dialog.close(); + this.resolve(true); + }); + }, + show() { + return new Promise((resolve, reject) => { + this.resolve = resolve; + this.reject = reject; + this.dialog.showModal(); + }); + }, +}; + +async function viewPassword() { + try { + loadingDialog.show(); + + const params = new URLSearchParams(window.location.search); + const id = params.get("id"); + const key = params.get("key"); + const iv = params.get("iv"); + + const exists = await hasPassword(id); + if (!exists) { + notFoundDialog.show(); return; } - if (!res.ok) { - const msg = await res.text(); - throw new Error( - `Failed to check if password exists: ${res.status}: ${msg}`, - ); - } - } catch (error) { - showError(error); - } finally { - } - - loadingDialog.close(); - confirmDialog.showModal(); - - confirm.addEventListener("click", (ev) => { - confirmDialog.close(); - viewPassword(params); - }); -} - -async function viewPassword(params) { - const viewDialog = document.querySelector("dialog#view"); - const password = document.querySelector("textarea#password"); - const loadingDialog = document.querySelector("dialog#loading"); - - try { - loadingDialog.showModal(); - - const res = await fetch(`/api/password/${params.get("id")}`); - if (!res.ok) { - const msg = await res.text(); - throw new Error(`Failed to load password: ${res.status}: ${msg}`); + const shouldView = await confirmViewDialog.show(); + if (!shouldView) { + // This is needed for the redirect, otherwise the user won't get redirected + setTimeout(() => (window.location.search = ""), 0); + return; } - const json = await res.json(); + const encrypted = await getPassword(id); + const password = await decryptPassword(encrypted, key, iv); - const decrypted = await decryptPassword( - json.password, - params.get("key"), - params.get("iv"), - ); - - password.value = decrypted; - viewDialog.showModal(); + viewDialog.show(password); } catch (error) { - showError(error); + errorDialog.show(error); } finally { loadingDialog.close(); } } -window.addEventListener("load", () => { - const query = window.location.search; - if (query.trim() != "") { - const params = new URLSearchParams(window.location.search); - confirmViewPassword(params); - } +const enterPassword = document.querySelector("form#enter-password"); - init(); +enterPassword.addEventListener("submit", async (ev) => { + try { + loadingDialog.show(); + ev.preventDefault(); + const data = new FormData(ev.target); + + const password = await encryptPassword(data.get("password")); + const id = await uploadPassword( + password.password, + parseInt(data.get("expires-in")), + ); + + const params = new URLSearchParams(); + params.set("id", id); + params.set("key", password.key); + params.set("iv", password.iv); + + const url = new URL(window.location); + url.search = params.toString(); + urlDialog.show(url.toString()); + } catch (error) { + errorDialog.show(error); + } finally { + loadingDialog.close(); + } }); + +loadingDialog.init(); +errorDialog.init(); +urlDialog.init(); +notFoundDialog.init(); +viewDialog.init(); +confirmViewDialog.init(); + +const query = window.location.search; +if (query.trim() != "") { + viewPassword(); +}