[wip] e2ee messqge

This commit is contained in:
GitHub
2023-11-24 20:03:30 +08:00
committed by freedit-dev
parent df8a3a22a1
commit e02b2613e2
11 changed files with 382 additions and 7 deletions

View File

@@ -9,7 +9,8 @@ use crate::{
mod_inn_post, post, post_delete, post_downvote, post_hide, post_lock, post_pin,
post_upvote, preview, tag,
},
meta_handler::{handler_404, home, robots, style},
message::{key, key_post, message},
meta_handler::{encoding_js, encryption_js, handler_404, home, robots, style},
notification::notification,
solo::{solo, solo_delete, solo_like, solo_list, solo_post},
tantivy::search,
@@ -104,11 +105,15 @@ pub async fn router() -> Router {
.route("/feed/star/:item_id", get(feed_star))
.route("/feed/subscribe/:uid/:item_id", get(feed_subscribe))
.route("/feed/read/:item_id", get(feed_read))
.route("/search", get(search));
.route("/search", get(search))
.route("/message/:uid", get(message))
.route("/key", get(key).post(key_post));
let router_static = Router::new()
.route("/static/style.css", get(style))
.route("/robots.txt", get(robots))
.route("/static/js/encryption-helper.js", get(encryption_js))
.route("/static/js/encoding-helper.js", get(encoding_js))
.nest_service("/static/avatars", ServeDir::new(&CONFIG.avatars_path))
.nest_service("/static/inn_icons", ServeDir::new(&CONFIG.inn_icons_path))
.nest_service("/static/upload", ServeDir::new(&CONFIG.upload_path));

View File

@@ -258,10 +258,10 @@ pub(crate) async fn admin_view(
let one_fmt = unescape(&format!("{:?}", one)).unwrap();
ones.push(format!("{key}: {one_fmt}"));
}
"feed_errs" => {
let feed_id = ivec_to_u32(&k);
let err = String::from_utf8_lossy(&v);
ones.push(format!("{feed_id}: {err}"));
"feed_errs" | "pub_keys" => {
let id = ivec_to_u32(&k);
let msg = String::from_utf8_lossy(&v);
ones.push(format!("{id}: {msg}"));
}
"drafts" => {
let uid = u8_slice_to_u32(&k[0..4]);

99
src/controller/message.rs Normal file
View File

@@ -0,0 +1,99 @@
use askama::Template;
use axum::{
extract::Path,
headers::Cookie,
response::{IntoResponse, Redirect},
Form, TypedHeader,
};
use serde::Deserialize;
use crate::{controller::fmt::clean_html, error::AppError, DB};
use super::{
db_utils::u32_to_ivec,
meta_handler::{into_response, PageData},
Claim, SiteConfig,
};
/// Page data: `message.html`
#[derive(Template)]
#[template(path = "message.html", escape = "none")]
struct PageMessage<'a> {
page_data: PageData<'a>,
pub_key: String,
}
/// `GET /message/:uid`
pub(crate) async fn message(
cookie: Option<TypedHeader<Cookie>>,
Path(uid): Path<u32>,
) -> Result<impl IntoResponse, AppError> {
let cookie = cookie.ok_or(AppError::NonLogin)?;
let site_config = SiteConfig::get(&DB)?;
let claim = Claim::get(&DB, &cookie, &site_config).ok_or(AppError::NonLogin)?;
let Some(pub_key) = DB.get(u32_to_ivec(uid))? else {
return Err(AppError::Custom(
"User has not generated key pairs".to_string(),
));
};
let page_message = PageMessage {
page_data: PageData::new("Message", &site_config, Some(claim), false),
pub_key: String::from_utf8_lossy(&pub_key).to_string(),
};
Ok(into_response(&page_message))
}
/// Page data: `key.html`
#[derive(Template)]
#[template(path = "key.html", escape = "none")]
struct PageKey<'a> {
page_data: PageData<'a>,
pub_key: String,
}
/// `GET /key`
pub(crate) async fn key(
cookie: Option<TypedHeader<Cookie>>,
) -> Result<impl IntoResponse, AppError> {
let cookie = cookie.ok_or(AppError::NonLogin)?;
let site_config = SiteConfig::get(&DB)?;
let claim = Claim::get(&DB, &cookie, &site_config).ok_or(AppError::NonLogin)?;
let pub_key = DB
.open_tree("pub_keys")?
.get(u32_to_ivec(claim.uid))?
.map(|r| String::from_utf8_lossy(&r).to_string())
.unwrap_or_default();
let page_key = PageKey {
page_data: PageData::new("Generate Key Pairs", &site_config, Some(claim), false),
pub_key,
};
Ok(into_response(&page_key))
}
/// Form data: `/key`
#[derive(Deserialize)]
pub(crate) struct FormKey {
pub_key: String,
}
/// `POST /key`
pub(crate) async fn key_post(
cookie: Option<TypedHeader<Cookie>>,
Form(input): Form<FormKey>,
) -> Result<impl IntoResponse, AppError> {
let cookie = cookie.ok_or(AppError::NonLogin)?;
let site_config = SiteConfig::get(&DB)?;
let claim = Claim::get(&DB, &cookie, &site_config).ok_or(AppError::NonLogin)?;
let pub_key = clean_html(&input.pub_key);
DB.open_tree("pub_keys")?
.insert(u32_to_ivec(claim.uid), pub_key.as_str())?;
Ok(Redirect::to("/key"))
}

View File

@@ -155,6 +155,14 @@ pub(crate) async fn style() -> (HeaderMap, &'static str) {
(headers, &CSS)
}
pub(crate) async fn encryption_js() -> &'static str {
include_str!("../../static/js/encryption-helper.js")
}
pub(crate) async fn encoding_js() -> &'static str {
include_str!("../../static/js/encoding-helper.js")
}
pub(crate) async fn robots() -> &'static str {
include_str!("../../static/robots.txt")
}

