feat: added end-to-end encryption
This commit is contained in:
parent
1b61dfbed7
commit
f6d70cf9ab
6 changed files with 317 additions and 37 deletions
BIN
client/public/assets/icons/ic_close.png
Normal file
BIN
client/public/assets/icons/ic_close.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 534 B |
|
@ -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,95 @@ 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: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
language,
|
||||
content: encryptedBase64,
|
||||
iv: ivBase64,
|
||||
}),
|
||||
});
|
||||
const data = await response.json();
|
||||
if (response.ok) {
|
||||
navigator.clipboard
|
||||
.writeText(`${window.location.origin}/${data.id}?key=${keyString}`)
|
||||
.then(
|
||||
function () {
|
||||
navigator.clipboard.writeText(
|
||||
`${window.location.origin}/${data.id}?key=${keyString}`,
|
||||
);
|
||||
alert("URL copied to clipboard!");
|
||||
},
|
||||
function () {
|
||||
try {
|
||||
document.execCommand("copy");
|
||||
alert("URL copied to clipboard!");
|
||||
} catch (err) {
|
||||
console.log("Oops, unable to copy");
|
||||
}
|
||||
},
|
||||
);
|
||||
navigate(`/${data.id}?key=${keyString}`);
|
||||
} else {
|
||||
console.error(data);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelClick = async () => {
|
||||
setOpenModal(false);
|
||||
const response = await fetch(`${BASE_URL}/bin`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
|
@ -46,37 +137,24 @@ const Editor = () => {
|
|||
});
|
||||
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}`)
|
||||
.then(
|
||||
function () {
|
||||
alert("Short URL copied to clipboard!");
|
||||
},
|
||||
function (err) {
|
||||
try {
|
||||
var successful = document.execCommand("copy");
|
||||
alert("Short URL copied to clipboard!");
|
||||
} catch (err) {
|
||||
console.log("Oops, unable to copy");
|
||||
}
|
||||
},
|
||||
);
|
||||
} else {
|
||||
navigator.clipboard.writeText(`${BASE_URL}/r/${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 (
|
||||
<>
|
||||
<Header isSelectVisible={!id} onLanguageChange={handleLanguageChange} />
|
||||
<Modal
|
||||
openModal={openModal}
|
||||
setOpenModal={setOpenModal}
|
||||
onSuccessClick={() => {
|
||||
handleSuccessClick();
|
||||
}}
|
||||
onCancelClick={() => {
|
||||
handleCancelClick();
|
||||
}}
|
||||
/>
|
||||
<div className={styles.container}>
|
||||
{!id && (
|
||||
<button className={styles.btn__save} onClick={handleClick}>
|
||||
<button
|
||||
className={styles.btn__save}
|
||||
onClick={() => {
|
||||
handleSaveClick();
|
||||
}}
|
||||
>
|
||||
<img src="assets/icons/save.svg" className={styles.btn__icon} />
|
||||
</button>
|
||||
)}
|
||||
|
@ -150,3 +261,4 @@ const Editor = () => {
|
|||
};
|
||||
|
||||
export default Editor;
|
||||
|
||||
|
|
25
client/src/components/Modal/Modal.jsx
Normal file
25
client/src/components/Modal/Modal.jsx
Normal file
|
@ -0,0 +1,25 @@
|
|||
import React from "react";
|
||||
import styles from "./Modal.module.css";
|
||||
|
||||
const Modal = ({ openModal, setOpenModal, onSuccessClick, onCancelClick }) => {
|
||||
return (
|
||||
<div className={`${styles.background} ${openModal && styles.active}`}>
|
||||
<div className={styles.container}>
|
||||
<button
|
||||
className={styles.container__close}
|
||||
onClick={() => setOpenModal(false)}
|
||||
>
|
||||
<img src="assets/icons/ic_close.png" className={styles.close__img} />
|
||||
</button>
|
||||
<p className={styles.container__title}>Encrypt content?</p>
|
||||
<div className={styles.container__actions}>
|
||||
<button onClick={onSuccessClick}>Yes</button>
|
||||
<button onClick={onCancelClick}>No</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Modal;
|
||||
|
61
client/src/components/Modal/Modal.module.css
Normal file
61
client/src/components/Modal/Modal.module.css
Normal file
|
@ -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;
|
||||
}
|
||||
|
81
client/src/utils/encryption.js
Normal file
81
client/src/utils/encryption.js
Normal file
|
@ -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 };
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in a new issue