rewrite publication workflow

This commit is contained in:
Vincent Breitmoser 2019-05-03 15:34:34 +02:00
parent c458a07970
commit fb4eacfa56
6 changed files with 361 additions and 79 deletions

View File

@ -96,7 +96,7 @@ a.brand {
.publishedUid {
margin-left: auto;
margin-right: auto;
width: 50%;
width: 65%;
text-align: left;
}
.publishedUid div {

View File

@ -0,0 +1,15 @@
{{#> layout }}
<p>
Your keys have been successfully uploaded:
</p>
<ul>
{{#each keys}}
<li><span class="fingerprint"><a href="{{key_link}}" target="_blank">{{key_fpr}}</a></span></li>
{{/each}}
</ul>
<p>
<strong>Note:</strong> To make keys searchable by address, you must upload them individually.
</p>
{{/layout}}

View File

@ -1,16 +1,49 @@
{{#> layout }}
<h2>Email verification</h2>
<p style="line-height: 2em;">
Your key was successfully uploaded, and can now be retrieved by fingerprint.<br />
<p>
Your key <span class="fingerprint"><a href="{{key_link}}" target="_blank">{{key_fpr}}</a></span> was successfully uploaded.
</p>
We also sent verification emails to the following addresses:
<div class="verificationEmails">
<ul>
{{#each emails}}
<li><span class="email">{{this}}</span></li>
{{#if is_revoked}}
<p>
This key is revoked. It can not be searched by email address.
</p>
{{else}}
{{#if uid_status}}
<p>
You can make the key available for search by address:
</p>
{{#each uid_status}}
<div class="publishedUid">
<div>
{{#if revoked}}
Revoked
{{else}}
{{#if published}}
Published
{{else}}
{{#if requested}}
Verification Pending
{{else}}
<form action="/publish/verify" method="post">
<input type="hidden" name="token" value="{{../token}}" />
<input type="hidden" name="address" value="{{address}}" />
<input type="submit" class="link" value="Send Verification Mail">
</form>
{{/if}}
{{/if}}
{{/if}}
</div>
<p>
<span class="email">{{address}}</span>
</p>
</div>
{{/each}}
</ul>
</div>
To make the key available for search by address, follow the link sent to
each address.
{{else}}
<p>
This key contains no email addresses.
</p>
{{/if}}
{{/if}}
{{/layout}}

View File

@ -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 = "<data>")]
pub fn pks_add(
db: rocket::State<KeyDatabase>,
tokens_stateless: rocket::State<tokens::Service>,
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),
}

View File

@ -317,6 +317,7 @@ fn rocket_factory(rocket: rocket::Rocket) -> Result<rocket::Rocket> {
// 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]) ->

View File

@ -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<String>,
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<PublishUidStatus>,
}
#[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<UploadOkKey>,
}
#[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<template::PublishUidStatus>
) -> 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<Email>,
requested: Vec<Email>,
}
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 = "<data>")]
pub fn vks_v1_publish_post(
db: rocket::State<KeyDatabase>,
mail_service: rocket::State<mail::Service>,
token_service: rocket::State<StatefulTokens>,
tokens_stateless: rocket::State<tokens::Service>,
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<KeyDatabase>,
cont_type: &ContentType,
data: Data,
) -> Result<MyResponse> {
handle_upload(db, cont_type, data, None)
}
// signature requires the request to have a `Content-Type`
pub fn handle_upload(
db: rocket::State<KeyDatabase>, cont_type: &ContentType, data: Data,
services: Option<(rocket::State<mail::Service>, rocket::State<StatefulTokens>)>,
db: &KeyDatabase,
tokens_stateless: &tokens::Service,
cont_type: &ContentType,
data: Data,
) -> Result<MyResponse> {
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<mail::Service>, rocket::State<StatefulTokens>)>,
db: &KeyDatabase,
tokens_stateless: &tokens::Service,
data: Data,
boundary: &str,
) -> Result<MyResponse> {
// 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<mail::Service>, rocket::State<StatefulTokens>)>,
tokens_stateless: &tokens::Service,
) -> Result<MyResponse> {
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<R>(
reader: R,
db: &KeyDatabase,
services: Option<(rocket::State<mail::Service>, rocket::State<StatefulTokens>)>,
tokens_stateless: &tokens::Service,
reader: R,
) -> Result<MyResponse>
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<String> = 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<MyResponse> {
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<TPK>,
) -> Result<MyResponse> {
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="<request>")]
pub fn vks_publish_verify(
db: rocket::State<KeyDatabase>,
request: Form<forms::VerifyRequest>,
token_stateful: rocket::State<StatefulTokens>,
token_stateless: rocket::State<tokens::Service>,
mail_service: rocket::State<mail::Service>,
) -> Result<MyResponse> {
let verify_state = token_stateless.check::<VerifyTpkState>(&request.token)?;
let tpk_status = db.get_tpk_status(&verify_state.fpr, &verify_state.addresses)?;
let email_requested = request.address.parse::<Email>()
.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<Email>,
) -> 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/<token>")]