Make key material secret

This commit is contained in:
Edward Shen 2021-10-30 21:00:09 -07:00
parent ac52c20e3b
commit 8e05c622af
Signed by: edward
GPG key ID: 19182661E818369F
8 changed files with 123 additions and 52 deletions

3
Cargo.lock generated
View file

@ -953,7 +953,6 @@ dependencies = [
"clap",
"omegaupload-common",
"reqwest",
"secrecy",
]
[[package]]
@ -970,6 +969,7 @@ dependencies = [
"http",
"lazy_static",
"rand",
"secrecy",
"serde",
"thiserror",
"typenum",
@ -1368,7 +1368,6 @@ version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9bd1c54ea06cfd2f6b63219704de0b9b4f72dcc2b8fdef820be6cd799780e91e"
dependencies = [
"serde",
"zeroize",
]

View file

@ -11,5 +11,4 @@ omegaupload-common = { path = "../common" }
anyhow = "1"
atty = "0.2"
clap = "3.0.0-beta.4"
reqwest = { version = "0.11", default-features = false, features = ["rustls-tls", "blocking"] }
secrecy = { version = "0.8", features = ["serde"] }
reqwest = { version = "0.11", default-features = false, features = ["rustls-tls", "blocking"] }

View file

@ -2,18 +2,19 @@
#![deny(unsafe_code)]
use std::io::{Read, Write};
use std::path::PathBuf;
use anyhow::{anyhow, bail, Context, Result};
use atty::Stream;
use clap::Parser;
use omegaupload_common::crypto::{open_in_place, seal_in_place};
use omegaupload_common::secrecy::{ExposeSecret, SecretVec};
use omegaupload_common::{
base64, Expiration, ParsedUrl, Url, API_ENDPOINT, EXPIRATION_HEADER_NAME,
};
use reqwest::blocking::Client;
use reqwest::header::EXPIRES;
use reqwest::StatusCode;
use secrecy::{ExposeSecret, SecretString};
#[derive(Parser)]
struct Opts {
@ -29,9 +30,10 @@ enum Action {
/// Encrypt the uploaded paste with the provided password, preventing
/// public access.
#[clap(short, long)]
password: Option<SecretString>,
password: bool,
#[clap(short, long)]
duration: Option<Expiration>,
path: PathBuf,
},
Download {
/// The paste to download.
@ -47,7 +49,8 @@ fn main() -> Result<()> {
url,
password,
duration,
} => handle_upload(url, password, duration),
path,
} => handle_upload(url, password, duration, path),
Action::Download { url } => handle_download(url),
}?;
@ -56,8 +59,9 @@ fn main() -> Result<()> {
fn handle_upload(
mut url: Url,
password: Option<SecretString>,
password: bool,
duration: Option<Expiration>,
path: PathBuf,
) -> Result<()> {
url.set_fragment(None);
@ -66,11 +70,17 @@ fn handle_upload(
}
let (data, key) = {
let mut container = Vec::new();
std::io::stdin().read_to_end(&mut container)?;
let password = password.as_ref().map(|v| v.expose_secret().as_ref());
let mut container = std::fs::read(path)?;
let password = if password {
let mut buffer = vec![];
std::io::stdin().read_to_end(&mut buffer)?;
Some(SecretVec::new(buffer))
} else {
None
};
let enc_key = seal_in_place(&mut container, password)?;
let key = base64::encode(&enc_key);
let key = base64::encode(&enc_key.expose_secret().as_ref());
(container, key)
};
@ -90,11 +100,11 @@ fn handle_upload(
.map_err(|_| anyhow!("Failed to get base URL"))?
.extend(std::iter::once(res.text()?));
let mut fragment = format!("key:{}", key);
if password.is_some() {
fragment.push_str("!pw");
}
let fragment = if password {
format!("key:{}!pw", key)
} else {
key
};
url.set_fragment(Some(&fragment));
@ -140,7 +150,11 @@ fn handle_download(mut url: ParsedUrl) -> Result<()> {
password = Some(input);
}
open_in_place(&mut data, &url.decryption_key, password.as_deref())?;
open_in_place(
&mut data,
&url.decryption_key,
password.map(|v| SecretVec::new(v.into_bytes())),
)?;
if atty::is(Stream::Stdout) {
if let Ok(data) = String::from_utf8(data) {

View file

@ -13,6 +13,7 @@ chrono = { version = "0.4", features = ["serde"] }
headers = "*"
lazy_static = "1"
rand = "0.8"
secrecy = "0.8"
serde = { version = "1", features = ["derive"] }
thiserror = "1"
typenum = "1"

View file

@ -8,10 +8,9 @@ use chacha20poly1305::aead::{AeadInPlace, NewAead};
use chacha20poly1305::XChaCha20Poly1305;
use chacha20poly1305::XNonce;
use rand::{thread_rng, Rng};
use secrecy::{ExposeSecret, Secret, SecretVec, Zeroize};
use typenum::Unsigned;
pub use chacha20poly1305::Key;
#[derive(Debug)]
pub enum Error {
ChaCha20Poly1305(chacha20poly1305::aead::Error),
@ -41,6 +40,42 @@ impl Display for Error {
}
}
// This struct intentionally prevents implement Clone or Copy
#[derive(Default)]
pub struct Key(chacha20poly1305::Key);
impl Key {
pub fn new_secret(vec: Vec<u8>) -> Option<Secret<Self>> {
chacha20poly1305::Key::from_exact_iter(vec.into_iter())
.map(Self)
.map(Secret::new)
}
}
impl AsRef<chacha20poly1305::Key> for Key {
fn as_ref(&self) -> &chacha20poly1305::Key {
&self.0
}
}
impl Deref for Key {
type Target = chacha20poly1305::Key;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl DerefMut for Key {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.0
}
}
impl Zeroize for Key {
fn zeroize(&mut self) {
self.0.zeroize()
}
}
/// Seals the provided message with an optional message. The resulting sealed
/// message has the nonce used to encrypt the message appended to it as well as
/// a salt string used to derive the key. In other words, the modified buffer is
@ -60,16 +95,19 @@ impl Display for Error {
/// XChaCha20Poly1305.
/// - `rng_key` represents a randomly generated key.
/// - `kdf(pw, salt)` represents a key derived from Argon2.
pub fn seal_in_place(message: &mut Vec<u8>, pw: Option<&str>) -> Result<Key, Error> {
pub fn seal_in_place(
message: &mut Vec<u8>,
pw: Option<SecretVec<u8>>,
) -> Result<Secret<Key>, Error> {
let (key, nonce) = gen_key_nonce();
let cipher = XChaCha20Poly1305::new(&key);
let cipher = XChaCha20Poly1305::new(key.expose_secret());
cipher.encrypt_in_place(&nonce, &[], message)?;
let mut maybe_salt_string = None;
if let Some(password) = pw {
let (key, salt_string) = kdf(&password)?;
maybe_salt_string = Some(salt_string);
let cipher = XChaCha20Poly1305::new(&key);
let cipher = XChaCha20Poly1305::new(key.expose_secret());
cipher.encrypt_in_place(&nonce.increment(), &[], message)?;
}
@ -80,14 +118,18 @@ pub fn seal_in_place(message: &mut Vec<u8>, pw: Option<&str>) -> Result<Key, Err
Ok(key)
}
pub fn open_in_place(data: &mut Vec<u8>, key: &Key, password: Option<&str>) -> Result<(), Error> {
pub fn open_in_place(
data: &mut Vec<u8>,
key: &Secret<Key>,
password: Option<SecretVec<u8>>,
) -> Result<(), Error> {
let buffer_len = data.len();
let pw_key = if let Some(password) = password {
let salt_buf = data.split_off(buffer_len - Salt::SIZE);
let argon = Argon2::default();
let mut pw_key = Key::default();
argon.hash_password_into(password.as_bytes(), &salt_buf, &mut pw_key)?;
Some(pw_key)
argon.hash_password_into(password.expose_secret(), &salt_buf, &mut pw_key)?;
Some(Secret::new(pw_key))
} else {
None
};
@ -97,11 +139,11 @@ pub fn open_in_place(data: &mut Vec<u8>, key: &Key, password: Option<&str>) -> R
// At this point we should have a buffer that's only the ciphertext.
if let Some(key) = pw_key {
let cipher = XChaCha20Poly1305::new(&key);
let cipher = XChaCha20Poly1305::new(key.expose_secret());
cipher.decrypt_in_place(&nonce.increment(), &[], data)?;
}
let cipher = XChaCha20Poly1305::new(&key);
let cipher = XChaCha20Poly1305::new(key.expose_secret());
cipher.decrypt_in_place(&nonce, &[], data)?;
Ok(())
@ -109,13 +151,13 @@ pub fn open_in_place(data: &mut Vec<u8>, key: &Key, password: Option<&str>) -> R
/// Securely generates a random key and nonce.
#[must_use]
fn gen_key_nonce() -> (Key, Nonce) {
fn gen_key_nonce() -> (Secret<Key>, Nonce) {
let mut rng = thread_rng();
let mut key = GenericArray::default();
rng.fill(key.as_mut_slice());
let mut nonce = Nonce::default();
rng.fill(nonce.as_mut_slice());
(key, nonce)
(Secret::new(Key(key)), nonce)
}
// Type alias; to ensure that we're consistent on what the inner impl is.
@ -186,11 +228,11 @@ impl AsRef<[u8]> for Salt {
}
/// Hashes an input to output a usable key.
fn kdf(password: &str) -> Result<(Key, Salt), argon2::Error> {
fn kdf(password: &SecretVec<u8>) -> Result<(Secret<Key>, Salt), argon2::Error> {
let salt = Salt::random();
let hasher = Argon2::default();
let mut key = Key::default();
hasher.hash_password_into(password.as_ref(), salt.as_ref(), &mut key)?;
hasher.hash_password_into(password.expose_secret().as_ref(), salt.as_ref(), &mut key)?;
Ok((*Key::from_slice(&key), salt))
Ok((Secret::new(key), salt))
}

View file

@ -9,6 +9,8 @@ use bytes::Bytes;
use chrono::{DateTime, Duration, Utc};
use headers::{Header, HeaderName, HeaderValue};
use lazy_static::lazy_static;
pub use secrecy;
use secrecy::Secret;
use serde::{Deserialize, Serialize};
use thiserror::Error;
pub use url::Url;
@ -22,18 +24,33 @@ pub const API_ENDPOINT: &str = "/api";
pub struct ParsedUrl {
pub sanitized_url: Url,
pub decryption_key: Key,
pub decryption_key: Secret<Key>,
pub needs_password: bool,
}
#[derive(Default)]
pub struct PartialParsedUrl {
pub decryption_key: Option<Key>,
pub decryption_key: Option<Secret<Key>>,
pub needs_password: bool,
}
impl From<&str> for PartialParsedUrl {
fn from(fragment: &str) -> Self {
// Short circuit if the fragment only contains the key.
// Base64 has an interesting property that the length of an encoded text
// is always 4/3rds larger than the original data.
if !fragment.contains("key") {
let decryption_key = base64::decode(fragment)
.ok()
.and_then(|k| Key::new_secret(k));
return Self {
decryption_key,
needs_password: false,
};
}
let args = fragment.split('!').filter_map(|kv| {
let (k, v) = {
let mut iter = kv.split(':');
@ -49,7 +66,7 @@ impl From<&str> for PartialParsedUrl {
for (key, value) in args {
match (key, value) {
("key", Some(value)) => {
decryption_key = base64::decode(value).map(|k| *Key::from_slice(&k)).ok();
decryption_key = base64::decode(value).ok().and_then(|k| Key::new_secret(k));
}
("pw", _) => {
needs_password = true;
@ -71,10 +88,6 @@ pub enum ParseUrlError {
BadUrl,
#[error("Missing decryption key")]
NeedKey,
#[error("Missing nonce")]
NeedNonce,
#[error("Missing decryption key and nonce")]
NeedKeyAndNonce,
}
impl FromStr for ParsedUrl {
@ -82,22 +95,19 @@ impl FromStr for ParsedUrl {
fn from_str(s: &str) -> Result<Self, Self::Err> {
let mut url = Url::from_str(s).map_err(|_| ParseUrlError::BadUrl)?;
let fragment = url.fragment().ok_or(ParseUrlError::NeedKeyAndNonce)?;
let fragment = url.fragment().ok_or(ParseUrlError::NeedKey)?;
if fragment.is_empty() {
return Err(ParseUrlError::NeedKeyAndNonce);
return Err(ParseUrlError::NeedKey);
}
let PartialParsedUrl {
decryption_key,
mut decryption_key,
needs_password,
} = PartialParsedUrl::from(fragment);
url.set_fragment(None);
let decryption_key = match &decryption_key {
Some(k) => Ok(*k),
None => Err(ParseUrlError::NeedKey),
}?;
let decryption_key = decryption_key.take().ok_or(ParseUrlError::NeedKey)?;
Ok(Self {
sanitized_url: url,

View file

@ -5,6 +5,7 @@ use std::sync::Arc;
use gloo_console::log;
use js_sys::{Array, Uint8Array};
use omegaupload_common::crypto::{open_in_place, Key};
use omegaupload_common::secrecy::{Secret, SecretVec};
use serde::Serialize;
use wasm_bindgen::JsCast;
use web_sys::{Blob, BlobPropertyBag};
@ -35,8 +36,8 @@ fn now() -> f64 {
pub fn decrypt(
mut container: Vec<u8>,
key: Key,
maybe_password: Option<&str>,
key: Secret<Key>,
maybe_password: Option<SecretVec<u8>>,
) -> Result<DecryptedData, PasteCompleteConstructionError> {
open_in_place(&mut container, &key, maybe_password)
.map_err(|_| PasteCompleteConstructionError::Decryption)?;

View file

@ -10,6 +10,7 @@ use http::uri::PathAndQuery;
use http::{StatusCode, Uri};
use js_sys::{Array, JsString, Object, Uint8Array};
use omegaupload_common::crypto::Key;
use omegaupload_common::secrecy::{Secret, SecretVec};
use omegaupload_common::{Expiration, PartialParsedUrl};
use reqwasm::http::Request;
use wasm_bindgen::prelude::{wasm_bindgen, Closure};
@ -90,7 +91,7 @@ fn main() {
if let Ok(Some(password)) = pw {
if !password.is_empty() {
break Some(password);
break Some(SecretVec::new(password.into_bytes()));
}
}
}
@ -101,7 +102,7 @@ fn main() {
if location().pathname().unwrap() == "/" {
} else {
spawn_local(async move {
if let Err(e) = fetch_resources(request_uri, key, password.as_deref()).await {
if let Err(e) = fetch_resources(request_uri, key, password).await {
log!(e.to_string());
}
});
@ -109,7 +110,11 @@ fn main() {
}
#[allow(clippy::future_not_send)]
async fn fetch_resources(request_uri: Uri, key: Key, password: Option<&str>) -> Result<()> {
async fn fetch_resources(
request_uri: Uri,
key: Secret<Key>,
password: Option<SecretVec<u8>>,
) -> Result<()> {
match Request::get(&request_uri.to_string()).send().await {
Ok(resp) if resp.status() == StatusCode::OK => {
let expires = Expiration::try_from(resp.headers()).map_or_else(