View File

@@ -113,6 +113,7 @@ pub mod tantivy;
pub(super) mod admin;
pub(super) mod inn;
pub(super) mod message;
pub(super) mod solo;
pub(super) mod upload;
pub(super) mod user;

View File

@@ -0,0 +1,57 @@
// JavaScript is unminified and unchanged, copied from https://github.com/galehouse5/rsa-webcrypto-tool
//
// The Unlicense: <http://unlicense.org>
var pemToBase64String = function (value, label) {
var lines = value.split("\n");
var base64String = "";
for (var i = 0; i < lines.length; i++) {
if (lines[i].startsWith("-----")) continue;
base64String += lines[i];
}
return base64String;
};
var base64StringToArrayBuffer = function (value) {
var byteString = atob(value);
var byteArray = new Uint8Array(byteString.length);
for (var i = 0; i < byteString.length; i++) {
byteArray[i] = byteString.charCodeAt(i);
}
return byteArray.buffer;
};
var base64StringToPem = function (value, label) {
var pem = "-----BEGIN {0}-----\n".replace("{0}", label);
for (var i = 0; i < value.length; i += 64) {
pem += value.substr(i, 64) + "\n";
}
pem += "-----END {0}-----\n".replace("{0}", label);
return pem;
};
var arrayBufferToBase64String = function (value) {
var byteArray = new Uint8Array(value);
var byteString = "";
for (var i = 0; i < byteArray.byteLength; i++) {
byteString += String.fromCharCode(byteArray[i]);
}
return btoa(byteString);
};
var pemToArrayBuffer = function (value) {
return base64StringToArrayBuffer(pemToBase64String(value));
};
var arrayBufferToPem = function (value, label) {
return base64StringToPem(arrayBufferToBase64String(value), label);
};

View File

