Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions app/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)"}

Expand All @@ -36,6 +37,7 @@ def as_dict(self):
"title": self.title,
"plan": self.plan,
"sql": self.sql,
"iv": self.iv,
}


Expand All @@ -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"])
Expand All @@ -73,14 +76,15 @@ 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,
{
"title": form.title.data,
"plan": form.plan.data,
"query": form.query.data,
"is_public": False,
"iv": form.iv.data,
},
)
db.session.commit()
Expand Down
89 changes: 61 additions & 28 deletions app/static/js/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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([]);
Expand All @@ -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) {
Expand All @@ -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;
}
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -191,6 +223,7 @@ const app = createApp({
titleInput,
planInput,
queryInput,
passwordInput,
draggingPlan,
draggingQuery,
plans,
Expand Down
51 changes: 50 additions & 1 deletion app/static/js/plan.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
6 changes: 6 additions & 0 deletions app/templates/about.html
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,12 @@ <h3>
<p>
Please report bugs on <a href="https://github.com/dalibo/pev2/issues"><i class="fab fa-github"></i> github</a>.
</p>
<h3>Password encryption</h3>
<p>
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.
</p>
<h3 id="retention">
Data retention policy
</h3>
Expand Down
10 changes: 10 additions & 0 deletions app/templates/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,16 @@ <h2>
<label for="queryInput" class="form-label">Query <span class="small text-muted">(optional)</span></label>
<textarea name="sql" class="form-control" :class="[draggingQuery ? 'dropzone-over' : '']" id="queryInput" rows="8" v-model="queryInput" @dragenter="draggingQuery = true" @dragleave="draggingQuery = false" @drop.prevent="handleDrop" placeholder="Paste corresponding SQL query or drop a file"></textarea>
</div>
<div class="mb-3">
<label for="passwordInput" class="form-label">Password <span class="small text-muted">(optional)</span>
<span class="badge text-bg-success ms-2"><small>New</small></span>
</label>
<div class="row">
<div class="col-6">
<input name="password" class="form-control" id="passwordInput" v-model="passwordInput" maxlength="100">
</div>
</div>
</div>
<div class="row">
<div class="col d-flex">
<button type="submit" class="btn btn-primary">Submit</button>
Expand Down
30 changes: 27 additions & 3 deletions app/templates/plan.html
Original file line number Diff line number Diff line change
Expand Up @@ -47,12 +47,36 @@
</header>
<div class="d-flex flex-column flex-grow-1 overflow-auto">
<div class="flex-grow-1 overflow-auto d-flex">
<pev2 :plan-source="plan.plan" :plan-query="plan.sql || ''" v-if="plan"></pev2>
<div v-else class="w-100 h-100 d-flex">
<template v-if="plan.iv">
<div class="w-100 h-100 d-flex">
<div class="align-self-center mx-auto">
Loading
<div class="mb-3">
This plan is protected by a password.
</div>
<form @submit="unlockPlan">
<div class="mb-3">
<input name="password" type="password" v-model="passwordInput" class="form-control" maxlength="100" placeholder="Password" autofocus/>
</div>
<div class="mb-3 text-danger" v-if="decryptError">
Password is incorrect (unable to decrypt)
</div>
<div class="row">
<div class="col d-flex justify-content-center">
<button type="submit" class="btn btn-primary">Unlock</button>
</div>
</div>
</form>
</div>
</div>
</template>
<template v-else>
<pev2 :plan-source="plan.plan" :plan-query="plan.sql || ''" v-if="plan"></pev2>
<div v-else class="w-100 h-100 d-flex">
<div class="align-self-center mx-auto">
Loading
</div>
</div>
</template>
</div>
</div>
</div>
Expand Down
Loading