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.

+

Password encryption

+

+ 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. +

Data retention policy

diff --git a/app/templates/index.html b/app/templates/index.html index b5f118e..b835dd3 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -37,6 +37,16 @@

+
+ +
+
+ +
+
+
diff --git a/app/templates/plan.html b/app/templates/plan.html index 5eda68c..9d692dd 100644 --- a/app/templates/plan.html +++ b/app/templates/plan.html @@ -47,12 +47,36 @@
- -
+ +
diff --git a/migrations/versions/84f81debadff_add_support_for_plans_password_.py b/migrations/versions/84f81debadff_add_support_for_plans_password_.py new file mode 100644 index 0000000..aea59a0 --- /dev/null +++ b/migrations/versions/84f81debadff_add_support_for_plans_password_.py @@ -0,0 +1,98 @@ +"""Add support for plans password encryption. + +Revision ID: 84f81debadff +Revises: 7d4af6ed6f66 +Create Date: 2025-06-05 15:17:59.811472 + +""" + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "84f81debadff" +down_revision = "7d4af6ed6f66" +branch_labels = None +depends_on = None + + +def upgrade(): + with op.batch_alter_table("plans", schema=None) as batch_op: + batch_op.add_column( + sa.Column( + "iv", + sa.String(), + nullable=True, + comment="Encryption initialization vector", + ) + ) + op.execute(""" + DROP FUNCTION public.register_plan; + + CREATE FUNCTION public.register_plan(in_title text, in_plan + text, in_sql text, in_is_public boolean, in_iv text) + RETURNS register_plan_return + LANGUAGE plpgsql + AS $function$ + DECLARE + use_hash_length int4 := 16; + reply register_plan_return; + insert_sql TEXT; + BEGIN + insert_sql := 'INSERT INTO public.plans (id, title, plan, sql, + is_public, created, delete_key, iv) VALUES ($1, $2, $3, $4, $5, now(), $6, $7)'; + reply.delete_key := get_random_string( 50 ); + LOOP + reply.id := get_random_string(use_hash_length); + BEGIN + execute insert_sql using reply.id, in_title, in_plan, in_sql, in_is_public, reply.delete_key, in_iv; + RETURN reply; + EXCEPTION WHEN unique_violation THEN + -- do nothing + END; + use_hash_length := use_hash_length + 1; + IF use_hash_length >= 30 THEN + raise exception 'Random string of length == 30 requested. something''s wrong.'; + END IF; + END LOOP; + END; + $function$; + """) + + +def downgrade(): + with op.batch_alter_table("plans", schema=None) as batch_op: + batch_op.drop_column("iv") + + op.execute(""" + DROP FUNCTION public.register_plan; + + CREATE FUNCTION public.register_plan(in_title text, in_plan + text, in_sql text, in_is_public boolean) + RETURNS register_plan_return + LANGUAGE plpgsql + AS $function$ + DECLARE + use_hash_length int4 := 16; + reply register_plan_return; + insert_sql TEXT; + BEGIN + insert_sql := 'INSERT INTO public.plans (id, title, plan, sql, + is_public, created, delete_key) VALUES ($1, $2, $3, $4, $5, now(), $6)'; + reply.delete_key := get_random_string( 50 ); + LOOP + reply.id := get_random_string(use_hash_length); + BEGIN + execute insert_sql using reply.id, in_title, in_plan, in_sql, in_is_public, reply.delete_key; + RETURN reply; + EXCEPTION WHEN unique_violation THEN + -- do nothing + END; + use_hash_length := use_hash_length + 1; + IF use_hash_length >= 30 THEN + raise exception 'Random string of length == 30 requested. something''s wrong.'; + END IF; + END LOOP; + END; + $function$; + """)