@@ -0,0 +1,85 @@
// JavaScript is unminified and unchanged, copied from https://github.com/galehouse5/rsa-webcrypto-tool
//
// The Unlicense: <http://unlicense.org>
var rsaAlgorithm = {
name: "RSA-OAEP",
modulusLength: 2048,
publicExponent: new Uint8Array([0x01, 0x00, 0x01]),
hash: { name: "SHA-256" }
};
var aesAlgorithm = {
name: "AES-GCM",
length: 256
};
var aesIVLength = 12;
var generateRsaKeys = function () {
return crypto.subtle.generateKey(rsaAlgorithm,
/* extractable: */ true, /* keyUsages: */ ["wrapKey", "unwrapKey"])
.catch(function (error) { throw "Error generating keys."; })
.then(function (rsaKey) {
var exportPublicKey = crypto.subtle.exportKey("spki", rsaKey.publicKey)
.catch(function (error) { throw "Error exporting public key."; });
var exportPrivateKey = crypto.subtle.exportKey("pkcs8", rsaKey.privateKey)
.catch(function (error) { throw "Error exporting private key."; });
return Promise.all([exportPublicKey, exportPrivateKey])
.then(function (keys) { return { publicKeyBuffer: keys[0], privateKeyBuffer: keys[1] }; });
});
};
var rsaEncrypt = function (data, rsaPublicKeyBuffer) {
var importRsaPublicKey = crypto.subtle.importKey("spki", rsaPublicKeyBuffer, rsaAlgorithm,
/* extractable: */ false, /* keyUsages: */ ["wrapKey"])
.catch(function (error) { throw "Error importing public key."; });
var generateAesKey = crypto.subtle.generateKey(aesAlgorithm,
/* extractable: */ true, /* keyUsages: */ ["encrypt"])
.catch(function (error) { throw "Error generating symmetric key."; });
return Promise.all([importRsaPublicKey, generateAesKey])
.then(function (keys) {
var rsaPublicKey = keys[0], aesKey = keys[1];
var aesIV = crypto.getRandomValues(new Uint8Array(aesIVLength));
var initializedAesAlgorithm = Object.assign({ iv: aesIV }, aesAlgorithm);
var wrapAesKey = crypto.subtle.wrapKey("raw", aesKey, rsaPublicKey, rsaAlgorithm)
.catch(function (error) { throw "Error encrypting symmetric key."; });
var encryptData = crypto.subtle.encrypt(initializedAesAlgorithm, aesKey, data)
.catch(function (error) { throw "Error encrypting data."; });
return Promise.all([wrapAesKey, encryptData])
.then(function (buffers) {
var wrappedAesKey = new Uint8Array(buffers[0]), encryptedData = new Uint8Array(buffers[1]);
var encryptionState = new Uint8Array(wrappedAesKey.length + aesIV.length + encryptedData.length);
encryptionState.set(wrappedAesKey, 0);
encryptionState.set(aesIV, wrappedAesKey.length);
encryptionState.set(encryptedData, wrappedAesKey.length + aesIV.length);
return encryptionState.buffer;
});
});
};
var rsaDecrypt = function (data, rsaPrivateKeyBuffer) {
return crypto.subtle.importKey("pkcs8", rsaPrivateKeyBuffer, rsaAlgorithm,
/* extractable: */ false, /* keyUsages: */ ["unwrapKey"])
.catch(function (error) { throw "Error importing private key."; })
.then(function (rsaKey) {
var wrappedAesKeyLength = rsaAlgorithm.modulusLength / 8;
var wrappedAesKey = new Uint8Array(data.slice(0, wrappedAesKeyLength));
var aesIV = new Uint8Array(data.slice(wrappedAesKeyLength, wrappedAesKeyLength + aesIVLength));
var initializedaesAlgorithm = Object.assign({ iv: aesIV }, aesAlgorithm);
return crypto.subtle.unwrapKey("raw", wrappedAesKey, rsaKey, rsaAlgorithm, initializedaesAlgorithm,
/* extractable: */ false, /* keyUsages: */ ["decrypt"])
.catch(function (error) { throw "Error decrypting symmetric key." })
.then (function (aesKey) {
var encryptedData = new Uint8Array(data.slice(wrappedAesKeyLength + aesIVLength));
return crypto.subtle.decrypt(initializedaesAlgorithm, aesKey, encryptedData)
.catch(function (error) { throw "Error decrypting data." });
});
});
};

117
templates/key.html Normal file
View File

