From f6d70cf9ab622a58739d2dc939b8b1043b4791c5 Mon Sep 17 00:00:00 2001 From: rohan09-raj Date: Tue, 14 May 2024 21:45:00 +0530 Subject: feat: added end-to-end encryption --- client/public/assets/icons/ic_close.png | Bin 0 -> 534 bytes client/src/components/Editor/Editor.jsx | 164 ++++++++++++++++++++++----- client/src/components/Modal/Modal.jsx | 25 ++++ client/src/components/Modal/Modal.module.css | 61 ++++++++++ client/src/utils/encryption.js | 81 +++++++++++++ server/main.go | 9 +- 6 files changed, 310 insertions(+), 30 deletions(-) create mode 100644 client/public/assets/icons/ic_close.png create mode 100644 client/src/components/Modal/Modal.jsx create mode 100644 client/src/components/Modal/Modal.module.css create mode 100644 client/src/utils/encryption.js diff --git a/client/public/assets/icons/ic_close.png b/client/public/assets/icons/ic_close.png new file mode 100644 index 0000000..90d5759 Binary files /dev/null and b/client/public/assets/icons/ic_close.png differ diff --git a/client/src/components/Editor/Editor.jsx b/client/src/components/Editor/Editor.jsx index 868036f..e1675fc 100644 --- a/client/src/components/Editor/Editor.jsx +++ b/client/src/components/Editor/Editor.jsx @@ -4,11 +4,16 @@ import Prism from "prismjs"; import styles from "./Editor.module.css"; import "../prism-themes/prism-gruvbox-dark.css"; import "../prism-themes/prism-line-numbers.css"; -import { - BASE_URL, - URL_REGEX, -} from "../../utils/constants"; +import { BASE_URL, URL_REGEX } from "../../utils/constants"; import Header from "../Header/Header"; +import { + generateAESKey, + keyToString, + stringToKey, + encryptAES, + decryptAES, +} from "../../utils/encryption"; +import Modal from "../Modal/Modal"; const Editor = () => { const { id } = useParams(); @@ -16,8 +21,10 @@ const Editor = () => { const location = useLocation(); const [text, setText] = useState(""); const [language, setLanguage] = useState("none"); + const [openModal, setOpenModal] = useState(false); const textareaRef = useRef(null); const lineNumberRef = useRef(null); + const queryParams = new URLSearchParams(location.search); const handleTextChange = (event) => { setText(event.target.value); @@ -29,11 +36,56 @@ const Editor = () => { } }; - const handleClick = async () => { + const handleSaveClick = async () => { if (!text) { alert("Please enter some text!"); return; } + if (URL_REGEX.test(text)) { + const response = await fetch(`${BASE_URL}/bin`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + language, + content: text, + }), + }); + const data = await response.json(); + if (response.ok) { + navigator.clipboard + .writeText(`${window.location.origin}/r/${data.id}`) + .then( + function () { + alert("Short URL copied to clipboard!"); + }, + function () { + try { + document.execCommand("copy"); + alert("Short URL copied to clipboard!"); + } catch (err) { + console.log("Oops, unable to copy"); + } + }, + ); + } + navigate(`/${data.id}`); + return; + } + setOpenModal(true); + }; + + const handleSuccessClick = async () => { + setOpenModal(false); + const key = await generateAESKey(); + const keyString = await keyToString(key); + const { encrypted, iv } = await encryptAES(text, key); + const encryptedBase64 = btoa( + String.fromCharCode.apply(null, new Uint8Array(encrypted)), + ); + const ivBase64 = btoa(String.fromCharCode.apply(null, iv)); + const response = await fetch(`${BASE_URL}/bin`, { method: "POST", headers: { @@ -41,42 +93,68 @@ const Editor = () => { }, body: JSON.stringify({ language, - content: text, + content: encryptedBase64, + iv: ivBase64, }), }); const data = await response.json(); if (response.ok) { - const isURL = URL_REGEX.test(text); - if (isURL) { - navigator.clipboard.writeText(`${BASE_URL}/r/${data.id}`).then( + navigator.clipboard + .writeText(`${window.location.origin}/${data.id}?key=${keyString}`) + .then( function () { - alert("Short URL copied to clipboard!"); + navigator.clipboard.writeText( + `${window.location.origin}/${data.id}?key=${keyString}`, + ); + alert("URL copied to clipboard!"); }, - function (err) { + function () { try { - var successful = document.execCommand("copy"); - alert("Short URL copied to clipboard!"); + document.execCommand("copy"); + alert("URL copied to clipboard!"); } catch (err) { console.log("Oops, unable to copy"); } }, ); - } else { - navigator.clipboard.writeText(`${BASE_URL}/r/${data.id}`).then( + navigate(`/${data.id}?key=${keyString}`); + } else { + console.error(data); + } + }; + + const handleCancelClick = async () => { + setOpenModal(false); + const response = await fetch(`${BASE_URL}/bin`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + language, + content: text, + }), + }); + const data = await response.json(); + if (response.ok) { + navigator.clipboard + .writeText(`${window.location.origin}/${data.id}`) + .then( function () { - navigator.clipboard.writeText(`${BASE_URL}/${data.id}`); + navigator.clipboard.writeText( + `${window.location.origin}/${data.id}`, + ); alert("URL copied to clipboard!"); }, - function (err) { + function () { try { - var successful = document.execCommand("copy"); - alert("URL copied to clipboard!"); + document.execCommand("copy"); + alert("URL copied to clip`board!"); } catch (err) { console.log("Oops, unable to copy"); } }, ); - } navigate(`/${data.id}`); } else { console.error(data); @@ -96,12 +174,30 @@ const Editor = () => { const response = await fetch(`${BASE_URL}/bin/${id}`); const data = await response.json(); if (response.ok) { - const isURL = URL_REGEX.test(data.content); - if (isURL) { - setText(`Your shortened URL: ${BASE_URL}/r/${id}`); - } else { + if (data.iv) { + const keyString = queryParams.get("key"); + const key = await stringToKey(keyString); + const encrypted = new Uint8Array( + atob(data.content) + .split("") + .map((char) => char.charCodeAt(0)), + ).buffer; + const ivArray = new Uint8Array( + atob(data.iv) + .split("") + .map((char) => char.charCodeAt(0)), + ); + const decryptedContent = await decryptAES(encrypted, key, ivArray); setLanguage(data.language); - setText(data.content); + setText(decryptedContent); + } else { + const isURL = URL_REGEX.test(data.content); + if (isURL) { + setText(`Your shortened URL: ${window.location.origin}/r/${id}`); + } else { + setLanguage(data.language); + setText(data.content); + } } } }; @@ -117,9 +213,24 @@ const Editor = () => { return ( <>
+ { + handleSuccessClick(); + }} + onCancelClick={() => { + handleCancelClick(); + }} + />
{!id && ( - )} @@ -150,3 +261,4 @@ const Editor = () => { }; export default Editor; + diff --git a/client/src/components/Modal/Modal.jsx b/client/src/components/Modal/Modal.jsx new file mode 100644 index 0000000..54c816e --- /dev/null +++ b/client/src/components/Modal/Modal.jsx @@ -0,0 +1,25 @@ +import React from "react"; +import styles from "./Modal.module.css"; + +const Modal = ({ openModal, setOpenModal, onSuccessClick, onCancelClick }) => { + return ( +
+
+ +

Encrypt content?

+
+ + +
+
+
+ ); +}; + +export default Modal; + diff --git a/client/src/components/Modal/Modal.module.css b/client/src/components/Modal/Modal.module.css new file mode 100644 index 0000000..9d79c62 --- /dev/null +++ b/client/src/components/Modal/Modal.module.css @@ -0,0 +1,61 @@ +.background { + display: none; + position: absolute; + top: 0; + left: 0; + height: 100%; + width: 100%; + background-color: #00000062; + z-index: 10; +} + +.active { + display: flex; + align-items: center; + justify-content: center; +} + +.container { + top: 0; + left: 0; + background-color: var(--color-dark); + padding: 40px; + width: 300px; + border-radius: 10px; + z-index: 11; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +} + +.container__close { + cursor: pointer; + background: none; + border: none; + margin-left: auto; +} + +.close__img { + width: 20px; +} + +.container__actions { + margin-top: 20px; + display: flex; + width: 100%; + justify-content: space-around; +} + +.container__actions button { + padding: 10px 20px; + cursor: pointer; + width: 12rem; + text-align: center; + background-color: var(--color-yellow); + color: var(--color-dark); + border-radius: 10px; + font-size: 1rem; + margin: 0 10px; +} + diff --git a/client/src/utils/encryption.js b/client/src/utils/encryption.js new file mode 100644 index 0000000..e0e6c33 --- /dev/null +++ b/client/src/utils/encryption.js @@ -0,0 +1,81 @@ +async function generateAESKey() { + try { + const key = await window.crypto.subtle.generateKey( + { + name: "AES-GCM", + length: 256, + }, + true, + ["encrypt", "decrypt"], + ); + return key; + } catch (error) { + console.error("Error generating AES key:", error); + } +} + +async function keyToString(key) { + try { + const exportedKey = await window.crypto.subtle.exportKey("raw", key); + const keyString = btoa(String.fromCharCode(...new Uint8Array(exportedKey))); + return keyString.replace(/\+/g, "-").replace(/\//g, "_"); + } catch (error) { + console.error("Error converting key to string:", error); + } +} + +async function stringToKey(keyString) { + try { + const originalKeyString = keyString.replace(/-/g, "+").replace(/_/g, "/"); + const buffer = Uint8Array.from(atob(originalKeyString), (c) => + c.charCodeAt(0), + ).buffer; + + const key = await window.crypto.subtle.importKey( + "raw", + buffer, + { name: "AES-GCM", length: 256 }, + true, + ["encrypt", "decrypt"], + ); + + return key; + } catch (error) { + console.error("Error converting string to key:", error); + } +} + +async function encryptAES(plaintext, key) { + try { + const iv = window.crypto.getRandomValues(new Uint8Array(12)); + const encrypted = await window.crypto.subtle.encrypt( + { + name: "AES-GCM", + iv: iv, + }, + key, + new TextEncoder().encode(plaintext), + ); + return { encrypted, iv }; + } catch (error) { + console.error("Error encrypting:", error); + } +} + +async function decryptAES(encrypted, key, iv) { + try { + const decrypted = await window.crypto.subtle.decrypt( + { + name: "AES-GCM", + iv: iv, + }, + key, + encrypted, + ); + return new TextDecoder().decode(decrypted); + } catch (error) { + console.error("Error decrypting:", error); + } +} + +export { generateAESKey, keyToString, stringToKey, encryptAES, decryptAES }; diff --git a/server/main.go b/server/main.go index 1ce935c..5aaeba7 100644 --- a/server/main.go +++ b/server/main.go @@ -22,6 +22,7 @@ var port string type Bin struct { Content string `json:"content"` Language string `json:"language"` + IV string `json:"iv"` } const ( @@ -110,19 +111,19 @@ func redirectToURL(echoContext echo.Context) error { } func createTable() error { - _, err := db.Exec("CREATE TABLE IF NOT EXISTS bins (id TEXT PRIMARY KEY, content TEXT, language TEXT)") + _, err := db.Exec("CREATE TABLE IF NOT EXISTS bins (id TEXT PRIMARY KEY, content TEXT, language TEXT, iv TEXT)") return err } func getBinById(id string) (Bin, error) { - row := db.QueryRow("SELECT content, language FROM bins WHERE id = ?", id) + row := db.QueryRow("SELECT content, language, iv FROM bins WHERE id = ?", id) bin := Bin{} - err := row.Scan(&bin.Content, &bin.Language) + err := row.Scan(&bin.Content, &bin.Language, &bin.IV) return bin, err } func saveBin(id string, bin Bin) error { - _, err := db.Exec("INSERT INTO bins (id, content, language) VALUES (?, ?, ?)", id, bin.Content, bin.Language) + _, err := db.Exec("INSERT INTO bins (id, content, language, iv) VALUES (?, ?, ?, ?)", id, bin.Content, bin.Language, bin.IV) return err } -- cgit v1.2.3-73-gaa49b