diff --git a/app/__init__.py b/app/__init__.py index 4deb480..61cb36f 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -6,7 +6,7 @@ from flask_migrate import Migrate from flask_sqlalchemy import SQLAlchemy from flask_wtf import FlaskForm -from wtforms import StringField, TextAreaField +from wtforms import HiddenField, StringField, TextAreaField from wtforms.validators import DataRequired app = Flask(__name__, instance_relative_config=True) @@ -27,6 +27,7 @@ class Plan(db.Model): sql = db.Column(db.String) is_public = db.Column(db.Boolean, default=False) delete_key = db.Column(db.String) + iv = db.Column(db.String, comment="Encryption initialization vector") __table_args__ = {"postgresql_partition_by": "HASH (id)"} @@ -36,6 +37,7 @@ def as_dict(self): "title": self.title, "plan": self.plan, "sql": self.sql, + "iv": self.iv, } @@ -55,6 +57,7 @@ class PlanForm(FlaskForm): title = StringField("Title") plan = TextAreaField("Plan", validators=[DataRequired()]) query = TextAreaField("Query") + iv = HiddenField("IV") @app.route("/new", methods=["POST"]) @@ -73,7 +76,7 @@ def save(json=False): """ form = PlanForm() if form.validate_on_submit(): - sql = "SELECT register_plan(:title, :plan, :query, :is_public)" + sql = "SELECT register_plan(:title, :plan, :query, :is_public, :iv)" query = db.session.execute( sql, { @@ -81,6 +84,7 @@ def save(json=False): "plan": form.plan.data, "query": form.query.data, "is_public": False, + "iv": form.iv.data, }, ) db.session.commit() diff --git a/app/static/js/index.js b/app/static/js/index.js index 4396bfe..c63952b 100644 --- a/app/static/js/index.js +++ b/app/static/js/index.js @@ -27,6 +27,7 @@ const app = createApp({ const titleInput = ref(""); const planInput = ref(""); const queryInput = ref(""); + const passwordInput = ref(""); const draggingPlan = ref(false); const draggingQuery = ref(false); const plans = ref([]); @@ -45,7 +46,7 @@ const app = createApp({ } } - function submitPlan() { + async function submitPlan() { // User don't want to be asked again const dontAskAgain = document.getElementById("dontAskAgain").checked; if (dontAskAgain) { @@ -58,16 +59,53 @@ const app = createApp({ titleInput.value = titleInput.value || "Plan created on " + moment().format("MMMM Do YYYY, h:mm a"); + let plan_data = planInput.value; + let query_data = queryInput.value; + let iv_data = null; + if (passwordInput.value) { + const iv = window.crypto.getRandomValues(new Uint8Array(12)); + iv_data = btoa(String.fromCharCode(...iv)); + const key = await derivePasswordKey(passwordInput.value, iv); + plan_data = await encrypt(plan_data, iv, key); + query_data = await encrypt(query_data, iv, key); + } inputPlan = { title: titleInput.value, - plan: planInput.value, - query: queryInput.value, - createdOn: new Date(), + plan: plan_data, + query: query_data, + iv: iv_data, }; } share(inputPlan); } + async function derivePasswordKey(password, iv) { + const pwKey = await window.crypto.subtle.importKey( + "raw", + new TextEncoder().encode(password), + "PBKDF2", + false, + ["deriveKey"] + ); + + return await window.crypto.subtle.deriveKey( + { name: "PBKDF2", salt: iv, iterations: 100000, hash: "SHA-256" }, + pwKey, + { name: "AES-GCM", length: 256 }, + false, + ["encrypt"] + ); + } + + async function encrypt(data, iv, key) { + const ciphertext = await window.crypto.subtle.encrypt( + { name: "AES-GCM", iv: iv }, + key, + new TextEncoder().encode(data) + ); + return btoa(String.fromCharCode(...new Uint8Array(ciphertext))); + } + function loadSample(sample) { [titleInput.value, planInput.value, queryInput.value] = sample; } @@ -136,30 +174,24 @@ const app = createApp({ function share(plan) { const form = document.getElementById("submitForm"); - axios - .post(form.action, { - title: plan.title, - plan: plan.plan, - query: plan.query, - }) - .then((response) => { - localStorage.removeItem(plan.id); - const data = response.data; - const id = "plan_" + data.id; - localStorage.setItem( - id, - JSON.stringify({ - id: id, - shareId: data.id, - title: plan.title, - createdOn: plan.createdOn, - deleteKey: data.deleteKey, - }) - ); - - // redirect to page with plan from server - window.location.href = "/plan/" + data.id; - }); + axios.post(form.action, plan).then((response) => { + localStorage.removeItem(plan.id); + const data = response.data; + const id = "plan_" + data.id; + localStorage.setItem( + id, + JSON.stringify({ + id: id, + shareId: data.id, + title: plan.title, + createdOn: new Date(), + deleteKey: data.deleteKey, + }) + ); + + // redirect to page with plan from server + window.location.href = "/plan/" + data.id; + }); } function formattedDate(date) { @@ -191,6 +223,7 @@ const app = createApp({ titleInput, planInput, queryInput, + passwordInput, draggingPlan, draggingQuery, plans, diff --git a/app/static/js/plan.js b/app/static/js/plan.js index 8bba1fd..367f74d 100644 --- a/app/static/js/plan.js +++ b/app/static/js/plan.js @@ -15,7 +15,56 @@ library.add(faLink); const app = createApp({ setup() { const plan = ref(planData); - return { plan }; + const decryptError = ref(false); + const passwordInput = ref(""); + + async function unlockPlan(e) { + e.preventDefault(); + try { + plan.value.plan = await decrypt(plan.value.plan, plan.value.iv); + if (plan.value.query) { + plan.value.query = await decrypt(plan.value.query, plan.value.iv); + } + plan.value.iv = null; + } catch (err) { + decryptError.value = true; + } + } + + function fromBase64(b64) { + const binary = atob(b64); + return Uint8Array.from(binary, (c) => c.charCodeAt(0)); + } + + async function decrypt(encrypted, iv) { + const ctBytes = fromBase64(encrypted); + const ivBytes = fromBase64(iv); + + const pwKey = await window.crypto.subtle.importKey( + "raw", + new TextEncoder().encode(passwordInput.value), + "PBKDF2", + false, + ["deriveKey"] + ); + + const key = await window.crypto.subtle.deriveKey( + { name: "PBKDF2", salt: ivBytes, iterations: 100000, hash: "SHA-256" }, + pwKey, + { name: "AES-GCM", length: 256 }, + false, + ["decrypt"] + ); + + const plaintext = await window.crypto.subtle.decrypt( + { name: "AES-GCM", iv: ivBytes }, + key, + ctBytes + ); + + return new TextDecoder().decode(plaintext); + } + return { decryptError, passwordInput, plan, unlockPlan }; }, components: { pev2: Plan, diff --git a/app/templates/about.html b/app/templates/about.html index baa7efb..392f320 100644 --- a/app/templates/about.html +++ b/app/templates/about.html @@ -91,6 +91,12 @@
Please report bugs on github.
++ If a password is provided when submitting a plan, the content of both the plan + and the query fields is stored encrypted. The encryption is made client-side (in the + browser). No password is sent to the server. +