@@ -0,0 +1,117 @@
{% extends "layout.html" %}
{% block csp %}
<meta http-equiv="Content-Security-Policy" content="default-src 'self';
img-src 'self';script-src 'self' 'unsafe-inline'; style-src 'self'; object-src 'none';
font-src 'none'; form-action 'self'; frame-src 'none'; media-src 'none'; manifest-src 'none'; worker-src 'none';">
{% endblock %}
{% block content %}
<div class="box">
<div class="content">
<p class="title">Generate RSA Keys</p>
<p>Your keys and data are kept confidential by running cryptography operations in your browser using
<a href="https://www.w3.org/TR/WebCryptoAPI/" target="Web Crypto API">Web Crypto API</a> and
JavaScript is left unminified so you can verify page source.
</p>
<p>The code is copied from: <a href="https://github.com/galehouse5/rsa-webcrypto-tool">https://github.com/galehouse5/rsa-webcrypto-tool</a></p>
<p class="has-text-danger">You must keep Private Key yourself and upload public key.</p>
<p>You can generate key pair from <a href="https://github.com/galehouse5/rsa-webcrypto-tool">rsa-webcrypto-tool</a> and upload public key here.</p>
<button id="button" class="button is-link">Generate Keys</button>
<div id="message"></div>
<div class="box">
<fieldset>
<div class="field">
<div class="is-normal">
<label class="label" for="private-key">RSA Private Key</label>
</div>
<div class="field-body">
<div class="field">
<div class="control">
<p class="help">Download <a id="private-key-download" class="download" download="id_rsa">private key</a></p>
<textarea id="private-key-text" name="pri_key" rows="10" class="textarea"></textarea>
</div>
</div>
</div>
</div>
</fieldset>
</div>
<form id="result" class="box" action="/key" method="post">
<fieldset>
<div class="field">
<div class="is-normal">
<label class="label" for="public-key">RSA Public Key</label>
</div>
<div class="field-body">
<div class="field">
<div class="control">
<textarea id="public-key-text" name="pub_key" rows="10" class="textarea" placeholder="{{pub_key}}">{{pub_key}}</textarea>
</div>
</div>
</div>
</div>
<div class="field">
<div class="field-label"></div>
<div class="field-body">
<div class="field">
<div class="control">
<p class="help">Only the last uploaded public key will be used.</p>
<button type="submit" form="result" class="button is-link">Upload Public Key</button>
</div>
</div>
</div>
</div>
</fieldset>
</form>
</div>
</div>
<div class="divider"></div>
{% endblock %}
{% block extra %}
<script src="static/js/encoding-helper.js"></script>
<script src="static/js/encryption-helper.js"></script>
<script>
(function () {
var publicKeyText = document.getElementById("public-key-text");
var privateKeyText = document.getElementById("private-key-text");
var privateKeyDownload = document.getElementById("private-key-download");
var button = document.getElementById("button");
var message = document.getElementById("message");
var result = document.getElementById("result");
var success = function (keys) {
publicKeyText.value = arrayBufferToPem(keys.publicKeyBuffer, "RSA PUBLIC KEY");
privateKeyText.value = arrayBufferToPem(keys.privateKeyBuffer, "RSA PRIVATE KEY");
privateKeyDownload.href = window.URL.createObjectURL(
new Blob([privateKeyText.value], { type: "application/octet-stream" }));
result.style.display = "block";
message.innerText = null;
button.disabled = false;
};
var error = function (error) {
message.innerText = error;
button.disabled = false;
};
var process = function () {
message.innerText = "Processing...";
button.disabled = true;
generateRsaKeys().then(success, error);
};
var warn = function () {
if (privateKey.value === "") return;
return "Are you sure? Your keys will be lost unless you've saved them.";
};
button.addEventListener("click", process);
window.onbeforeunload = warn;
})();
</script>
{% endblock %}

View File

@@ -148,4 +148,7 @@
</footer>
</body>
{% block extra %}
{% endblock %}
</html>

0
templates/message.html Normal file
View File

View File

@@ -35,7 +35,7 @@
</div>
<div class="level-right">
<div class="level-item">
<a>
<a href="/message/{{user.uid}}">
<span class="icon is-large">
<span class="icon">
{% include "icons/mail.svg" %}