DAR-Encryption
This commit is contained in:
parent
3ec0d51003
commit
032254ba52
13 changed files with 1638 additions and 955 deletions
1371
Cargo.lock
generated
1371
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
23
Cargo.toml
23
Cargo.toml
|
@ -6,18 +6,23 @@ edition = "2021"
|
||||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
tokio = { version = "1.27.0", features = ["full"] }
|
tokio = { version = "1.33", features = ["full"] }
|
||||||
tokio-util = { version="0.7"}
|
tokio-util = { version="0.7", features = ["io"]}
|
||||||
futures-util = "0.3"
|
futures-util = "0.3"
|
||||||
axum = {version="0.6", features=["macros", "headers"]}
|
axum = {version="0.6", features=["macros", "headers"]}
|
||||||
serde = "1.0"
|
serde = "1.0"
|
||||||
serde_cbor = "0.11"
|
toml = "0.8"
|
||||||
render = { git="https://github.com/render-rs/render.rs" }
|
render = { git="https://github.com/render-rs/render.rs" }
|
||||||
thiserror = "1.0.40"
|
thiserror = "1.0"
|
||||||
rand = "0.8.5"
|
rand = "0.8"
|
||||||
dotenvy = "0.15"
|
dotenvy = "0.15"
|
||||||
markdown = "0.3.0"
|
markdown = "0.3"
|
||||||
axum_oidc = {git="https://git2.zettoit.eu/pfz4/axum_oidc"}
|
axum_oidc = {git="https://git2.zettoit.eu/pfz4/axum_oidc"}
|
||||||
rust-s3 = { version="0.33.0", features=["tokio-rustls-tls", "tags"], default_features=false }
|
log = "0.4"
|
||||||
log = "0.4.18"
|
env_logger = "0.10"
|
||||||
pretty_env_logger = "0.5.0"
|
|
||||||
|
chacha20 = "0.9"
|
||||||
|
sha3 = "0.10"
|
||||||
|
hex = "0.4"
|
||||||
|
bytes = "1.5"
|
||||||
|
pin-project-lite = "0.2"
|
||||||
|
|
|
@ -1,4 +0,0 @@
|
||||||
FROM debian:stable-slim as final
|
|
||||||
WORKDIR /app
|
|
||||||
COPY ./target/release/bin ./bin
|
|
||||||
CMD ["/app/bin"]
|
|
129
flake.lock
Normal file
129
flake.lock
Normal file
|
@ -0,0 +1,129 @@
|
||||||
|
{
|
||||||
|
"nodes": {
|
||||||
|
"crane": {
|
||||||
|
"inputs": {
|
||||||
|
"flake-compat": "flake-compat",
|
||||||
|
"flake-utils": [
|
||||||
|
"flake-utils"
|
||||||
|
],
|
||||||
|
"nixpkgs": [
|
||||||
|
"nixpkgs"
|
||||||
|
],
|
||||||
|
"rust-overlay": [
|
||||||
|
"rust-overlay"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1697513493,
|
||||||
|
"narHash": "sha256-Kjidf29+ahcsQE7DICxI4g4tjMSY76BfhKFANnkQhk0=",
|
||||||
|
"owner": "ipetkov",
|
||||||
|
"repo": "crane",
|
||||||
|
"rev": "eb5034b6ee36d523bf1d326ab990811ac2ceb870",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "ipetkov",
|
||||||
|
"repo": "crane",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"flake-compat": {
|
||||||
|
"flake": false,
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1696267196,
|
||||||
|
"narHash": "sha256-AAQ/2sD+0D18bb8hKuEEVpHUYD1GmO2Uh/taFamn6XQ=",
|
||||||
|
"owner": "edolstra",
|
||||||
|
"repo": "flake-compat",
|
||||||
|
"rev": "4f910c9827911b1ec2bf26b5a062cd09f8d89f85",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "edolstra",
|
||||||
|
"repo": "flake-compat",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"flake-utils": {
|
||||||
|
"inputs": {
|
||||||
|
"systems": "systems"
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1694529238,
|
||||||
|
"narHash": "sha256-zsNZZGTGnMOf9YpHKJqMSsa0dXbfmxeoJ7xHlrt+xmY=",
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "flake-utils",
|
||||||
|
"rev": "ff7b65b44d01cf9ba6a71320833626af21126384",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "flake-utils",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nixpkgs": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1697059129,
|
||||||
|
"narHash": "sha256-9NJcFF9CEYPvHJ5ckE8kvINvI84SZZ87PvqMbH6pro0=",
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "5e4c2ada4fcd54b99d56d7bd62f384511a7e2593",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "NixOS",
|
||||||
|
"ref": "nixos-unstable",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": {
|
||||||
|
"inputs": {
|
||||||
|
"crane": "crane",
|
||||||
|
"flake-utils": "flake-utils",
|
||||||
|
"nixpkgs": "nixpkgs",
|
||||||
|
"rust-overlay": "rust-overlay"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"rust-overlay": {
|
||||||
|
"inputs": {
|
||||||
|
"flake-utils": [
|
||||||
|
"flake-utils"
|
||||||
|
],
|
||||||
|
"nixpkgs": [
|
||||||
|
"nixpkgs"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1697508761,
|
||||||
|
"narHash": "sha256-QKWiXUlnke+EiJw3pek1l7xyJ4YsxYXZeQJt/YLgjvA=",
|
||||||
|
"owner": "oxalica",
|
||||||
|
"repo": "rust-overlay",
|
||||||
|
"rev": "6f74c92caaf2541641b50ec623676430101d1fd4",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "oxalica",
|
||||||
|
"repo": "rust-overlay",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"systems": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1681028828,
|
||||||
|
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default",
|
||||||
|
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": "root",
|
||||||
|
"version": 7
|
||||||
|
}
|
69
flake.nix
Normal file
69
flake.nix
Normal file
|
@ -0,0 +1,69 @@
|
||||||
|
{
|
||||||
|
description = "bin service";
|
||||||
|
|
||||||
|
inputs = {
|
||||||
|
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||||
|
flake-utils.url = "github:numtide/flake-utils";
|
||||||
|
rust-overlay = {
|
||||||
|
url = "github:oxalica/rust-overlay";
|
||||||
|
inputs = {
|
||||||
|
nixpkgs.follows = "nixpkgs";
|
||||||
|
flake-utils.follows = "flake-utils";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
crane = {
|
||||||
|
url = "github:ipetkov/crane";
|
||||||
|
inputs = {
|
||||||
|
nixpkgs.follows = "nixpkgs";
|
||||||
|
flake-utils.follows = "flake-utils";
|
||||||
|
rust-overlay.follows = "rust-overlay";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
outputs = { self, nixpkgs, flake-utils, rust-overlay, crane}:
|
||||||
|
flake-utils.lib.eachDefaultSystem (system:
|
||||||
|
let
|
||||||
|
overlays = [ (import rust-overlay) ];
|
||||||
|
pkgs = import nixpkgs {
|
||||||
|
inherit system overlays;
|
||||||
|
};
|
||||||
|
|
||||||
|
rustToolchain = pkgs.rust-bin.stable.latest.default;
|
||||||
|
|
||||||
|
markdownFilter = path: _type: builtins.match ".*md$" path != null;
|
||||||
|
markdownOrCargo = path: type: (markdownFilter path type) || (craneLib.filterCargoSources path type);
|
||||||
|
|
||||||
|
craneLib = (crane.mkLib pkgs).overrideToolchain rustToolchain;
|
||||||
|
src = pkgs.lib.cleanSourceWith {
|
||||||
|
src = craneLib.path ./.;
|
||||||
|
filter = markdownOrCargo;
|
||||||
|
};
|
||||||
|
|
||||||
|
nativeBuildInputs = with pkgs; [ rustToolchain pkg-config ];
|
||||||
|
buildInputs = with pkgs; [ ];
|
||||||
|
|
||||||
|
commonArgs = {
|
||||||
|
inherit src buildInputs nativeBuildInputs;
|
||||||
|
};
|
||||||
|
cargoArtifacts = craneLib.buildDepsOnly commonArgs;
|
||||||
|
|
||||||
|
bin = craneLib.buildPackage (commonArgs // {
|
||||||
|
inherit cargoArtifacts;
|
||||||
|
});
|
||||||
|
|
||||||
|
in
|
||||||
|
with pkgs;
|
||||||
|
{
|
||||||
|
packages = {
|
||||||
|
inherit bin;
|
||||||
|
default = bin;
|
||||||
|
};
|
||||||
|
devShells.default = mkShell {
|
||||||
|
inputsFrom = [ bin ];
|
||||||
|
};
|
||||||
|
|
||||||
|
hydraJobs."build" = bin;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
1
result
Symbolic link
1
result
Symbolic link
|
@ -0,0 +1 @@
|
||||||
|
/nix/store/5y25qhj42iskjzj2pyr1lyd5an76y0z2-bin-0.1.0
|
45
src/error.rs
Normal file
45
src/error.rs
Normal file
|
@ -0,0 +1,45 @@
|
||||||
|
use axum::{http::StatusCode, response::IntoResponse};
|
||||||
|
use log::error;
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum Error {
|
||||||
|
#[error("io error: {:?}", 0)]
|
||||||
|
Io(#[from] std::io::Error),
|
||||||
|
#[error("time error: {:?}", 0)]
|
||||||
|
Time(#[from] std::time::SystemTimeError),
|
||||||
|
#[error("metadata error: {:?}", 0)]
|
||||||
|
MetadataDe(#[from] toml::de::Error),
|
||||||
|
#[error("metadata error: {:?}", 0)]
|
||||||
|
MetadataSer(#[from] toml::ser::Error),
|
||||||
|
#[error("phrase is not valid")]
|
||||||
|
PhraseInvalid,
|
||||||
|
#[error("bin could not be found")]
|
||||||
|
BinNotFound,
|
||||||
|
#[error("file exists")]
|
||||||
|
DataFileExists,
|
||||||
|
|
||||||
|
#[error("hex error: {:?}", 0)]
|
||||||
|
Hex(#[from] hex::FromHexError),
|
||||||
|
|
||||||
|
#[error("could not parse ttl")]
|
||||||
|
ParseTtl,
|
||||||
|
|
||||||
|
#[error("encryption error")]
|
||||||
|
ChaCha,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntoResponse for Error {
|
||||||
|
fn into_response(self) -> axum::response::Response {
|
||||||
|
match self {
|
||||||
|
Self::PhraseInvalid => (StatusCode::BAD_REQUEST, "phrase is not valid"),
|
||||||
|
Self::BinNotFound => (StatusCode::NOT_FOUND, "bin does not exist"),
|
||||||
|
Self::DataFileExists => (StatusCode::CONFLICT, "bin already has data"),
|
||||||
|
Self::ParseTtl => (StatusCode::BAD_REQUEST, "invalid ttl class"),
|
||||||
|
_ => {
|
||||||
|
error!("{:?}", self);
|
||||||
|
(StatusCode::INTERNAL_SERVER_ERROR, "internal server error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
}
|
108
src/garbage_collector.rs
Normal file
108
src/garbage_collector.rs
Normal file
|
@ -0,0 +1,108 @@
|
||||||
|
use std::{
|
||||||
|
collections::BinaryHeap,
|
||||||
|
sync::Arc,
|
||||||
|
time::{Duration, SystemTime, UNIX_EPOCH},
|
||||||
|
};
|
||||||
|
|
||||||
|
use log::{debug, info, warn};
|
||||||
|
use tokio::{fs, sync::Mutex};
|
||||||
|
|
||||||
|
use crate::{metadata::Metadata, util::Id};
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub(crate) struct GarbageCollector {
|
||||||
|
heap: Arc<Mutex<BinaryHeap<GarbageCollectorItem>>>,
|
||||||
|
path: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GarbageCollector {
|
||||||
|
pub(crate) async fn new(path: String) -> Self {
|
||||||
|
let mut heap = BinaryHeap::new();
|
||||||
|
let mut dir = fs::read_dir(&path).await.expect("readable data dir");
|
||||||
|
while let Ok(Some(entry)) = dir.next_entry().await {
|
||||||
|
let file_name = entry.file_name();
|
||||||
|
let file_name = file_name.to_string_lossy();
|
||||||
|
if let Some(id) = file_name.strip_suffix(".toml") {
|
||||||
|
if let Ok(metadata) = Metadata::from_file(&format!("./data/{}.toml", id)).await {
|
||||||
|
heap.push(GarbageCollectorItem {
|
||||||
|
expires_at: metadata.expires_at,
|
||||||
|
id: Id::from_str(id),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Self {
|
||||||
|
heap: Arc::new(Mutex::new(heap)),
|
||||||
|
path,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn schedule(&self, id: &Id, expires_at: u64) {
|
||||||
|
self.heap.lock().await.push(GarbageCollectorItem {
|
||||||
|
id: id.clone(),
|
||||||
|
expires_at,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn run(&self) {
|
||||||
|
let mut heap = self.heap.lock().await;
|
||||||
|
|
||||||
|
let now = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.expect("time after EPOCH")
|
||||||
|
.as_secs();
|
||||||
|
|
||||||
|
// skip if first item is not old enough to be deleted
|
||||||
|
if let Some(true) = heap.peek().map(|x| x.expires_at > now) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Some(item) = heap.pop() {
|
||||||
|
if let Ok(metadata) =
|
||||||
|
Metadata::from_file(&format!("{}/{}.toml", self.path, item.id)).await
|
||||||
|
{
|
||||||
|
// check metadata if the item is really ready for deletion (needed for reschedule
|
||||||
|
// population of bin with data)
|
||||||
|
if metadata.expires_at <= now {
|
||||||
|
debug!("deleting bin {}", item.id);
|
||||||
|
let res_meta =
|
||||||
|
fs::remove_file(&format!("{}/{}.toml", self.path, item.id)).await;
|
||||||
|
let res_data = fs::remove_file(&format!("{}/{}.dat", self.path, item.id)).await;
|
||||||
|
|
||||||
|
if res_meta.is_err() || res_data.is_err() {
|
||||||
|
warn!("failed to delete bin {} for gc", item.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
info!("cant open metadata file for bin {} for gc", item.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn spawn(&self) {
|
||||||
|
let gc = self.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
loop {
|
||||||
|
tokio::time::sleep(Duration::from_secs(10)).await;
|
||||||
|
gc.run().await;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(PartialEq, Eq)]
|
||||||
|
struct GarbageCollectorItem {
|
||||||
|
id: Id,
|
||||||
|
expires_at: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PartialOrd for GarbageCollectorItem {
|
||||||
|
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
|
||||||
|
Some(self.cmp(other))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Ord for GarbageCollectorItem {
|
||||||
|
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
|
||||||
|
self.expires_at.cmp(&other.expires_at).reverse()
|
||||||
|
}
|
||||||
|
}
|
|
@ -2,9 +2,7 @@
|
||||||
An empty bin was created for you. The first HTTP POST or PUT request can upload data.
|
An empty bin was created for you. The first HTTP POST or PUT request can upload data.
|
||||||
All following requests can only read the uploaded data.
|
All following requests can only read the uploaded data.
|
||||||
|
|
||||||
To use the build-in link-shortener functionality you have to POST or PUT the URL with Content-Type: text/x-uri or Content-Type: text/uri-list.
|
To change the default expiration date, you can use the `?ttl=<seconds_to_live>` parameter.
|
||||||
|
|
||||||
To change the default expiration date, you can use the `?ttl=` parameter. The following expiry classes are defined: <lifecycle_classes>.
|
|
||||||
|
|
||||||
## Upload a link
|
## Upload a link
|
||||||
`$ curl -H "Content-Type: text/x-uri" --data "https://example.com" <bin_url>`
|
`$ curl -H "Content-Type: text/x-uri" --data "https://example.com" <bin_url>`
|
||||||
|
@ -27,4 +25,3 @@ $ curl <bin_url> | gpg -d - | tar -xzf
|
||||||
|
|
||||||
## Accessing the data
|
## Accessing the data
|
||||||
After uploading data you can access it by accessing <bin_url> with an optional file extension that suits the data that you uploaded.
|
After uploading data you can access it by accessing <bin_url> with an optional file extension that suits the data that you uploaded.
|
||||||
If the bin is a link you will get redirected.
|
|
||||||
|
|
400
src/main.rs
400
src/main.rs
|
@ -1,13 +1,13 @@
|
||||||
|
#![deny(clippy::unwrap_used)]
|
||||||
use std::{
|
use std::{
|
||||||
collections::HashSet,
|
borrow::Borrow,
|
||||||
env,
|
env,
|
||||||
io::ErrorKind,
|
str::FromStr,
|
||||||
task::{Context, Poll},
|
time::{SystemTime, UNIX_EPOCH},
|
||||||
time::Duration,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
body::{Bytes, StreamBody},
|
body::StreamBody,
|
||||||
debug_handler,
|
debug_handler,
|
||||||
extract::{BodyStream, FromRef, Path, Query, State},
|
extract::{BodyStream, FromRef, Path, Query, State},
|
||||||
headers::ContentType,
|
headers::ContentType,
|
||||||
|
@ -16,62 +16,49 @@ use axum::{
|
||||||
routing::get,
|
routing::get,
|
||||||
Router, TypedHeader,
|
Router, TypedHeader,
|
||||||
};
|
};
|
||||||
use axum_oidc::{ClaimsExtractor, EmptyAdditionalClaims, Key, OidcApplication};
|
use axum_oidc::oidc::{self, EmptyAdditionalClaims, OidcApplication, OidcExtractor};
|
||||||
use futures_util::{Stream, StreamExt, TryStreamExt};
|
use chacha20::{
|
||||||
use log::{info, warn};
|
cipher::{KeyIvInit, StreamCipher},
|
||||||
use rand::{distributions::Alphanumeric, Rng};
|
ChaCha20,
|
||||||
|
};
|
||||||
|
use futures_util::StreamExt;
|
||||||
|
use garbage_collector::GarbageCollector;
|
||||||
|
use log::debug;
|
||||||
use render::{html, raw};
|
use render::{html, raw};
|
||||||
use s3::{creds::Credentials, error::S3Error, request::ResponseDataStream, Bucket};
|
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use tokio_util::io::StreamReader;
|
use sha3::{Digest, Sha3_256};
|
||||||
|
use tokio::{
|
||||||
|
fs::File,
|
||||||
|
io::{AsyncWriteExt, BufReader, BufWriter},
|
||||||
|
};
|
||||||
|
use util::{IdSalt, KeySalt};
|
||||||
|
|
||||||
// RFC 7230 section 3.1.1
|
use crate::{
|
||||||
// It is RECOMMENDED that all HTTP senders and recipients
|
error::Error,
|
||||||
// support, at a minimum, request-line lengths of 8000 octets.
|
metadata::Metadata,
|
||||||
const HTTP_URL_MAXLENGTH: i64 = 8000;
|
util::{Id, Key, Nonce, Phrase},
|
||||||
|
web_util::DecryptingStream,
|
||||||
|
};
|
||||||
|
mod error;
|
||||||
|
mod garbage_collector;
|
||||||
|
mod metadata;
|
||||||
|
mod util;
|
||||||
|
mod web_util;
|
||||||
|
|
||||||
// support the RFC2483 with text/uri-list and the inofficial text/x-uri mimetype
|
/// length of the "phrase" that is used to access the bin (https://example.com/<phrase>)
|
||||||
const HTTP_URL_MIMETYPES: [&str; 2] = ["text/x-uri", "text/uri-list"];
|
const PHRASE_LENGTH: usize = 16;
|
||||||
|
/// length of the salts that are used to generate the key for chacha and id for the files
|
||||||
|
const SALT_LENGTH: usize = 256 - PHRASE_LENGTH;
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
|
||||||
enum Error {
|
|
||||||
#[error("s3 error: {:?}", 1)]
|
|
||||||
S3(#[from] S3Error),
|
|
||||||
#[error("url is invalid utf8")]
|
|
||||||
UrlUtf8Invalid,
|
|
||||||
#[error("item could not be found")]
|
|
||||||
ItemNotFound,
|
|
||||||
#[error("file exists")]
|
|
||||||
DataFileExists,
|
|
||||||
|
|
||||||
#[error("could not parse ttl")]
|
|
||||||
ParseTtl,
|
|
||||||
}
|
|
||||||
type HandlerResult<T> = Result<T, Error>;
|
type HandlerResult<T> = Result<T, Error>;
|
||||||
|
|
||||||
impl IntoResponse for Error {
|
|
||||||
fn into_response(self) -> axum::response::Response {
|
|
||||||
match self {
|
|
||||||
Self::S3(e) => {
|
|
||||||
warn!("S3 Error: {:?}", e);
|
|
||||||
(StatusCode::INTERNAL_SERVER_ERROR, "s3 error")
|
|
||||||
}
|
|
||||||
Self::UrlUtf8Invalid => (StatusCode::INTERNAL_SERVER_ERROR, "url is not valid utf8"),
|
|
||||||
Self::ItemNotFound => (StatusCode::NOT_FOUND, "bin could not be found"),
|
|
||||||
Self::DataFileExists => (StatusCode::CONFLICT, "bin already has data"),
|
|
||||||
Self::ParseTtl => (StatusCode::BAD_REQUEST, "invalid ttl class"),
|
|
||||||
}
|
|
||||||
.into_response()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
application_base: String,
|
application_base: String,
|
||||||
oidc_application: OidcApplication<EmptyAdditionalClaims>,
|
oidc_application: OidcApplication<EmptyAdditionalClaims>,
|
||||||
bucket: Bucket,
|
data: String,
|
||||||
lifecycle_classes: HashSet<String>,
|
key_salt: KeySalt,
|
||||||
default_lifecycle_class: String,
|
id_salt: IdSalt,
|
||||||
|
garbage_collector: GarbageCollector,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FromRef<AppState> for OidcApplication<EmptyAdditionalClaims> {
|
impl FromRef<AppState> for OidcApplication<EmptyAdditionalClaims> {
|
||||||
|
@ -80,10 +67,16 @@ impl FromRef<AppState> for OidcApplication<EmptyAdditionalClaims> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(PartialEq, Eq, PartialOrd, Ord, Debug)]
|
||||||
|
pub struct ExpiryHeapItem {
|
||||||
|
pub expires_at: u64,
|
||||||
|
pub id: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() {
|
async fn main() {
|
||||||
dotenvy::dotenv().ok();
|
dotenvy::dotenv().ok();
|
||||||
pretty_env_logger::init();
|
env_logger::init();
|
||||||
|
|
||||||
let application_base = env::var("APPLICATION_BASE").expect("APPLICATION_BASE env var");
|
let application_base = env::var("APPLICATION_BASE").expect("APPLICATION_BASE env var");
|
||||||
let issuer = env::var("ISSUER").expect("ISSUER env var");
|
let issuer = env::var("ISSUER").expect("ISSUER env var");
|
||||||
|
@ -96,204 +89,167 @@ async fn main() {
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
let oidc_application = OidcApplication::<EmptyAdditionalClaims>::create(
|
let oidc_application = OidcApplication::<EmptyAdditionalClaims>::create(
|
||||||
application_base.parse().unwrap(),
|
application_base
|
||||||
|
.parse()
|
||||||
|
.expect("valid APPLICATION_BASE url"),
|
||||||
issuer.to_string(),
|
issuer.to_string(),
|
||||||
client_id.to_string(),
|
client_id.to_string(),
|
||||||
client_secret.to_owned(),
|
client_secret.to_owned(),
|
||||||
scopes.clone(),
|
scopes.clone(),
|
||||||
Key::generate(),
|
oidc::Key::generate(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.expect("Oidc Authentication Client");
|
||||||
|
|
||||||
let bucket = Bucket::new(
|
let garbage_collector = GarbageCollector::new("./data".to_string()).await;
|
||||||
&env::var("S3_BUCKET").expect("S3_BUCKET env var"),
|
garbage_collector.spawn();
|
||||||
env::var("S3_REGION")
|
|
||||||
.expect("S3_REGION env var")
|
|
||||||
.parse()
|
|
||||||
.unwrap(),
|
|
||||||
Credentials::new(
|
|
||||||
Some(&env::var("S3_ACCESS_KEY").expect("S3_ACCESS_KEY env var")),
|
|
||||||
Some(&env::var("S3_SECRET_KEY").expect("S3_SECRET_KEY env var")),
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
.unwrap(),
|
|
||||||
)
|
|
||||||
.unwrap()
|
|
||||||
.with_path_style()
|
|
||||||
.with_request_timeout(Duration::from_secs(60 * 60 * 6));
|
|
||||||
|
|
||||||
let lifecycle_classes = std::env::var("LIFECYCLE_CLASSES")
|
|
||||||
.expect("LIFECYCLE_CLASSES env var")
|
|
||||||
.split(',')
|
|
||||||
.map(|x| x.to_string())
|
|
||||||
.collect::<HashSet<String>>();
|
|
||||||
let default_lifecycle_class =
|
|
||||||
std::env::var("DEFAULT_LIFECYCLE_CLASS").expect("DEFAULT_LIFECYCLE_CLASS env var");
|
|
||||||
if !lifecycle_classes.contains(&default_lifecycle_class) {
|
|
||||||
panic!("DEFAULT_LIFECYCLE_CLASS must be an element of LIFECYCLE_CLASSES");
|
|
||||||
}
|
|
||||||
|
|
||||||
let state: AppState = AppState {
|
let state: AppState = AppState {
|
||||||
application_base,
|
application_base,
|
||||||
oidc_application,
|
oidc_application,
|
||||||
bucket,
|
data: "./data".to_string(),
|
||||||
lifecycle_classes,
|
key_salt: KeySalt::from_str(&env::var("KEY_SALT").expect("KEY_SALT env var"))
|
||||||
default_lifecycle_class,
|
.expect("KEY SALT valid hex"),
|
||||||
|
id_salt: IdSalt::from_str(&env::var("ID_SALT").expect("ID_SALT env var"))
|
||||||
|
.expect("ID_SALT valid hex"),
|
||||||
|
garbage_collector,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// when the two salts are identical, the bin id and the bin key are also identical, this would
|
||||||
|
// make the encryption useless
|
||||||
|
assert_ne!(state.key_salt.raw(), state.id_salt.raw());
|
||||||
|
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
.route("/", get(get_index))
|
.route("/", get(get_index))
|
||||||
.route("/:id", get(get_item).post(post_item).put(post_item))
|
.route("/:id", get(get_item).post(post_item).put(post_item))
|
||||||
.with_state(state);
|
.with_state(state);
|
||||||
axum::Server::bind(&"[::]:3000".parse().unwrap())
|
axum::Server::bind(&"[::]:8080".parse().expect("valid listen address"))
|
||||||
.serve(app.into_make_service())
|
.serve(app.into_make_service())
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.expect("Axum Server");
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_index(
|
async fn get_index(
|
||||||
State(app_state): State<AppState>,
|
State(app_state): State<AppState>,
|
||||||
ClaimsExtractor(claims): ClaimsExtractor<EmptyAdditionalClaims>,
|
OidcExtractor { claims, .. }: OidcExtractor<EmptyAdditionalClaims>,
|
||||||
) -> Result<impl IntoResponse, Error> {
|
) -> Result<impl IntoResponse, Error> {
|
||||||
let subject = claims.subject().to_string();
|
let subject = claims.subject().to_string();
|
||||||
|
|
||||||
//generate id
|
//generate phrase and derive id from it
|
||||||
let id = rand::thread_rng()
|
let phrase = Phrase::random();
|
||||||
.sample_iter(Alphanumeric)
|
let id = Id::from_phrase(&phrase, &app_state.id_salt);
|
||||||
.take(8)
|
|
||||||
.map(char::from)
|
|
||||||
.collect::<String>();
|
|
||||||
|
|
||||||
app_state.bucket.put_object(&id, &[]).await?;
|
let nonce = Nonce::random();
|
||||||
app_state
|
|
||||||
.bucket
|
|
||||||
.put_object_tagging(
|
|
||||||
&id,
|
|
||||||
&[
|
|
||||||
("ttl".to_string(), app_state.default_lifecycle_class.clone()),
|
|
||||||
("subject".to_string(), subject),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
info!("created bin {id}");
|
let expires_at = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs() + 24 * 3600;
|
||||||
|
|
||||||
|
let metadata_path = format!("{}/{}.toml", app_state.data, id);
|
||||||
|
let metadata = Metadata {
|
||||||
|
subject,
|
||||||
|
nonce: nonce.to_hex(),
|
||||||
|
etag: None,
|
||||||
|
size: None,
|
||||||
|
content_type: None,
|
||||||
|
expires_at,
|
||||||
|
};
|
||||||
|
metadata.to_file(&metadata_path).await?;
|
||||||
|
|
||||||
|
app_state.garbage_collector.schedule(&id, expires_at).await;
|
||||||
|
|
||||||
|
debug!("created bin {id}");
|
||||||
|
|
||||||
Ok(Redirect::temporary(&format!(
|
Ok(Redirect::temporary(&format!(
|
||||||
"{}{}",
|
"{}{}",
|
||||||
app_state.application_base, id
|
app_state.application_base, phrase
|
||||||
)))
|
)))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct PostQuery {
|
pub struct PostQuery {
|
||||||
ttl: Option<String>,
|
ttl: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn post_item(
|
async fn post_item(
|
||||||
Path(id): Path<String>,
|
Path(phrase): Path<String>,
|
||||||
Query(params): Query<PostQuery>,
|
Query(params): Query<PostQuery>,
|
||||||
State(app_state): State<AppState>,
|
State(app_state): State<AppState>,
|
||||||
content_type: Option<TypedHeader<ContentType>>,
|
content_type: Option<TypedHeader<ContentType>>,
|
||||||
stream: BodyStream,
|
mut stream: BodyStream,
|
||||||
) -> HandlerResult<impl IntoResponse> {
|
) -> HandlerResult<impl IntoResponse> {
|
||||||
let id = sanitize_id(id);
|
let phrase = Phrase::from_str(&phrase)?;
|
||||||
|
let id = Id::from_phrase(&phrase, &app_state.id_salt);
|
||||||
|
|
||||||
let metadata = app_state.bucket.head_object(&id).await?.0;
|
let metadata_path = format!("{}/{}.toml", app_state.data, id);
|
||||||
|
let mut metadata = Metadata::from_file(&metadata_path).await?;
|
||||||
|
|
||||||
if metadata.e_tag.is_none() {
|
let path = format!("{}/{}.dat", app_state.data, id);
|
||||||
return Err(Error::ItemNotFound);
|
let path = std::path::Path::new(&path);
|
||||||
}
|
|
||||||
if let Some(content_length) = metadata.content_length {
|
if !path.exists() {
|
||||||
if content_length > 0 {
|
let key = Key::from_phrase(&phrase, &app_state.key_salt);
|
||||||
return Err(Error::DataFileExists);
|
let nonce = Nonce::from_hex(&metadata.nonce)?;
|
||||||
}
|
let mut cipher = ChaCha20::new(key.borrow(), nonce.borrow());
|
||||||
|
|
||||||
|
let file = File::create(&path).await?;
|
||||||
|
let mut writer = BufWriter::new(file);
|
||||||
|
|
||||||
|
let mut etag_hasher = Sha3_256::new();
|
||||||
|
let mut size = 0;
|
||||||
|
|
||||||
|
while let Some(chunk) = stream.next().await {
|
||||||
|
let mut buf = chunk.unwrap_or_default().to_vec();
|
||||||
|
etag_hasher.update(&buf);
|
||||||
|
size += buf.len() as u64;
|
||||||
|
cipher.apply_keystream(&mut buf);
|
||||||
|
writer.write_all(&buf).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
let ttl = if let Some(ttl) = ¶ms.ttl {
|
writer.flush().await?;
|
||||||
if !app_state.lifecycle_classes.contains(ttl) {
|
|
||||||
return Err(Error::ParseTtl);
|
metadata.etag = Some(hex::encode(etag_hasher.finalize()));
|
||||||
}
|
metadata.size = Some(size);
|
||||||
ttl
|
metadata.content_type = match content_type {
|
||||||
} else {
|
Some(content_type) => Some(content_type.to_string()),
|
||||||
&app_state.default_lifecycle_class
|
None => Some("application/octet-stream".to_string()),
|
||||||
};
|
};
|
||||||
|
metadata.expires_at = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs()
|
||||||
|
+ params.ttl.unwrap_or(24 * 3600);
|
||||||
|
metadata.to_file(&metadata_path).await?;
|
||||||
|
|
||||||
let tags = app_state.bucket.get_object_tagging(&id).await?.0;
|
|
||||||
|
|
||||||
let mut reader =
|
|
||||||
StreamReader::new(stream.map_err(|e| std::io::Error::new(ErrorKind::Other, e.to_string())));
|
|
||||||
let status_code = match content_type {
|
|
||||||
Some(content_type) => {
|
|
||||||
app_state
|
app_state
|
||||||
.bucket
|
.garbage_collector
|
||||||
.put_object_stream_with_content_type(&mut reader, &id, &content_type.to_string())
|
.schedule(&id, metadata.expires_at)
|
||||||
.await
|
.await;
|
||||||
|
|
||||||
|
debug!("bin {id} got filled");
|
||||||
|
|
||||||
|
Ok((StatusCode::OK, "ok\n"))
|
||||||
|
} else {
|
||||||
|
Err(Error::DataFileExists)
|
||||||
}
|
}
|
||||||
None => app_state.bucket.put_object_stream(&mut reader, &id).await,
|
|
||||||
}?;
|
|
||||||
|
|
||||||
let status_code = StatusCode::from_u16(status_code).unwrap();
|
|
||||||
|
|
||||||
let subject = tags
|
|
||||||
.iter()
|
|
||||||
.find(|x| x.key() == "subject")
|
|
||||||
.map(|x| x.value())
|
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
app_state
|
|
||||||
.bucket
|
|
||||||
.put_object_tagging(
|
|
||||||
&id,
|
|
||||||
&[
|
|
||||||
("ttl".to_string(), ttl.to_string()),
|
|
||||||
("subject".to_string(), subject),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
info!("bin {id} is now read only");
|
|
||||||
|
|
||||||
Ok((
|
|
||||||
status_code,
|
|
||||||
format!(
|
|
||||||
"{}\n",
|
|
||||||
status_code
|
|
||||||
.canonical_reason()
|
|
||||||
.unwrap_or(&status_code.to_string())
|
|
||||||
),
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[debug_handler]
|
#[debug_handler]
|
||||||
async fn get_item(
|
async fn get_item(
|
||||||
Path(id): Path<String>,
|
Path(phrase): Path<String>,
|
||||||
State(app_state): State<AppState>,
|
State(app_state): State<AppState>,
|
||||||
) -> HandlerResult<impl IntoResponse> {
|
) -> HandlerResult<impl IntoResponse> {
|
||||||
let id = sanitize_id(id);
|
let phrase = Phrase::from_str(&phrase)?;
|
||||||
|
let id = Id::from_phrase(&phrase, &app_state.id_salt);
|
||||||
|
|
||||||
let metadata = app_state.bucket.head_object(&id).await?.0;
|
let metadata = Metadata::from_file(&format!("{}/{}.toml", app_state.data, id)).await?;
|
||||||
|
|
||||||
if metadata.e_tag.is_none() {
|
let path = format!("{}/{}.dat", app_state.data, id);
|
||||||
return Err(Error::ItemNotFound);
|
|
||||||
}
|
let key = Key::from_phrase(&phrase, &app_state.key_salt);
|
||||||
if metadata.content_length.is_none() || metadata.content_length == Some(0) {
|
let nonce = Nonce::from_hex(&metadata.nonce)?;
|
||||||
let body = include_str!("item_explanation.md")
|
|
||||||
.replace(
|
if std::fs::metadata(&path).is_err() {
|
||||||
|
let body = include_str!("item_explanation.md").replace(
|
||||||
"<bin_url>",
|
"<bin_url>",
|
||||||
&format!("{}{}", app_state.application_base, id),
|
&format!("{}{}", app_state.application_base, phrase),
|
||||||
)
|
|
||||||
.replace(
|
|
||||||
"<lifecycle_classes>",
|
|
||||||
&app_state
|
|
||||||
.lifecycle_classes
|
|
||||||
.iter()
|
|
||||||
.map(|x| x.to_string())
|
|
||||||
.reduce(|acc, e| acc + ", " + e.as_str())
|
|
||||||
.unwrap_or_default(),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
let body = markdown::to_html(&body);
|
let body = markdown::to_html(&body);
|
||||||
let body = html! {
|
let body = html! {
|
||||||
<html>
|
<html>
|
||||||
|
@ -309,70 +265,32 @@ async fn get_item(
|
||||||
</html>
|
</html>
|
||||||
};
|
};
|
||||||
Ok((StatusCode::ACCEPTED, Html(body)).into_response())
|
Ok((StatusCode::ACCEPTED, Html(body)).into_response())
|
||||||
} else if let Some(content_length) = metadata.content_length {
|
|
||||||
if HTTP_URL_MIMETYPES.contains(&metadata.content_type.as_deref().unwrap_or(""))
|
|
||||||
&& content_length <= HTTP_URL_MAXLENGTH
|
|
||||||
{
|
|
||||||
let file = app_state.bucket.get_object(&id).await?;
|
|
||||||
let url = String::from_utf8(file.to_vec()).map_err(|_| Error::UrlUtf8Invalid)?;
|
|
||||||
|
|
||||||
// Use the first line that doesn't start with a # to be compliant with RFC2483.
|
|
||||||
let url = url.lines().find(|x| !x.starts_with('#')).unwrap_or("");
|
|
||||||
|
|
||||||
Ok(Redirect::temporary(url).into_response())
|
|
||||||
} else {
|
} else {
|
||||||
let file_stream = app_state.bucket.get_object_stream(&id).await.unwrap();
|
//TODO(pfz4): Maybe add link handling
|
||||||
|
let file = File::open(&path).await?;
|
||||||
|
let reader = BufReader::new(file);
|
||||||
|
|
||||||
let body = StreamBody::new(ResponseStream(std::sync::Mutex::new(file_stream)));
|
let body = StreamBody::new(DecryptingStream::new(reader, id, &metadata, &key, &nonce));
|
||||||
|
|
||||||
let mut headers = HeaderMap::new();
|
let mut headers = HeaderMap::new();
|
||||||
headers.insert(
|
headers.insert(
|
||||||
header::CONTENT_LENGTH,
|
header::CONTENT_LENGTH,
|
||||||
metadata.content_length.unwrap().into(),
|
metadata.size.unwrap_or_default().into(),
|
||||||
);
|
);
|
||||||
|
|
||||||
if let Some(content_type) = metadata.content_type {
|
if let Some(content_type) = metadata.content_type.and_then(|x| x.parse().ok()) {
|
||||||
headers.insert(header::CONTENT_TYPE, content_type.parse().unwrap());
|
headers.insert(header::CONTENT_TYPE, content_type);
|
||||||
}
|
}
|
||||||
if let Some(etag) = metadata.e_tag {
|
if let Some(etag) = metadata.etag.clone().and_then(|x| x.parse().ok()) {
|
||||||
headers.insert(header::ETAG, etag.parse().unwrap());
|
headers.insert(header::ETAG, etag);
|
||||||
}
|
}
|
||||||
if let Some(content_encoding) = metadata.content_encoding {
|
if let Some(digest) = metadata
|
||||||
headers.insert(header::CONTENT_ENCODING, content_encoding.parse().unwrap());
|
.etag
|
||||||
}
|
.and_then(|x| format!("sha3-256={x}").parse().ok())
|
||||||
if let Some(content_language) = metadata.content_language {
|
{
|
||||||
headers.insert(header::CONTENT_LANGUAGE, content_language.parse().unwrap());
|
headers.insert("Digest", digest);
|
||||||
}
|
|
||||||
if let Some(cache_control) = metadata.cache_control {
|
|
||||||
headers.insert(header::CACHE_CONTROL, cache_control.parse().unwrap());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok((StatusCode::OK, headers, body).into_response())
|
Ok((StatusCode::OK, headers, body).into_response())
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
// logically should not happen
|
|
||||||
panic!("logic contradiction");
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
fn sanitize_id(id: String) -> String {
|
|
||||||
id.chars()
|
|
||||||
.take_while(|c| *c != '.')
|
|
||||||
.filter(|c| c.is_ascii_alphanumeric())
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ResponseStream(std::sync::Mutex<ResponseDataStream>);
|
|
||||||
impl Stream for ResponseStream {
|
|
||||||
type Item = Result<Bytes, Error>;
|
|
||||||
|
|
||||||
fn poll_next(self: std::pin::Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
|
|
||||||
self.0
|
|
||||||
.lock()
|
|
||||||
.unwrap()
|
|
||||||
.bytes()
|
|
||||||
.poll_next_unpin(cx)
|
|
||||||
.map(|x| x.map(Ok))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
unsafe impl Send for ResponseStream {}
|
|
||||||
|
|
33
src/metadata.rs
Normal file
33
src/metadata.rs
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
use std::{io::ErrorKind, time::Instant};
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tokio::{fs::File, io::AsyncWriteExt};
|
||||||
|
|
||||||
|
use crate::Error;
|
||||||
|
|
||||||
|
#[derive(Deserialize, Serialize, Debug)]
|
||||||
|
pub struct Metadata {
|
||||||
|
pub subject: String,
|
||||||
|
pub nonce: String,
|
||||||
|
pub etag: Option<String>,
|
||||||
|
pub size: Option<u64>,
|
||||||
|
pub content_type: Option<String>,
|
||||||
|
pub expires_at: u64, // seconds since UNIX_EPOCH
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Metadata {
|
||||||
|
pub async fn from_file(path: &str) -> Result<Self, Error> {
|
||||||
|
let metadata = match tokio::fs::read_to_string(path).await {
|
||||||
|
Ok(x) => Ok(x),
|
||||||
|
Err(err) if err.kind() == ErrorKind::NotFound => Err(Error::BinNotFound),
|
||||||
|
Err(x) => Err(x.into()),
|
||||||
|
}?;
|
||||||
|
Ok(toml::from_str::<Self>(&metadata)?)
|
||||||
|
}
|
||||||
|
pub async fn to_file(&self, path: &str) -> Result<(), Error> {
|
||||||
|
let data = toml::to_string(&self)?;
|
||||||
|
let mut file = File::create(path).await?;
|
||||||
|
file.write_all(data.as_ref()).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
238
src/util.rs
Normal file
238
src/util.rs
Normal file
|
@ -0,0 +1,238 @@
|
||||||
|
use std::{borrow::Borrow, fmt::Display, str::FromStr};
|
||||||
|
|
||||||
|
use chacha20::cipher::{generic_array::GenericArray, ArrayLength};
|
||||||
|
use rand::{distributions, Rng};
|
||||||
|
use sha3::{Digest, Sha3_256};
|
||||||
|
|
||||||
|
use crate::{error::Error, PHRASE_LENGTH, SALT_LENGTH};
|
||||||
|
|
||||||
|
#[derive(Debug, PartialEq)]
|
||||||
|
pub(crate) struct Phrase(String);
|
||||||
|
#[derive(Debug, PartialEq, Eq, Clone)]
|
||||||
|
pub(crate) struct Id(String);
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub(crate) struct IdSalt(Vec<u8>);
|
||||||
|
#[derive(Debug, PartialEq)]
|
||||||
|
pub(crate) struct Key(Vec<u8>);
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub(crate) struct KeySalt(Vec<u8>);
|
||||||
|
#[derive(Debug, PartialEq)]
|
||||||
|
pub(crate) struct Nonce(Vec<u8>);
|
||||||
|
|
||||||
|
impl Phrase {
|
||||||
|
pub(crate) fn random() -> Self {
|
||||||
|
let phrase = rand::thread_rng()
|
||||||
|
.sample_iter(distributions::Alphanumeric)
|
||||||
|
.take(PHRASE_LENGTH)
|
||||||
|
.map(char::from)
|
||||||
|
.collect::<String>();
|
||||||
|
Self(phrase)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl FromStr for Phrase {
|
||||||
|
type Err = Error;
|
||||||
|
|
||||||
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||||
|
if s.chars().any(|x| !x.is_ascii_alphanumeric()) {
|
||||||
|
Err(Error::PhraseInvalid)
|
||||||
|
} else {
|
||||||
|
Ok(Self(s.to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl Display for Phrase {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
self.0.fmt(f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Id {
|
||||||
|
pub(crate) fn from_phrase(phrase: &Phrase, salt: &IdSalt) -> Self {
|
||||||
|
let mut hasher = Sha3_256::new();
|
||||||
|
hasher.update(&phrase.0);
|
||||||
|
hasher.update(&salt.0);
|
||||||
|
|
||||||
|
let id = hex::encode(hasher.finalize());
|
||||||
|
Self(id)
|
||||||
|
}
|
||||||
|
pub(crate) fn from_str(s: &str) -> Self {
|
||||||
|
Self(s.to_string())
|
||||||
|
}
|
||||||
|
pub(crate) fn raw(&self) -> &str {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl Display for Id {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
self.0.fmt(f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IdSalt {
|
||||||
|
pub(crate) fn random() -> Self {
|
||||||
|
let salt = rand::thread_rng()
|
||||||
|
.sample_iter(distributions::Standard)
|
||||||
|
.take(SALT_LENGTH)
|
||||||
|
.collect();
|
||||||
|
Self(salt)
|
||||||
|
}
|
||||||
|
pub(crate) fn raw(&self) -> &[u8] {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl FromStr for IdSalt {
|
||||||
|
type Err = hex::FromHexError;
|
||||||
|
|
||||||
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||||
|
Ok(Self(hex::decode(s)?))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Key {
|
||||||
|
pub(crate) fn from_phrase(phrase: &Phrase, salt: &KeySalt) -> Self {
|
||||||
|
let mut hasher = Sha3_256::new();
|
||||||
|
hasher.update(&phrase.0);
|
||||||
|
hasher.update(&salt.0);
|
||||||
|
Self(hasher.finalize().to_vec())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<L: ArrayLength<u8>> Borrow<GenericArray<u8, L>> for Key {
|
||||||
|
fn borrow(&self) -> &GenericArray<u8, L> {
|
||||||
|
GenericArray::<u8, L>::from_slice(&self.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl KeySalt {
|
||||||
|
pub(crate) fn random() -> Self {
|
||||||
|
let salt = rand::thread_rng()
|
||||||
|
.sample_iter(distributions::Standard)
|
||||||
|
.take(SALT_LENGTH)
|
||||||
|
.collect();
|
||||||
|
Self(salt)
|
||||||
|
}
|
||||||
|
pub(crate) fn raw(&self) -> &[u8] {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl FromStr for KeySalt {
|
||||||
|
type Err = hex::FromHexError;
|
||||||
|
|
||||||
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||||
|
Ok(Self(hex::decode(s)?))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Nonce {
|
||||||
|
pub(crate) fn random() -> Self {
|
||||||
|
// generate a 12 byte / 96 bit nonce for chacha20 as defined in rfc7539
|
||||||
|
let nonce = rand::thread_rng()
|
||||||
|
.sample_iter(distributions::Standard)
|
||||||
|
.take(12)
|
||||||
|
.collect();
|
||||||
|
Self(nonce)
|
||||||
|
}
|
||||||
|
pub(crate) fn from_hex(hex_value: &str) -> Result<Self, Error> {
|
||||||
|
Ok(Self(hex::decode(hex_value)?))
|
||||||
|
}
|
||||||
|
pub(crate) fn to_hex(&self) -> String {
|
||||||
|
hex::encode(&self.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<L: ArrayLength<u8>> Borrow<GenericArray<u8, L>> for Nonce {
|
||||||
|
fn borrow(&self) -> &GenericArray<u8, L> {
|
||||||
|
GenericArray::from_slice(&self.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod test {
|
||||||
|
use crate::{
|
||||||
|
util::{Id, IdSalt, Key, KeySalt, Nonce, Phrase},
|
||||||
|
PHRASE_LENGTH, SALT_LENGTH,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn phrase() {
|
||||||
|
assert_eq!(PHRASE_LENGTH, Phrase::random().0.len());
|
||||||
|
assert_ne!(Phrase::random(), Phrase::random());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn id() {
|
||||||
|
let phrase = Phrase::random();
|
||||||
|
let salt = IdSalt::random();
|
||||||
|
let phrase2 = Phrase::random();
|
||||||
|
let salt2 = IdSalt::random();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
Id::from_phrase(&phrase, &salt),
|
||||||
|
Id::from_phrase(&phrase, &salt)
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_ne!(
|
||||||
|
Id::from_phrase(&phrase, &salt),
|
||||||
|
Id::from_phrase(&phrase, &salt2)
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_ne!(
|
||||||
|
Id::from_phrase(&phrase, &salt),
|
||||||
|
Id::from_phrase(&phrase2, &salt)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn key() {
|
||||||
|
let phrase = Phrase::random();
|
||||||
|
let salt = KeySalt::random();
|
||||||
|
let phrase2 = Phrase::random();
|
||||||
|
let salt2 = KeySalt::random();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
Key::from_phrase(&phrase, &salt),
|
||||||
|
Key::from_phrase(&phrase, &salt)
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_ne!(
|
||||||
|
Key::from_phrase(&phrase, &salt),
|
||||||
|
Key::from_phrase(&phrase, &salt2)
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_ne!(
|
||||||
|
Key::from_phrase(&phrase, &salt),
|
||||||
|
Key::from_phrase(&phrase2, &salt)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[allow(clippy::unwrap_used)]
|
||||||
|
fn key_id_collision() {
|
||||||
|
let phrase = Phrase::random();
|
||||||
|
let id_salt = IdSalt::random();
|
||||||
|
let key_salt = KeySalt::random();
|
||||||
|
|
||||||
|
assert_ne!(
|
||||||
|
hex::decode(Id::from_phrase(&phrase, &id_salt).0).unwrap(),
|
||||||
|
Key::from_phrase(&phrase, &key_salt).0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn id_salt() {
|
||||||
|
assert_eq!(SALT_LENGTH, IdSalt::random().0.len());
|
||||||
|
assert_ne!(IdSalt::random(), IdSalt::random());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn key_salt() {
|
||||||
|
assert_eq!(SALT_LENGTH, KeySalt::random().0.len());
|
||||||
|
assert_ne!(KeySalt::random(), KeySalt::random());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nonce() {
|
||||||
|
assert_eq!(12, Nonce::random().0.len());
|
||||||
|
assert_ne!(Nonce::random(), Nonce::random());
|
||||||
|
}
|
||||||
|
}
|
133
src/web_util.rs
Normal file
133
src/web_util.rs
Normal file
|
@ -0,0 +1,133 @@
|
||||||
|
use std::{
|
||||||
|
borrow::Borrow,
|
||||||
|
pin::Pin,
|
||||||
|
task::{Context, Poll},
|
||||||
|
};
|
||||||
|
|
||||||
|
use bytes::{Bytes, BytesMut};
|
||||||
|
use chacha20::{
|
||||||
|
cipher::{KeyIvInit, StreamCipher},
|
||||||
|
ChaCha20,
|
||||||
|
};
|
||||||
|
use futures_util::Stream;
|
||||||
|
use log::{debug, warn};
|
||||||
|
use pin_project_lite::pin_project;
|
||||||
|
use sha3::{Digest, Sha3_256};
|
||||||
|
use tokio::io::AsyncRead;
|
||||||
|
use tokio_util::io::poll_read_buf;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
metadata::Metadata,
|
||||||
|
util::{Id, Key, Nonce},
|
||||||
|
};
|
||||||
|
pin_project! {
|
||||||
|
pub(crate) struct DecryptingStream<R> {
|
||||||
|
#[pin]
|
||||||
|
reader: Option<R>,
|
||||||
|
buf: BytesMut,
|
||||||
|
// chunk size
|
||||||
|
capacity: usize,
|
||||||
|
// chacha20 cipher
|
||||||
|
cipher: ChaCha20,
|
||||||
|
// hasher to verify file integrity
|
||||||
|
hasher: Sha3_256,
|
||||||
|
// hash to verify against
|
||||||
|
target_hash: String,
|
||||||
|
// id of the file for logging purposes
|
||||||
|
id: Id,
|
||||||
|
// total file size
|
||||||
|
size: u64,
|
||||||
|
// current position of the "reading head"
|
||||||
|
progress: u64
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl<R: AsyncRead> DecryptingStream<R> {
|
||||||
|
pub(crate) fn new(reader: R, id: Id, metadata: &Metadata, key: &Key, nonce: &Nonce) -> Self {
|
||||||
|
let cipher = ChaCha20::new(key.borrow(), nonce.borrow());
|
||||||
|
Self {
|
||||||
|
reader: Some(reader),
|
||||||
|
buf: BytesMut::new(),
|
||||||
|
capacity: 1 << 22, // 4 MiB
|
||||||
|
cipher,
|
||||||
|
hasher: Sha3_256::new(),
|
||||||
|
target_hash: metadata.etag.clone().unwrap_or_default(),
|
||||||
|
id,
|
||||||
|
size: metadata.size.unwrap_or_default(),
|
||||||
|
progress: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<R: AsyncRead> Stream for DecryptingStream<R> {
|
||||||
|
type Item = std::io::Result<Bytes>;
|
||||||
|
|
||||||
|
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
|
||||||
|
let mut this = self.as_mut().project();
|
||||||
|
|
||||||
|
let reader = match this.reader.as_pin_mut() {
|
||||||
|
Some(r) => r,
|
||||||
|
None => return Poll::Ready(None),
|
||||||
|
};
|
||||||
|
|
||||||
|
if this.buf.capacity() == 0 {
|
||||||
|
this.buf.reserve(*this.capacity);
|
||||||
|
}
|
||||||
|
|
||||||
|
match poll_read_buf(reader, cx, &mut this.buf) {
|
||||||
|
Poll::Pending => Poll::Pending,
|
||||||
|
Poll::Ready(Err(err)) => {
|
||||||
|
debug!("failed to send bin {}", this.id);
|
||||||
|
self.project().reader.set(None);
|
||||||
|
Poll::Ready(Some(Err(err)))
|
||||||
|
}
|
||||||
|
Poll::Ready(Ok(0)) => {
|
||||||
|
if self.progress_check() == DecryptingStreamProgress::Failed {
|
||||||
|
// The hash is invalid, the file has been tampered with. Close reader and stream causing the download to fail
|
||||||
|
self.project().reader.set(None);
|
||||||
|
return Poll::Ready(None);
|
||||||
|
};
|
||||||
|
self.project().reader.set(None);
|
||||||
|
Poll::Ready(None)
|
||||||
|
}
|
||||||
|
Poll::Ready(Ok(n)) => {
|
||||||
|
let mut chunk = this.buf.split();
|
||||||
|
// decrypt the chunk using chacha
|
||||||
|
this.cipher.apply_keystream(&mut chunk);
|
||||||
|
// update the sha3 hasher
|
||||||
|
this.hasher.update(&chunk);
|
||||||
|
// track progress
|
||||||
|
*this.progress += n as u64;
|
||||||
|
if self.progress_check() == DecryptingStreamProgress::Failed {
|
||||||
|
// The hash is invalid, the file has been tampered with. Close reader and stream causing the download to fail
|
||||||
|
warn!("bin {} is corrupted! transmission failed", self.id);
|
||||||
|
self.project().reader.set(None);
|
||||||
|
return Poll::Ready(None);
|
||||||
|
};
|
||||||
|
Poll::Ready(Some(Ok(chunk.freeze())))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<R: AsyncRead> DecryptingStream<R> {
|
||||||
|
/// checks if the hash is correct when the last byte has been read
|
||||||
|
fn progress_check(&self) -> DecryptingStreamProgress {
|
||||||
|
if self.progress >= self.size {
|
||||||
|
let hash = hex::encode(self.hasher.clone().finalize());
|
||||||
|
if hash != self.target_hash {
|
||||||
|
DecryptingStreamProgress::Failed
|
||||||
|
} else {
|
||||||
|
DecryptingStreamProgress::Finished
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
DecryptingStreamProgress::Running
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(PartialEq, Eq)]
|
||||||
|
enum DecryptingStreamProgress {
|
||||||
|
Finished,
|
||||||
|
Failed,
|
||||||
|
Running,
|
||||||
|
}
|
Loading…
Reference in a new issue