From fb4eacfa56d5d09cc6f92f3c3eb61af7543f8232 Mon Sep 17 00:00:00 2001 From: Vincent Breitmoser Date: Fri, 3 May 2019 15:34:34 +0200 Subject: [PATCH] rewrite publication workflow --- dist/assets/site.css | 2 +- .../publish/publish-ok-multiple.html.hbs | 15 + dist/templates/publish/publish_ok.html.hbs | 57 +++- src/web/hkp.rs | 5 +- src/web/mod.rs | 98 ++++++- src/web/upload.rs | 263 ++++++++++++++---- 6 files changed, 361 insertions(+), 79 deletions(-) create mode 100644 dist/templates/publish/publish-ok-multiple.html.hbs diff --git a/dist/assets/site.css b/dist/assets/site.css index 568ecb1..881b9aa 100644 --- a/dist/assets/site.css +++ b/dist/assets/site.css @@ -96,7 +96,7 @@ a.brand { .publishedUid { margin-left: auto; margin-right: auto; - width: 50%; + width: 65%; text-align: left; } .publishedUid div { diff --git a/dist/templates/publish/publish-ok-multiple.html.hbs b/dist/templates/publish/publish-ok-multiple.html.hbs new file mode 100644 index 0000000..1f863bb --- /dev/null +++ b/dist/templates/publish/publish-ok-multiple.html.hbs @@ -0,0 +1,15 @@ +{{#> layout }} +

+ Your keys have been successfully uploaded: +

+ + + +

+ Note: To make keys searchable by address, you must upload them individually. +

+{{/layout}} diff --git a/dist/templates/publish/publish_ok.html.hbs b/dist/templates/publish/publish_ok.html.hbs index 7701f29..72423a3 100644 --- a/dist/templates/publish/publish_ok.html.hbs +++ b/dist/templates/publish/publish_ok.html.hbs @@ -1,16 +1,49 @@ {{#> layout }} -

Email verification

-

- Your key was successfully uploaded, and can now be retrieved by fingerprint.
+

+ Your key {{key_fpr}} was successfully uploaded.

- We also sent verification emails to the following addresses: -
- -
- To make the key available for search by address, follow the link sent to - each address. + {{else}} +

+ This key contains no email addresses. +

+ {{/if}} + {{/if}} + {{/layout}} diff --git a/src/web/hkp.rs b/src/web/hkp.rs index 484c528..9a6a95f 100644 --- a/src/web/hkp.rs +++ b/src/web/hkp.rs @@ -9,6 +9,8 @@ use rocket::http::uri::Uri; use database::{Database, Query, KeyDatabase}; use database::types::{Email, Fingerprint, KeyID}; +use tokens; + use web::{ HagridState, MyResponse, @@ -118,10 +120,11 @@ impl<'a, 'r> FromRequest<'a, 'r> for Hkp { #[post("/pks/add", data = "")] pub fn pks_add( db: rocket::State, + tokens_stateless: rocket::State, cont_type: &ContentType, data: Data, ) -> MyResponse { - match upload::handle_upload_without_verify(db, cont_type, data) { + match upload::handle_upload(&db, &tokens_stateless, cont_type, data) { Ok(_) => MyResponse::plain("Ok".into()), Err(err) => MyResponse::ise(err), } diff --git a/src/web/mod.rs b/src/web/mod.rs index 5f6ccb9..7201fa8 100644 --- a/src/web/mod.rs +++ b/src/web/mod.rs @@ -317,6 +317,7 @@ fn rocket_factory(rocket: rocket::Rocket) -> Result { // User interaction. upload::publish, upload::publish_verify, + upload::vks_publish_verify, // HKP hkp::pks_lookup, hkp::pks_add, @@ -524,7 +525,7 @@ pub mod tests { } #[test] - fn upload_single() { + fn upload_verify_single() { let (tmpdir, client) = client().unwrap(); let filemail_into = tmpdir.path().join("filemail"); @@ -535,7 +536,7 @@ pub mod tests { let mut tpk_serialized = Vec::new(); tpk.serialize(&mut tpk_serialized).unwrap(); - vks_publish_submit(&client, &tpk_serialized); + let token = vks_publish_submit_get_token(&client, &tpk_serialized); // Prior to email confirmation, we should not be able to look // it up by email address. @@ -548,6 +549,9 @@ pub mod tests { // And check that we can see the human-readable result page. check_hr_responses_by_fingerprint(&client, &tpk, 0); + // Check the verification link + check_verify_link(&client, &token, "foo@invalid.example.com"); + // Now check for the verification mail. check_mails_and_verify_email(&client, filemail_into.as_path()); @@ -578,8 +582,7 @@ pub mod tests { #[test] fn upload_two() { - let (tmpdir, config) = configuration().unwrap(); - let filemail_into = tmpdir.path().join("filemail"); + let (_tmpdir, config) = configuration().unwrap(); let rocket = rocket_factory(rocket::custom(config)).unwrap(); let client = Client::new(rocket).expect("valid rocket instance"); @@ -595,7 +598,7 @@ pub mod tests { let mut tpk_serialized = Vec::new(); tpk_0.serialize(&mut tpk_serialized).unwrap(); tpk_1.serialize(&mut tpk_serialized).unwrap(); - vks_publish_submit(&client, &tpk_serialized); + vks_publish_submit_multiple(&client, &tpk_serialized); // Prior to email confirmation, we should not be able to look // them up by email address. @@ -610,14 +613,57 @@ pub mod tests { // And check that we can see the human-readable result page. check_hr_responses_by_fingerprint(&client, &tpk_0, 0); check_hr_responses_by_fingerprint(&client, &tpk_1, 0); + } + + #[test] + fn upload_verify_two() { + let (tmpdir, config) = configuration().unwrap(); + let filemail_into = tmpdir.path().join("filemail"); + + let rocket = rocket_factory(rocket::custom(config)).unwrap(); + let client = Client::new(rocket).expect("valid rocket instance"); + + // Generate two keys and upload them. + let tpk_1 = TPKBuilder::autocrypt( + None, Some("foo@invalid.example.com".into())) + .generate().unwrap().0; + let tpk_2 = TPKBuilder::autocrypt( + None, Some("bar@invalid.example.com".into())) + .generate().unwrap().0; + + let mut tpk_serialized_1 = Vec::new(); + tpk_1.serialize(&mut tpk_serialized_1).unwrap(); + let token_1 = vks_publish_submit_get_token(&client, &tpk_serialized_1); + + let mut tpk_serialized_2 = Vec::new(); + tpk_2.serialize(&mut tpk_serialized_2).unwrap(); + let token_2 = vks_publish_submit_get_token(&client, &tpk_serialized_2); + + // Prior to email confirmation, we should not be able to look + // them up by email address. + check_null_responses_by_email(&client, "foo@invalid.example.com"); + check_null_responses_by_email(&client, "bar@invalid.example.com"); + + // And check that we can get them back via the machine readable + // interface. + check_mr_responses_by_fingerprint(&client, &tpk_1, 0); + check_mr_responses_by_fingerprint(&client, &tpk_2, 0); + + // And check that we can see the human-readable result page. + check_hr_responses_by_fingerprint(&client, &tpk_1, 0); + check_hr_responses_by_fingerprint(&client, &tpk_2, 0); + + // Check the verification link + check_verify_link(&client, &token_1, "foo@invalid.example.com"); + check_verify_link(&client, &token_2, "bar@invalid.example.com"); // Now check for the verification mails. check_mails_and_verify_email(&client, &filemail_into); check_mails_and_verify_email(&client, &filemail_into); // Now lookups using the mail address should work. - check_responses_by_email(&client, "foo@invalid.example.com", &tpk_0, 1); - check_responses_by_email(&client, "bar@invalid.example.com", &tpk_1, 1); + check_responses_by_email(&client, "foo@invalid.example.com", &tpk_1, 1); + check_responses_by_email(&client, "bar@invalid.example.com", &tpk_2, 1); // Request deletion of the bindings. vks_manage(&client, "foo@invalid.example.com"); @@ -631,12 +677,12 @@ pub mod tests { check_null_responses_by_email(&client, "bar@invalid.example.com"); // But lookup by fingerprint should still work. - check_mr_responses_by_fingerprint(&client, &tpk_0, 0); check_mr_responses_by_fingerprint(&client, &tpk_1, 0); + check_mr_responses_by_fingerprint(&client, &tpk_2, 0); // And check that we can see the human-readable result page. - check_hr_responses_by_fingerprint(&client, &tpk_0, 0); check_hr_responses_by_fingerprint(&client, &tpk_1, 0); + check_hr_responses_by_fingerprint(&client, &tpk_2, 0); assert_consistency(client.rocket()); } @@ -782,6 +828,19 @@ pub mod tests { &tpk, nr_uids); } + fn check_verify_link(client: &Client, token: &str, address: &str) { + let encoded = ::url::form_urlencoded::Serializer::new(String::new()) + .append_pair("token", token) + .append_pair("address", address) + .finish(); + + let response = client.post("/publish/verify") + .header(ContentType::Form) + .body(encoded.as_bytes()) + .dispatch(); + assert_eq!(response.status(), Status::Ok); + } + fn check_mails_and_verify_email(client: &Client, filemail_path: &Path) { let pattern = format!("{}(/publish/[^ \t\n]*)", BASE_URI); let confirm_uri = pop_mail_capture_pattern(filemail_path, &pattern); @@ -822,9 +881,26 @@ pub mod tests { Ok(None) } - fn vks_publish_submit<'a>(client: &'a Client, data: &[u8]) { - let response = vks_publish_submit_response(client, data); + fn vks_publish_submit_multiple<'a>(client: &'a Client, data: &[u8]) { + let mut response = vks_publish_submit_response(client, data); + let response_body = response.body_string().unwrap(); + assert_eq!(response.status(), Status::Ok); + assert!(response_body.contains("you must upload them individually")); + } + + fn vks_publish_submit_get_token<'a>(client: &'a Client, data: &[u8]) -> String { + let mut response = vks_publish_submit_response(client, data); + let response_body = response.body_string().unwrap(); + + let pattern = "name=\"token\" value=\"([^\"]*)\""; + let capture_re = regex::bytes::Regex::new(pattern).unwrap(); + let capture_content = capture_re .captures(response_body.as_bytes()).unwrap() + .get(1).unwrap().as_bytes(); + let token = String::from_utf8_lossy(capture_content).to_string(); + + assert_eq!(response.status(), Status::Ok); + token } fn vks_publish_submit_response<'a>(client: &'a Client, data: &[u8]) -> diff --git a/src/web/upload.rs b/src/web/upload.rs index 06b6041..dd632e2 100644 --- a/src/web/upload.rs +++ b/src/web/upload.rs @@ -6,13 +6,17 @@ use multipart::server::save::SaveResult::*; use multipart::server::Multipart; use rocket::http::ContentType; +use rocket::request::Form; use rocket::Data; -use database::{Database, KeyDatabase, StatefulTokens}; -use database::types::Fingerprint; +use database::{Database, KeyDatabase, StatefulTokens, EmailAddressStatus, TpkStatus}; +use database::types::{Fingerprint,Email}; use mail; +use tokens::{self, StatelessSerializable}; use web::MyResponse; +use sequoia_openpgp::TPK; + use std::io::Read; use std::convert::TryFrom; @@ -36,9 +40,85 @@ mod template { #[derive(Serialize)] pub struct VerificationSent { - pub emails: Vec, pub commit: String, pub version: String, + pub key_fpr: String, + pub key_link: String, + pub is_revoked: bool, + pub token: String, + pub uid_status: Vec, + } + + #[derive(Serialize)] + pub struct UploadOkKey { + pub key_fpr: String, + pub key_link: String, + } + + #[derive(Serialize)] + pub struct UploadOkMultiple { + pub commit: String, + pub version: String, + pub keys: Vec, + } + + #[derive(Serialize)] + pub struct PublishUidStatus { + pub address: String, + pub requested: bool, + pub published: bool, + pub revoked: bool, + } + +} + +mod forms { + #[derive(FromForm)] + pub struct VerifyRequest { + pub token: String, + pub address: String, + } +} + +impl MyResponse { + fn publish_ok( + token_stateless: &tokens::Service, + verify_state: VerifyTpkState, + uid_status: Vec + ) -> Self { + let key_fpr = verify_state.fpr.to_string(); + let key_link = format!("/pks/lookup?op=get&search={}", &verify_state.fpr); + let token = token_stateless.create(verify_state); + + let context = template::VerificationSent { + version: env!("VERGEN_SEMVER").to_string(), + commit: env!("VERGEN_SHA_SHORT").to_string(), + is_revoked: false, + key_fpr, + key_link, + token: token, + uid_status, + }; + + MyResponse::ok("publish/publish_ok", context) + } +} + +#[derive(Serialize,Deserialize)] +struct VerifyTpkState { + fpr: Fingerprint, + addresses: Vec, + requested: Vec, +} + +impl StatelessSerializable for VerifyTpkState { +} + +impl VerifyTpkState { + fn with_requested(self, requested_address: Email) -> Self { + let VerifyTpkState { fpr, addresses, mut requested } = self; + requested.push(requested_address); + VerifyTpkState { fpr, addresses, requested } } } @@ -56,28 +136,22 @@ pub fn publish(guide: bool) -> MyResponse { #[post("/vks/v1/publish", data = "")] pub fn vks_v1_publish_post( db: rocket::State, - mail_service: rocket::State, - token_service: rocket::State, + tokens_stateless: rocket::State, cont_type: &ContentType, data: Data, ) -> MyResponse { - match handle_upload(db, cont_type, data, Some((mail_service, token_service))) { + match handle_upload(&db, &tokens_stateless, cont_type, data) { Ok(ok) => ok, Err(err) => MyResponse::ise(err), } } -pub fn handle_upload_without_verify( - db: rocket::State, - cont_type: &ContentType, - data: Data, -) -> Result { - handle_upload(db, cont_type, data, None) -} // signature requires the request to have a `Content-Type` pub fn handle_upload( - db: rocket::State, cont_type: &ContentType, data: Data, - services: Option<(rocket::State, rocket::State)>, + db: &KeyDatabase, + tokens_stateless: &tokens::Service, + cont_type: &ContentType, + data: Data, ) -> Result { if cont_type.is_form_data() { // multipart/form-data @@ -90,7 +164,7 @@ pub fn handle_upload( boundary param not provided"))), }; - process_upload(boundary, data, db.inner(), services) + process_upload(db, tokens_stateless, data, boundary) } else if cont_type.is_form() { use rocket::request::FormItems; use std::io::Cursor; @@ -111,9 +185,9 @@ pub fn handle_upload( match key.as_str() { "keytext" => { return process_key( + db, + tokens_stateless, Cursor::new(decoded_value.as_bytes()), - &db, - services, ); } _ => { /* skip */ } @@ -129,18 +203,20 @@ pub fn handle_upload( } fn process_upload( - boundary: &str, data: Data, db: &KeyDatabase, - services: Option<(rocket::State, rocket::State)>, + db: &KeyDatabase, + tokens_stateless: &tokens::Service, + data: Data, + boundary: &str, ) -> Result { // saves all fields, any field longer than 10kB goes to a temporary directory // Entries could implement FromData though that would give zero control over // how the files are saved; Multipart would be a good impl candidate though match Multipart::with_body(data.open().take(UPLOAD_LIMIT), boundary).save().temp() { Full(entries) => { - process_multipart(entries, db, services) + process_multipart(entries, db, tokens_stateless) } Partial(partial, _) => { - process_multipart(partial.entries, db, services) + process_multipart(partial.entries, db, tokens_stateless) } Error(err) => Err(err.into()) } @@ -148,12 +224,12 @@ fn process_upload( fn process_multipart( entries: Entries, db: &KeyDatabase, - services: Option<(rocket::State, rocket::State)>, + tokens_stateless: &tokens::Service, ) -> Result { match entries.fields.get("keytext") { Some(ent) if ent.len() == 1 => { let reader = ent[0].data.readable()?; - process_key(reader, db, services) + process_key(db, tokens_stateless, reader) } Some(_) => Ok(MyResponse::bad_request( @@ -165,9 +241,9 @@ fn process_multipart( } fn process_key( - reader: R, db: &KeyDatabase, - services: Option<(rocket::State, rocket::State)>, + tokens_stateless: &tokens::Service, + reader: R, ) -> Result where R: Read, @@ -188,40 +264,119 @@ where }); } - if tpks.is_empty() { - return Ok(MyResponse::bad_request( - "publish/publish", - failure::err_msg("No key submitted"))); + match tpks.len() { + 0 => Ok(MyResponse::bad_request("publish/publish", + failure::err_msg("No key submitted"))), + 1 => process_key_single(db, tokens_stateless, tpks.into_iter().next().unwrap()), + _ => process_key_multiple(db, tpks), } +} - let mut results: Vec = vec!(); - for tpk in tpks { - let tpk_name = tpk.fingerprint().to_string(); - let tpk_fpr = Fingerprint::try_from(tpk.fingerprint()).unwrap(); - let mut unpublished_emails = db.merge(tpk)?; - unpublished_emails.sort(); +fn process_key_single( + db: &KeyDatabase, + tokens_stateless: &tokens::Service, + tpk: TPK, +) -> Result { + let fp = Fingerprint::try_from(tpk.fingerprint()).unwrap(); - if let Some((ref mail_service, ref token_service)) = services { - for email in unpublished_emails { - let token_content = serde_json::to_string(&(tpk_fpr.clone(), email.clone()))?; - let token = token_service.new_token("verify", token_content.as_bytes())?; - mail_service.send_verification( - tpk_name.clone(), - &email, - &token, - )?; - results.push(email.to_string()); - } + let tpk_status = db.merge(tpk)?; + + let verify_state = { + let emails = tpk_status.email_status.iter() + .map(|(email,_)| email.clone()) + .collect(); + VerifyTpkState { + fpr: fp.clone(), + addresses: emails, + requested: vec!(), } - } - - let context = template::VerificationSent { - emails: results, - version: env!("VERGEN_SEMVER").to_string(), - commit: env!("VERGEN_SHA_SHORT").to_string(), }; - Ok(MyResponse::ok("publish/publish_ok", context)) + Ok(show_publish_verify(tokens_stateless, tpk_status, verify_state, None)) +} + +fn process_key_multiple( + db: &KeyDatabase, + tpks: Vec, +) -> Result { + let merged_keys: Vec<_> = tpks + .into_iter() + .flat_map(|tpk| Fingerprint::try_from(tpk.fingerprint()) + .map(|fpr| (fpr, tpk))) + .flat_map(|(fpr, tpk)| db.merge(tpk).map(|_| fpr)) + .map(|fpr| template::UploadOkKey { + key_fpr: fpr.to_string(), + key_link: format!("/pks/lookup?op=get&search={}", fpr), + }) + .collect(); + + let context = template::UploadOkMultiple { + version: env!("VERGEN_SEMVER").to_string(), + commit: env!("VERGEN_SHA_SHORT").to_string(), + keys: merged_keys, + }; + + Ok(MyResponse::ok("publish/publish-ok-multiple", context)) +} + +#[post("/publish/verify", data="")] +pub fn vks_publish_verify( + db: rocket::State, + request: Form, + token_stateful: rocket::State, + token_stateless: rocket::State, + mail_service: rocket::State, +) -> Result { + let verify_state = token_stateless.check::(&request.token)?; + let tpk_status = db.get_tpk_status(&verify_state.fpr, &verify_state.addresses)?; + + let email_requested = request.address.parse::() + .ok() + .filter(|email| verify_state.addresses.contains(email)) + .filter(|email| !verify_state.requested.contains(email)); + let request_ok = !tpk_status.is_revoked && email_requested.is_some(); + + if request_ok { + let token_content = (verify_state.fpr.clone(), request.address.clone()); + let token_str = serde_json::to_string(&token_content)?; + let token = token_stateful.new_token("verify", token_str.as_bytes())?; + + mail_service.send_verification( + verify_state.fpr.to_string(), + email_requested.as_ref().unwrap(), + &token, + )?; + } + + Ok(show_publish_verify(&token_stateless, tpk_status, verify_state, email_requested)) +} + +fn show_publish_verify( + token_stateless: &tokens::Service, + tpk_status: TpkStatus, + verify_state: VerifyTpkState, + email_requested: Option, +) -> MyResponse { + if tpk_status.is_revoked { + return MyResponse::publish_ok(&token_stateless, verify_state, vec!()) + } + + let verify_state = if let Some(email_requested) = email_requested { + verify_state.with_requested(email_requested) + } else { + verify_state + }; + let uid_status: Vec<_> = tpk_status.email_status.iter() + .map(|(email, status)| + template::PublishUidStatus { + address: email.to_string(), + requested: verify_state.requested.contains(&email), + published: *status == EmailAddressStatus::Published, + revoked: *status == EmailAddressStatus::Revoked, + }) + .collect(); + + MyResponse::publish_ok(&token_stateless, verify_state, uid_status) } #[get("/publish/")]