gourami

[UNMAINTAINED] Activitypub server in Rust
Log | Files | Refs | README | LICENSE

commit d3b8430ce4eec79b0bb5fa8b17c3b8476fe8a9ad
parent d882b28b1b83154416dd18a4b47ae1a5116ec0db
Author: alex wennerberg <alex@alexwennerberg.com>
Date:   Thu,  7 May 2020 19:47:37 -0500

Proof of concept -- key validation

This commit is not working code

Diffstat:
MCargo.lock | 17++++++-----------
MCargo.toml | 3++-
MREADME.md | 3++-
Msample_env | 4++--
Msrc/ap.rs | 151+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
Msrc/routes.rs | 7+++++--
6 files changed, 149 insertions(+), 36 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -117,9 +117,9 @@ checksum = "b41b7ea54a0c9d92199de89e20e58d49f02f8e699814ef3fdf266f6f748d15c7" [[package]] name = "base64" -version = "0.12.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d5ca2cd0adc3f48f9e9ea5a6bbdf9ccc0bfade884847e484d452414c7ccffb3" +checksum = "53d1ccbaf7d9ec9537465a97bf19edc1a4e158ecb49fc16178202238c569cc42" [[package]] name = "bcrypt" @@ -127,7 +127,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6f02d7d008a57bcb2251ba115b803934e02315edbde9a861c88713493e381b63" dependencies = [ - "base64 0.12.0", + "base64 0.12.1", "blowfish", "byteorder", "lazy_static", @@ -329,12 +329,6 @@ dependencies = [ ] [[package]] -name = "data-encoding" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11c0346158a19b3627234e15596f5e465c360fcdb97d817bcb255e0510f5a788" - -[[package]] name = "derive_builder" version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -611,10 +605,10 @@ version = "0.1.0" dependencies = [ "ammonia", "askama", + "base64 0.12.1", "bcrypt", "chrono", "clap", - "data-encoding", "diesel", "env_logger", "http-signature-normalization", @@ -622,6 +616,7 @@ dependencies = [ "lazy_static", "log 0.4.8", "maplit", + "openssl", "rand 0.7.3", "regex", "reqwest", @@ -658,7 +653,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed18eb2459bf1a09ad2d6b1547840c3e5e62882fa09b9a6a20b1de8e3228848f" dependencies = [ - "base64 0.12.0", + "base64 0.12.1", "bitflags", "bytes", "headers-core", diff --git a/Cargo.toml b/Cargo.toml @@ -7,10 +7,10 @@ edition = "2018" [dependencies] ammonia = "3.1.0" askama = "0.8.0" +base64 = "0.12.1" bcrypt = "0.7.0" chrono = "0.4.11" clap = "2.33.0" -data-encoding = "2.2.0" diesel = { version = "1.4.4", features = ["sqlite", "r2d2"] } env_logger = "0.7.1" http-signature-normalization = "0.5.1" @@ -18,6 +18,7 @@ hyper = "0.13.5" lazy_static = "1.4.0" log = "0.4.8" maplit = "1.0.2" +openssl = "0.10.29" rand = "0.7.3" regex = "1.3.7" ring = "0.16.13" diff --git a/README.md b/README.md @@ -25,7 +25,8 @@ Read [this document](https://git.sr.ht/~alexwennerberg/gourami-social/tree/maste ## Dependencies: * sqlite3 -* sqlite3-dev +* libsqlite3-dev +* openssl ## Installation diff --git a/sample_env b/sample_env @@ -6,5 +6,5 @@ export RUST_LOG=debug,hyper=info,html5ever=info export GOURAMI_ENV="DEV" export GOURAMI_DOMAIN="localhost:3030" # used for http signatures -export SIGNATURE_PRIVKEY="local/private.pem" -export SIGNATURE_PUBKEY="local/public.pem" +export SIGNATURE_PRIVKEY="local/private.pk8" +export SIGNATURE_PUBKEY="local/public.der" diff --git a/src/ap.rs b/src/ap.rs @@ -1,9 +1,12 @@ use ring::digest; -use data_encoding::HEXUPPER; +use ring::signature::UnparsedPublicKey; +use std::path::Path; +use base64; use crate::db::conn::POOL; use crate::db::note::{NoteInput, RemoteNoteInput}; use crate::db::user::{NewRemoteUser, User}; use diesel::insert_into; +use openssl::rsa::Rsa; use diesel::prelude::*; use diesel::sqlite::SqliteConnection; use serde_json::json; @@ -11,8 +14,8 @@ use serde_json::Value; use std::env; use std::collections::BTreeMap; use reqwest::Request; -use chrono::Duration; -use http_signature_normalization::Config; +use chrono::{Duration, Utc}; +use http_signature_normalization::{Config}; /// Users don't follow users in Gourami. Instead the server does hte following /// There are a number of reasons for this: @@ -42,12 +45,12 @@ enum Action { } /// get the server user json -fn server_actor_json() -> Value { +pub fn server_actor_json() -> Value { // TODO figure out how to get lazy static working let DOMAIN: &str = &env::var("GOURAMI_DOMAIN").unwrap(); - let SERVER_ACTOR: &str = &format!("{}/actor", &env::var("GOURAMI_DOMAIN").unwrap()); - let SERVER_INBOX: &str = &format!("{}/inbox", &env::var("GOURAMI_DOMAIN").unwrap()); - let SERVER_KEY_ID: &str = &format!("{}/inbox", &env::var("GOURAMI_DOMAIN").unwrap()); + let SERVER_ACTOR: &str = &format!("http://{}/actor", DOMAIN); + let SERVER_INBOX: &str = &format!("http://{}/inbox", DOMAIN); + let SERVER_KEY_ID: &str = &format!("{}#key", SERVER_ACTOR); let SERVER_PUBLIC_KEY: &str = &fs::read_to_string(env::var("SIGNATURE_PUBKEY").unwrap()).unwrap(); json!({ "@context": [ @@ -201,15 +204,25 @@ pub fn new_note_to_ap_message(note: &NoteInput, user: &User) -> Value { // fn generate_ap(activity: Activity) { // } pub trait HttpSignature { - fn http_sign_outgoing(self) -> Result<reqwest::Request, Box<dyn std::error::Error>>; + fn http_sign_outgoing(self) -> Result<reqwest::RequestBuilder, Box<dyn std::error::Error>>; } +// fn read_file(path: &std::path::Path) -> Vec<u8> { +// use std::io::Read; + +// let mut file = std::fs::File::open(path).unwrap(); +// let mut contents: Vec<u8> = Vec::new(); +// file.read_to_end(&mut contents).unwrap(); +// contents +// } + impl HttpSignature for reqwest::RequestBuilder { - fn http_sign_outgoing(self) -> Result<reqwest::Request, Box<dyn std::error::Error>> { - let req = self.build().unwrap(); - let config = Config::default().set_expiration(Duration::seconds(30)); + fn http_sign_outgoing(self) -> Result<reqwest::RequestBuilder, Box<dyn std::error::Error>> { + // try and remove clone here + let req = self.try_clone().unwrap().build().unwrap(); + let config = Config::default().set_expiration(Duration::seconds(3600)).dont_use_created_field(); // let server_key_id = - let server_key_id: &str = &format!("{}/inbox", &env::var("GOURAMI_DOMAIN").unwrap()); + let server_key_id: &str = &format!("http://{}/actor#key", &env::var("GOURAMI_DOMAIN").unwrap()); let mut bt = std::collections::BTreeMap::new(); for (k, v) in req.headers().iter() { bt.insert(k.as_str().to_owned(), v.to_str()?.to_owned()); @@ -222,30 +235,130 @@ impl HttpSignature for reqwest::RequestBuilder { let unsigned = config.begin_sign(req.method().as_str(), &path_and_query, bt)?; println!("{:?}", &unsigned); let sig_header = unsigned.sign(server_key_id.to_owned(), |signing_string| { - let digest = digest::digest(&digest::SHA256, &signing_string.as_bytes()); - let hexencode = HEXUPPER.encode(digest.as_ref()); + let private_key = read_file(Path::new(&env::var("SIGNATURE_PRIVKEY").unwrap())); + let key_pair = ring::signature::RsaKeyPair::from_pkcs8(&private_key.unwrap()).unwrap(); + let rng = ring::rand::SystemRandom::new(); + let mut signature = vec![0; key_pair.public_modulus_len()]; + key_pair.sign(&ring::signature::RSA_PKCS1_SHA256, &rng, signing_string.as_bytes(), &mut signature).unwrap(); + // let digest = digest::digest(&digest::SHA256, &signing_string.as_bytes()); + println!("{:?}", &signing_string); + let hexencode = base64::encode(&signature); Ok(hexencode) as Result<_, Box<dyn std::error::Error>> })? .signature_header(); - println!("{:?}", sig_header); - Ok(req) + // this SHOULD be OK + // host and date? + println!("{:?}", &sig_header); + let result = self.header("Signature", sig_header); + println!("{:?}", &result); + Ok(result) } } -fn verify_ap_message() { +fn sign_and_verify_rsa(private_key_path: &std::path::Path, +public_key_path: &std::path::Path) +-> Result<(), MyError> { + use ring::{rand, signature}; +// Create an `RsaKeyPair` from the DER-encoded bytes. This example uses +// a 2048-bit key, but larger keys are also supported. +let private_key_der = read_file(private_key_path)?; +let key_pair = signature::RsaKeyPair::from_pkcs8(&private_key_der) +.map_err(|_| MyError::BadPrivateKey)?; + +// Sign the message "hello, world", using PKCS#1 v1.5 padding and the +// SHA256 digest algorithm. +const MESSAGE: &'static [u8] = b"hello, world"; +let rng = rand::SystemRandom::new(); +let mut signature = vec![0; key_pair.public_modulus_len()]; +key_pair.sign(&signature::RSA_PKCS1_SHA256, &rng, MESSAGE, &mut signature) +.map_err(|_| MyError::OOM)?; + +// Verify the signature. +let public_key = +signature::UnparsedPublicKey::new(&signature::RSA_PKCS1_2048_8192_SHA256, + read_file(public_key_path)?); +public_key.verify(MESSAGE, &signature) +.map_err(|_| MyError::BadSignature) +} + +#[derive(Debug)] +enum MyError { +IO(std::io::Error), +BadPrivateKey, +OOM, +BadSignature, +} + +fn read_file(path: &std::path::Path) -> Result<Vec<u8>, MyError> { +use std::io::Read; +let mut file = std::fs::File::open(path).map_err(|e| MyError::IO(e))?; +let mut contents: Vec<u8> = Vec::new(); +file.read_to_end(&mut contents).map_err(|e| MyError::IO(e))?; +Ok(contents) +} +fn verify_ap_message(method: &str, path_and_query: &str, headers: BTreeMap<String,String>) { + // TODO -- case insensitivity? + // mastodon doesnt use created filed + let config = Config::default().set_expiration(Duration::seconds(3600)).dont_use_created_field(); + println!("{:?}", config); + println!("{:?}", headers); + let unverified = config.begin_verify(method, path_and_query, headers).unwrap(); + let res = unverified.verify(|signature, signing_string| { + println!("{:?}", signature); + println!("{:?}", signing_string); + let res: Value = reqwest::blocking::get(unverified.key_id()).unwrap().json().unwrap(); + let public_key: &[u8]= res.get("publicKey").unwrap().get("publicKeyPem").unwrap().as_str().unwrap().as_bytes(); + // let public_key = &read_file(Path::new(&env::var("SIGNATURE_PUBKEY").unwrap())).unwrap(); + let r = Rsa::public_key_from_pem(public_key).unwrap(); + let public_key = r.public_key_to_der_pkcs1().unwrap(); + let key = UnparsedPublicKey::new(&ring::signature::RSA_PKCS1_2048_8192_SHA256, &public_key); + let hexdecode = base64::decode(signature).unwrap(); + key.verify(signing_string.as_bytes(), &hexdecode).unwrap(); + println!("{:?}", hexdecode); + true}); + println!("{:?}", res); + // verify unverified + println!("{:?}", unverified); } #[cfg(test)] mod tests { use super::*; + fn prepare_headers() -> BTreeMap<String, String> { + let mut headers = BTreeMap::new(); + headers.insert( + "Content-Type".to_owned(), + "application/activity+json".to_owned(), + ); + headers + } + + #[test] + fn test_verify_rsa() { + sign_and_verify_rsa(Path::new(&env::var("SIGNATURE_PRIVKEY").unwrap()), Path::new(&env::var("SIGNATURE_PUBKEY").unwrap())).unwrap() + } + + #[test] + fn test_verify_ap_message() { + let mut headers = BTreeMap::new(); + headers.insert("Content-Type".to_owned(), "application/activity+json".to_owned()); + headers.insert("date".to_owned(), "Fri, 08 May 2020 00:42:41 +0000".to_owned()); + let sample = "keyId=\"http://localhost:3030/actor#key\",algorithm=\"hs2019\",headers=\"(request-target) content-type date\",signature=\"YCJ7bwIX8y6rJ9Be31wm4ZkiBqper4vGydPHc/avBRE7D7SpIfWO+aA00VQcHlAGYjNRLEWiA5SkpszW3wnAs5JzuRWK01pELsEluYyE54/ou+rc06DxPt9beb9mIrbPs9EByN6epkYAGuKna8xoE7qsjhpfz5Q0SfNP3qS10uLaP5/puFCxMVgDIb3wMiJz1WiCzWZ26e5Wujoea8l5HS37V4xYhqicXmTvU1SzEiC+Qsn3RteWTesItAEDID5CFOhFizkSvgYVNjpTMwbLf1QiqyfgctVQIYt4fuQSTlcdKjhpS1cAxKTJg5hFQ9vjo1Qm1NP6XBALcRWpAIw5SA==\""; + headers.insert("signature".to_owned(), sample.to_owned()); + verify_ap_message("post", "/inbox", headers); + } + #[test] - fn test_sign_outgoing_msg() { + fn test_send_ap() { let body: Value = serde_json::from_str(r#"{"foo": "bar"}"#).unwrap(); let req = reqwest::Client::new() - .post("https://localhost:3030") + .post("http://localhost:3030/inbox") + // for mastodon config -- newer versions of httsig dont use this + .header("date", Utc::now().to_rfc2822()) .json(&body) + .header("Content-Type", "application/activity+json") .http_sign_outgoing().unwrap(); } diff --git a/src/routes.rs b/src/routes.rs @@ -1,7 +1,7 @@ use crate::session; use crate::*; use env_logger; -use warp::{body::form, body::json, filters::cookie, filters::query::query, path}; +use warp::{reply, body::form, body::json, filters::cookie, filters::query::query, path}; // I had trouble decoupling routes from server -- couldnt figure out the return type pub async fn run_server() { @@ -87,6 +87,8 @@ pub async fn run_server() { // setup authentication // POST // TODO -- setup proper replies + let server_actor = path!("actor").map(|| reply::json(&ap::server_actor_json())); + let post_server_inbox = path!("inbox").and(json()).map(post_inbox); let post_server_inbox = path!("inbox").and(json()).map(post_inbox); @@ -99,6 +101,7 @@ pub async fn run_server() { // TODO secure against xss // used for api based authentication // let api_filter = session::create_session_filter(&POOL.get()); + let static_json = server_actor; // rename html renders let html_renders = home .or(login_page) .or(register_page) @@ -119,7 +122,7 @@ pub async fn run_server() { // catch all for any other paths let routes = warp::get() - .and(html_renders) + .and(html_renders.or(static_json)) .or(warp::post() .and(warp::body::content_length_limit(1024 * 32)) .and(forms))