From: Felix Date: Sat, 18 Apr 2020 18:54:20 +0000 (+0200) Subject: Add http signature to outgoing apub requests X-Git-Url: http://these/git/%7B%60/static/assets/css/themes/README.es.md?a=commitdiff_plain;h=8daf72278d6c9fdd18ad2bf8c45ea4d25c428bad;p=lemmy.git Add http signature to outgoing apub requests --- diff --git a/server/Cargo.lock b/server/Cargo.lock index b17f4d60..577c6d31 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -74,7 +74,7 @@ dependencies = [ "derive_more 0.99.3 (registry+https://github.com/rust-lang/crates.io-index)", "either 1.5.3 (registry+https://github.com/rust-lang/crates.io-index)", "futures 0.3.4 (registry+https://github.com/rust-lang/crates.io-index)", - "http 0.2.0 (registry+https://github.com/rust-lang/crates.io-index)", + "http 0.2.1 (registry+https://github.com/rust-lang/crates.io-index)", "log 0.4.8 (registry+https://github.com/rust-lang/crates.io-index)", "trust-dns-proto 0.18.0-alpha.2 (registry+https://github.com/rust-lang/crates.io-index)", "trust-dns-resolver 0.18.0-alpha.2 (registry+https://github.com/rust-lang/crates.io-index)", @@ -126,7 +126,7 @@ dependencies = [ "futures-util 0.3.4 (registry+https://github.com/rust-lang/crates.io-index)", "fxhash 0.2.1 (registry+https://github.com/rust-lang/crates.io-index)", "h2 0.2.2 (registry+https://github.com/rust-lang/crates.io-index)", - "http 0.2.0 (registry+https://github.com/rust-lang/crates.io-index)", + "http 0.2.1 (registry+https://github.com/rust-lang/crates.io-index)", "httparse 1.3.4 (registry+https://github.com/rust-lang/crates.io-index)", "indexmap 1.3.2 (registry+https://github.com/rust-lang/crates.io-index)", "language-tags 0.2.2 (registry+https://github.com/rust-lang/crates.io-index)", @@ -160,7 +160,7 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" dependencies = [ "bytestring 0.1.4 (registry+https://github.com/rust-lang/crates.io-index)", - "http 0.2.0 (registry+https://github.com/rust-lang/crates.io-index)", + "http 0.2.1 (registry+https://github.com/rust-lang/crates.io-index)", "log 0.4.8 (registry+https://github.com/rust-lang/crates.io-index)", "regex 1.3.5 (registry+https://github.com/rust-lang/crates.io-index)", "serde 1.0.105 (registry+https://github.com/rust-lang/crates.io-index)", @@ -1176,7 +1176,7 @@ dependencies = [ "futures-core 0.3.4 (registry+https://github.com/rust-lang/crates.io-index)", "futures-sink 0.3.4 (registry+https://github.com/rust-lang/crates.io-index)", "futures-util 0.3.4 (registry+https://github.com/rust-lang/crates.io-index)", - "http 0.2.0 (registry+https://github.com/rust-lang/crates.io-index)", + "http 0.2.1 (registry+https://github.com/rust-lang/crates.io-index)", "indexmap 1.3.2 (registry+https://github.com/rust-lang/crates.io-index)", "log 0.4.8 (registry+https://github.com/rust-lang/crates.io-index)", "slab 0.4.2 (registry+https://github.com/rust-lang/crates.io-index)", @@ -1237,7 +1237,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" [[package]] name = "http" -version = "0.2.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" dependencies = [ "bytes 0.5.4 (registry+https://github.com/rust-lang/crates.io-index)", @@ -1245,6 +1245,15 @@ dependencies = [ "itoa 0.4.5 (registry+https://github.com/rust-lang/crates.io-index)", ] +[[package]] +name = "http-signature-normalization" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "chrono 0.4.11 (registry+https://github.com/rust-lang/crates.io-index)", + "thiserror 1.0.11 (registry+https://github.com/rust-lang/crates.io-index)", +] + [[package]] name = "httparse" version = "1.3.4" @@ -1314,7 +1323,7 @@ dependencies = [ "futures-channel 0.3.4 (registry+https://github.com/rust-lang/crates.io-index)", "futures-io 0.3.4 (registry+https://github.com/rust-lang/crates.io-index)", "futures-util 0.3.4 (registry+https://github.com/rust-lang/crates.io-index)", - "http 0.2.0 (registry+https://github.com/rust-lang/crates.io-index)", + "http 0.2.1 (registry+https://github.com/rust-lang/crates.io-index)", "lazy_static 1.4.0 (registry+https://github.com/rust-lang/crates.io-index)", "log 0.4.8 (registry+https://github.com/rust-lang/crates.io-index)", "mime 0.3.16 (registry+https://github.com/rust-lang/crates.io-index)", @@ -1387,6 +1396,7 @@ dependencies = [ "actix-rt 1.0.0 (registry+https://github.com/rust-lang/crates.io-index)", "actix-web 2.0.0 (registry+https://github.com/rust-lang/crates.io-index)", "actix-web-actors 2.0.0 (registry+https://github.com/rust-lang/crates.io-index)", + "base64 0.12.0 (registry+https://github.com/rust-lang/crates.io-index)", "bcrypt 0.6.2 (registry+https://github.com/rust-lang/crates.io-index)", "chrono 0.4.11 (registry+https://github.com/rust-lang/crates.io-index)", "comrak 0.7.0 (registry+https://github.com/rust-lang/crates.io-index)", @@ -1398,6 +1408,8 @@ dependencies = [ "failure 0.1.7 (registry+https://github.com/rust-lang/crates.io-index)", "hjson 0.8.2 (registry+https://github.com/rust-lang/crates.io-index)", "htmlescape 0.3.1 (registry+https://github.com/rust-lang/crates.io-index)", + "http 0.2.1 (registry+https://github.com/rust-lang/crates.io-index)", + "http-signature-normalization 0.4.1 (registry+https://github.com/rust-lang/crates.io-index)", "isahc 0.9.1 (registry+https://github.com/rust-lang/crates.io-index)", "jsonwebtoken 7.1.0 (registry+https://github.com/rust-lang/crates.io-index)", "lazy_static 1.4.0 (registry+https://github.com/rust-lang/crates.io-index)", @@ -3070,7 +3082,8 @@ dependencies = [ "checksum hostname 0.1.5 (registry+https://github.com/rust-lang/crates.io-index)" = "21ceb46a83a85e824ef93669c8b390009623863b5c195d1ba747292c0c72f94e" "checksum hostname 0.3.1 (registry+https://github.com/rust-lang/crates.io-index)" = "3c731c3e10504cc8ed35cfe2f1db4c9274c3d35fa486e3b31df46f068ef3e867" "checksum htmlescape 0.3.1 (registry+https://github.com/rust-lang/crates.io-index)" = "e9025058dae765dee5070ec375f591e2ba14638c63feff74f13805a72e523163" -"checksum http 0.2.0 (registry+https://github.com/rust-lang/crates.io-index)" = "b708cc7f06493459026f53b9a61a7a121a5d1ec6238dee58ea4941132b30156b" +"checksum http 0.2.1 (registry+https://github.com/rust-lang/crates.io-index)" = "28d569972648b2c512421b5f2a405ad6ac9666547189d0c5477a3f200f3e02f9" +"checksum http-signature-normalization 0.4.1 (registry+https://github.com/rust-lang/crates.io-index)" = "257835255b5d40c6de712d90e56dc874ca5da2816121e7b9f3cfc7b3a55a5714" "checksum httparse 1.3.4 (registry+https://github.com/rust-lang/crates.io-index)" = "cd179ae861f0c2e53da70d892f5f3029f9594be0c41dc5269cd371691b1dc2f9" "checksum humantime 1.3.0 (registry+https://github.com/rust-lang/crates.io-index)" = "df004cfca50ef23c36850aaaa59ad52cc70d0e90243c3c7737a4dd32dc7a3c4f" "checksum ident_case 1.0.1 (registry+https://github.com/rust-lang/crates.io-index)" = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" diff --git a/server/Cargo.toml b/server/Cargo.toml index 03bbfbee..8bd170ef 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -39,3 +39,6 @@ percent-encoding = "2.1.0" isahc = "0.9" comrak = "0.7" openssl = "0.10" +http = "0.2.1" +http-signature-normalization = "0.4.1" +base64 = "0.12.0" diff --git a/server/src/api/community.rs b/server/src/api/community.rs index 40d8afe3..30c8aa20 100644 --- a/server/src/api/community.rs +++ b/server/src/api/community.rs @@ -1,6 +1,7 @@ use super::*; use crate::apub::activities::follow_community; -use crate::apub::{gen_keypair_str, make_apub_endpoint, EndpointType}; +use crate::apub::signatures::generate_actor_keypair; +use crate::apub::{make_apub_endpoint, EndpointType}; use diesel::PgConnection; use std::str::FromStr; @@ -200,7 +201,7 @@ impl Perform for Oper { } // When you create a community, make sure the user becomes a moderator and a follower - let (community_public_key, community_private_key) = gen_keypair_str(); + let keypair = generate_actor_keypair(); let community_form = CommunityForm { name: data.name.to_owned(), @@ -214,8 +215,8 @@ impl Perform for Oper { updated: None, actor_id: make_apub_endpoint(EndpointType::Community, &data.name).to_string(), local: true, - private_key: Some(community_private_key), - public_key: Some(community_public_key), + private_key: Some(keypair.private_key), + public_key: Some(keypair.public_key), last_refreshed_at: None, published: None, }; diff --git a/server/src/api/user.rs b/server/src/api/user.rs index fbdead53..35bdd33a 100644 --- a/server/src/api/user.rs +++ b/server/src/api/user.rs @@ -1,5 +1,6 @@ use super::*; -use crate::apub::{gen_keypair_str, make_apub_endpoint, EndpointType}; +use crate::apub::signatures::generate_actor_keypair; +use crate::apub::{make_apub_endpoint, EndpointType}; use crate::settings::Settings; use crate::{generate_random_string, send_email}; use bcrypt::verify; @@ -251,7 +252,7 @@ impl Perform for Oper { return Err(APIError::err("admin_already_created").into()); } - let (user_public_key, user_private_key) = gen_keypair_str(); + let keypair = generate_actor_keypair(); // Register the new user let user_form = UserForm { @@ -274,8 +275,8 @@ impl Perform for Oper { actor_id: make_apub_endpoint(EndpointType::User, &data.username).to_string(), bio: None, local: true, - private_key: Some(user_private_key), - public_key: Some(user_public_key), + private_key: Some(keypair.private_key), + public_key: Some(keypair.public_key), last_refreshed_at: None, }; @@ -295,7 +296,7 @@ impl Perform for Oper { } }; - let (community_public_key, community_private_key) = gen_keypair_str(); + let keypair = generate_actor_keypair(); // Create the main community if it doesn't exist let main_community: Community = match Community::read(&conn, 2) { @@ -314,8 +315,8 @@ impl Perform for Oper { updated: None, actor_id: make_apub_endpoint(EndpointType::Community, default_community_name).to_string(), local: true, - private_key: Some(community_private_key), - public_key: Some(community_public_key), + private_key: Some(keypair.private_key), + public_key: Some(keypair.public_key), last_refreshed_at: None, published: None, }; diff --git a/server/src/apub/activities.rs b/server/src/apub/activities.rs index 73b7293d..a7f63ec8 100644 --- a/server/src/apub/activities.rs +++ b/server/src/apub/activities.rs @@ -1,4 +1,5 @@ use crate::apub::is_apub_id_valid; +use crate::apub::signatures::{sign, Keypair}; use crate::db::community::Community; use crate::db::community_view::CommunityFollowerView; use crate::db::post::Post; @@ -14,6 +15,7 @@ use failure::_core::fmt::Debug; use isahc::prelude::*; use log::debug; use serde::Serialize; +use url::Url; fn populate_object_props( props: &mut ObjectProperties, @@ -32,18 +34,27 @@ fn populate_object_props( } /// Send an activity to a list of recipients, using the correct headers etc. -fn send_activity(activity: &A, to: Vec) -> Result<(), Error> +fn send_activity( + activity: &A, + keypair: &Keypair, + sender_id: &str, + to: Vec, +) -> Result<(), Error> where A: Serialize + Debug, { let json = serde_json::to_string(&activity)?; debug!("Sending activitypub activity {} to {:?}", json, to); for t in to { - if !is_apub_id_valid(&t) { + let to_url = Url::parse(&t)?; + if !is_apub_id_valid(&to_url) { debug!("Not sending activity to {} (invalid or blacklisted)", t); continue; } - let res = Request::post(t) + let request = Request::post(t).header("Host", to_url.domain().unwrap()); + let signature = sign(&request, keypair, sender_id)?; + let res = request + .header("Signature", signature) .header("Content-Type", "application/json") .body(json.to_owned())? .send()?; @@ -77,7 +88,12 @@ pub fn post_create(post: &Post, creator: &User_, conn: &PgConnection) -> Result< .create_props .set_actor_xsd_any_uri(creator.actor_id.to_owned())? .set_object_base_box(page)?; - send_activity(&create, get_follower_inboxes(conn, &community)?)?; + send_activity( + &create, + &creator.get_keypair().unwrap(), + &creator.actor_id, + get_follower_inboxes(conn, &community)?, + )?; Ok(()) } @@ -95,7 +111,12 @@ pub fn post_update(post: &Post, creator: &User_, conn: &PgConnection) -> Result< .update_props .set_actor_xsd_any_uri(creator.actor_id.to_owned())? .set_object_base_box(page)?; - send_activity(&update, get_follower_inboxes(conn, &community)?)?; + send_activity( + &update, + &creator.get_keypair().unwrap(), + &creator.actor_id, + get_follower_inboxes(conn, &community)?, + )?; Ok(()) } @@ -116,12 +137,23 @@ pub fn follow_community( .set_actor_xsd_any_uri(user.actor_id.clone())? .set_object_xsd_any_uri(community.actor_id.clone())?; let to = format!("{}/inbox", community.actor_id); - send_activity(&follow, vec![to])?; + send_activity( + &follow, + &community.get_keypair().unwrap(), + &community.actor_id, + vec![to], + )?; Ok(()) } /// As a local community, accept the follow request from a remote user. -pub fn accept_follow(follow: &Follow) -> Result<(), Error> { +pub fn accept_follow(follow: &Follow, conn: &PgConnection) -> Result<(), Error> { + let community_uri = follow + .follow_props + .get_actor_xsd_any_uri() + .unwrap() + .to_string(); + let community = Community::read_from_actor_id(conn, &community_uri)?; let mut accept = Accept::new(); accept .object_props @@ -137,14 +169,12 @@ pub fn accept_follow(follow: &Follow) -> Result<(), Error> { accept .accept_props .set_object_base_box(BaseBox::from_concrete(follow.clone())?)?; - let to = format!( - "{}/inbox", - follow - .follow_props - .get_actor_xsd_any_uri() - .unwrap() - .to_string() - ); - send_activity(&accept, vec![to])?; + let to = format!("{}/inbox", community_uri); + send_activity( + &accept, + &community.get_keypair().unwrap(), + &community.actor_id, + vec![to], + )?; Ok(()) } diff --git a/server/src/apub/community_inbox.rs b/server/src/apub/community_inbox.rs index 65d7bec1..a60d8c68 100644 --- a/server/src/apub/community_inbox.rs +++ b/server/src/apub/community_inbox.rs @@ -56,6 +56,6 @@ fn handle_follow(follow: &Follow, conn: &PgConnection) -> Result(url: &Url) -> Result where Response: for<'de> Deserialize<'de>, { - if !is_apub_id_valid(&url.to_string()) { + if !is_apub_id_valid(&url) { return Err(format_err!("Activitypub uri invalid or blocked: {}", url)); } // TODO: this function should return a future diff --git a/server/src/apub/mod.rs b/server/src/apub/mod.rs index 634f3510..8d5df8a8 100644 --- a/server/src/apub/mod.rs +++ b/server/src/apub/mod.rs @@ -12,7 +12,6 @@ use activitystreams::actor::{properties::ApActorProperties, Group, Person}; use activitystreams::ext::Ext; use actix_web::body::Body; use actix_web::HttpResponse; -use openssl::{pkey::PKey, rsa::Rsa}; use serde::ser::Serialize; use url::Url; @@ -72,31 +71,9 @@ pub fn get_apub_protocol_string() -> &'static str { } } -/// Generate the asymmetric keypair for ActivityPub HTTP signatures. -pub fn gen_keypair_str() -> (String, String) { - let rsa = Rsa::generate(2048).expect("sign::gen_keypair: key generation error"); - let pkey = PKey::from_rsa(rsa).expect("sign::gen_keypair: parsing error"); - let public_key = pkey - .public_key_to_pem() - .expect("sign::gen_keypair: public key encoding error"); - let private_key = pkey - .private_key_to_pem_pkcs8() - .expect("sign::gen_keypair: private key encoding error"); - (vec_bytes_to_str(public_key), vec_bytes_to_str(private_key)) -} - -fn vec_bytes_to_str(bytes: Vec) -> String { - String::from_utf8_lossy(&bytes).into_owned() -} - // Checks if the ID has a valid format, correct scheme, and is in the whitelist. -fn is_apub_id_valid(apub_id: &str) -> bool { - let url = match Url::parse(apub_id) { - Ok(u) => u, - Err(_) => return false, - }; - - if url.scheme() != get_apub_protocol_string() { +fn is_apub_id_valid(apub_id: &Url) -> bool { + if apub_id.scheme() != get_apub_protocol_string() { return false; } @@ -106,7 +83,7 @@ fn is_apub_id_valid(apub_id: &str) -> bool { .split(',') .map(|d| d.to_string()) .collect(); - match url.domain() { + match apub_id.domain() { Some(d) => whitelist.contains(&d.to_owned()), None => false, } diff --git a/server/src/apub/signatures.rs b/server/src/apub/signatures.rs index 0348acb8..e0734e7b 100644 --- a/server/src/apub/signatures.rs +++ b/server/src/apub/signatures.rs @@ -1,7 +1,69 @@ -// For this example, we'll use the Extensible trait, the Extension trait, the Actor trait, and -// the Person type use activitystreams::{actor::Actor, ext::Extension}; +use failure::Error; +use http::request::Builder; +use http_signature_normalization::Config; +use openssl::hash::MessageDigest; +use openssl::sign::Signer; +use openssl::{pkey::PKey, rsa::Rsa}; use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; + +pub struct Keypair { + pub private_key: String, + pub public_key: String, +} + +/// Generate the asymmetric keypair for ActivityPub HTTP signatures. +pub fn generate_actor_keypair() -> Keypair { + let rsa = Rsa::generate(2048).expect("sign::gen_keypair: key generation error"); + let pkey = PKey::from_rsa(rsa).expect("sign::gen_keypair: parsing error"); + let public_key = pkey + .public_key_to_pem() + .expect("sign::gen_keypair: public key encoding error"); + let private_key = pkey + .private_key_to_pem_pkcs8() + .expect("sign::gen_keypair: private key encoding error"); + Keypair { + private_key: String::from_utf8_lossy(&private_key).into_owned(), + public_key: String::from_utf8_lossy(&public_key).into_owned(), + } +} + +/// Signs request headers with the given keypair. +pub fn sign(request: &Builder, keypair: &Keypair, sender_id: &str) -> Result { + let signing_key_id = format!("{}#main-key", sender_id); + let config = Config::new(); + + let headers = request + .headers_ref() + .unwrap() + .iter() + .map(|h| -> Result<(String, String), Error> { + Ok((h.0.as_str().to_owned(), h.1.to_str()?.to_owned())) + }) + .collect::, Error>>()?; + + let signature_header_value = config + .begin_sign( + request.method_ref().unwrap().as_str(), + request + .uri_ref() + .unwrap() + .path_and_query() + .unwrap() + .as_str(), + headers, + ) + .sign(signing_key_id, |signing_string| { + let private_key = PKey::private_key_from_pem(keypair.private_key.as_bytes())?; + let mut signer = Signer::new(MessageDigest::sha256(), &private_key).unwrap(); + signer.update(signing_string.as_bytes()).unwrap(); + Ok(base64::encode(signer.sign_to_vec()?)) as Result<_, Error> + })? + .signature_header(); + + Ok(signature_header_value) +} // The following is taken from here: // https://docs.rs/activitystreams/0.5.0-alpha.17/activitystreams/ext/index.html diff --git a/server/src/db/code_migrations.rs b/server/src/db/code_migrations.rs index a13a9964..a72de685 100644 --- a/server/src/db/code_migrations.rs +++ b/server/src/db/code_migrations.rs @@ -4,7 +4,8 @@ use super::community::{Community, CommunityForm}; use super::post::Post; use super::user::{UserForm, User_}; use super::*; -use crate::apub::{gen_keypair_str, make_apub_endpoint, EndpointType}; +use crate::apub::signatures::generate_actor_keypair; +use crate::apub::{make_apub_endpoint, EndpointType}; use crate::naive_now; use log::info; @@ -29,7 +30,7 @@ fn user_updates_2020_04_02(conn: &PgConnection) -> Result<(), Error> { .load::(conn)?; for cuser in &incorrect_users { - let (user_public_key, user_private_key) = gen_keypair_str(); + let keypair = generate_actor_keypair(); let form = UserForm { name: cuser.name.to_owned(), @@ -51,8 +52,8 @@ fn user_updates_2020_04_02(conn: &PgConnection) -> Result<(), Error> { actor_id: make_apub_endpoint(EndpointType::User, &cuser.name).to_string(), bio: cuser.bio.to_owned(), local: cuser.local, - private_key: Some(user_private_key), - public_key: Some(user_public_key), + private_key: Some(keypair.private_key), + public_key: Some(keypair.public_key), last_refreshed_at: Some(naive_now()), }; @@ -76,7 +77,7 @@ fn community_updates_2020_04_02(conn: &PgConnection) -> Result<(), Error> { .load::(conn)?; for ccommunity in &incorrect_communities { - let (community_public_key, community_private_key) = gen_keypair_str(); + let keypair = generate_actor_keypair(); let form = CommunityForm { name: ccommunity.name.to_owned(), @@ -90,8 +91,8 @@ fn community_updates_2020_04_02(conn: &PgConnection) -> Result<(), Error> { updated: None, actor_id: make_apub_endpoint(EndpointType::Community, &ccommunity.name).to_string(), local: ccommunity.local, - private_key: Some(community_private_key), - public_key: Some(community_public_key), + private_key: Some(keypair.private_key), + public_key: Some(keypair.public_key), last_refreshed_at: Some(naive_now()), published: None, }; diff --git a/server/src/db/community.rs b/server/src/db/community.rs index ca2fc120..7a706557 100644 --- a/server/src/db/community.rs +++ b/server/src/db/community.rs @@ -1,4 +1,5 @@ use super::*; +use crate::apub::signatures::Keypair; use crate::schema::{community, community_follower, community_moderator, community_user_ban}; #[derive(Queryable, Identifiable, PartialEq, Debug, Serialize, Deserialize)] @@ -95,6 +96,21 @@ impl Community { pub fn get_url(&self) -> String { format!("https://{}/c/{}", Settings::get().hostname, self.name) } + + pub fn get_keypair(&self) -> Option { + if let Some(private) = self.private_key.to_owned() { + if let Some(public) = self.public_key.to_owned() { + Some(Keypair { + private_key: private, + public_key: public, + }) + } else { + None + } + } else { + None + } + } } #[derive(Identifiable, Queryable, Associations, PartialEq, Debug)] diff --git a/server/src/db/user.rs b/server/src/db/user.rs index 3a079f09..dfce9f2c 100644 --- a/server/src/db/user.rs +++ b/server/src/db/user.rs @@ -1,4 +1,5 @@ use super::*; +use crate::apub::signatures::Keypair; use crate::schema::user_; use crate::schema::user_::dsl::*; use crate::{is_email_regex, naive_now, Settings}; @@ -124,6 +125,21 @@ impl User_ { use crate::schema::user_::dsl::*; user_.filter(actor_id.eq(object_id)).first::(conn) } + + pub fn get_keypair(&self) -> Option { + if let Some(private) = self.private_key.to_owned() { + if let Some(public) = self.public_key.to_owned() { + Some(Keypair { + private_key: private, + public_key: public, + }) + } else { + None + } + } else { + None + } + } } #[derive(Debug, Serialize, Deserialize)]