1use std::borrow::Cow;
2use std::error::Error;
3
4use actix_web::{dev::Payload, web::Data, FromRequest, HttpRequest};
5use autopush_common::{
6 db::User,
7 metric_name::MetricName,
8 metrics::StatsdClientExt,
9 tags::Tags,
10 util::{b64_decode_std, b64_decode_url},
11};
12
13use cadence::StatsdClient;
14use futures::{future::LocalBoxFuture, FutureExt};
15use jsonwebtoken::{Algorithm, DecodingKey, Validation};
16use openssl::hash::MessageDigest;
17use uuid::Uuid;
18
19use crate::error::{ApiError, ApiErrorKind, ApiResult};
20use crate::extractors::{
21 token_info::{ApiVersion, TokenInfo},
22 user::validate_user,
23};
24use crate::headers::{
25 crypto_key::CryptoKeyHeader,
26 vapid::{VapidClaims, VapidError, VapidHeader, VapidHeaderWithKey, VapidVersionData},
27};
28use crate::server::AppState;
29
30use crate::settings::Settings;
31
32#[derive(Clone, Debug)]
34pub struct Subscription {
35 pub user: User,
36 pub channel_id: Uuid,
37 pub vapid: Option<VapidHeaderWithKey>,
38 pub reliability_id: Option<String>,
44}
45
46impl FromRequest for Subscription {
47 type Error = ApiError;
48 type Future = LocalBoxFuture<'static, Result<Self, Self::Error>>;
49
50 fn from_request(req: &HttpRequest, _: &mut Payload) -> Self::Future {
51 let req = req.clone();
52
53 async move {
54 let token_info = TokenInfo::extract(&req).await?;
56 trace!("🔐 Token info: {:?}", &token_info);
57 let app_state: Data<AppState> =
58 Data::extract(&req).await.expect("No server state found");
59
60 let token = app_state
62 .fernet
63 .decrypt(&repad_base64(&token_info.token))
64 .map_err(|e| {
65 trace!("🔐 fernet: {:?}", e.to_string());
68 ApiErrorKind::InvalidToken
69 })?;
70
71 let vapid: Option<VapidHeaderWithKey> = parse_vapid(&token_info, &app_state.metrics)?
73 .map(|vapid| extract_public_key(vapid, &token_info))
74 .transpose()?;
75 trace!("🔍 raw vapid: {:?}", &vapid);
76 if let Some(with_key) = &vapid {
78 validate_vapid_jwt(with_key, &app_state.settings, &app_state.metrics)?;
80 let vapid_version_metric = format!(
82 "{}{:02}",
83 MetricName::UpdatesVapidDraft.as_ref(),
84 with_key.vapid.version()
85 );
86 app_state.metrics.incr_raw(&vapid_version_metric)?;
87 };
88 let reliability_id: Option<String> = vapid.as_ref().and_then(|v| {
92 app_state
93 .reliability_filter
94 .is_trackable(v)
95 .then(|| app_state.reliability_filter.get_id(req.headers()))
96 });
97 trace!("🔍 track_id: {:?}", reliability_id);
98 app_state
99 .metrics
100 .incr_with_tags(MetricName::NotificationReceived)
101 .with_tag("trackable", &reliability_id.is_some().to_string())
102 .send();
103 if let Some(header) = &vapid {
107 if let Some(sub) = header
108 .vapid
109 .insecure_sub()
110 .inspect_err(|e: &VapidError| {
111 let mut tags = Tags::default();
113 tags.tags
114 .insert("error".to_owned(), e.as_metric().to_owned());
115 app_state
116 .metrics
117 .incr_with_tags(MetricName::NotificationAuthError)
118 .with_tag("error", e.as_metric())
119 .send();
120 })
121 .unwrap_or_default()
122 {
123 info!("VAPID sub: {sub}");
124 };
125 app_state.metrics.incr(MetricName::NotificationAuthOk)?;
127 };
128
129 match token_info.api_version {
130 ApiVersion::Version1 => version_1_validation(&token)?,
131 ApiVersion::Version2 => version_2_validation(&token, vapid.as_ref())?,
132 }
133
134 let uaid = Uuid::from_slice(&token[..16]).unwrap();
138 let channel_id = Uuid::from_slice(&token[16..32]).unwrap();
139
140 trace!("UAID: {:?}, CHID: {:?}", uaid, channel_id);
141
142 let user = app_state
143 .db
144 .get_user(&uaid)
145 .await?
146 .ok_or(ApiErrorKind::NoSubscription)?;
147
148 trace!("user: {:?}", &user);
149 validate_user(&user, &channel_id, &app_state).await?;
150
151 Ok(Subscription {
152 user,
153 channel_id,
154 vapid,
155 reliability_id,
156 })
157 }
158 .boxed_local()
159 }
160}
161
162fn repad_base64(data: &str) -> Cow<'_, str> {
164 let trailing_chars = data.len() % 4;
165
166 if trailing_chars != 0 {
167 let mut data = data.to_string();
168
169 for _ in trailing_chars..4 {
170 data.push('=');
171 }
172
173 Cow::Owned(data)
174 } else {
175 Cow::Borrowed(data)
176 }
177}
178
179fn parse_vapid(token_info: &TokenInfo, metrics: &StatsdClient) -> ApiResult<Option<VapidHeader>> {
181 let auth_header = match token_info.auth_header.as_ref() {
182 Some(header) => header,
183 None => return Ok(None),
184 };
185
186 let vapid = VapidHeader::parse(auth_header).inspect_err(|e| {
187 metrics
188 .incr_with_tags(MetricName::NotificationAuthError)
189 .with_tag("error", e.as_metric())
190 .send();
191 })?;
192
193 metrics
194 .incr_with_tags(MetricName::NotificationAuth)
195 .with_tag("vapid", &vapid.version().to_string())
196 .with_tag("scheme", &vapid.scheme)
197 .send();
198
199 Ok(Some(vapid))
200}
201
202fn extract_public_key(vapid: VapidHeader, token_info: &TokenInfo) -> ApiResult<VapidHeaderWithKey> {
204 Ok(match vapid.version_data.clone() {
205 VapidVersionData::Version1 => {
206 let header = token_info.crypto_key_header.as_deref().ok_or_else(|| {
208 ApiErrorKind::InvalidEncryption("Missing Crypto-Key header".to_string())
209 })?;
210 let header_data = CryptoKeyHeader::parse(header).ok_or_else(|| {
211 ApiErrorKind::InvalidEncryption("Invalid Crypto-Key header".to_string())
212 })?;
213 let public_key = header_data.get_by_key("p256ecdsa").ok_or_else(|| {
214 ApiErrorKind::InvalidEncryption(
215 "Missing p256ecdsa in Crypto-Key header".to_string(),
216 )
217 })?;
218
219 VapidHeaderWithKey {
220 vapid,
221 public_key: public_key.to_string(),
222 }
223 }
224 VapidVersionData::Version2 { public_key } => VapidHeaderWithKey { vapid, public_key },
225 })
226}
227
228fn version_1_validation(token: &[u8]) -> ApiResult<()> {
230 if token.len() != 32 {
231 return Err(ApiErrorKind::InvalidToken.into());
233 }
234
235 Ok(())
236}
237
238fn decode_public_key(public_key: &str) -> ApiResult<Vec<u8>> {
244 if public_key.contains(['/', '+']) {
245 b64_decode_std(public_key.trim_end_matches('='))
246 } else {
247 b64_decode_url(public_key.trim_end_matches('='))
248 }
249 .map_err(|e| {
250 error!("decode_public_key: {:?}", e);
251 VapidError::InvalidKey(e.to_string()).into()
252 })
253}
254
255fn version_2_validation(token: &[u8], vapid: Option<&VapidHeaderWithKey>) -> ApiResult<()> {
257 if token.len() != 64 {
258 return Err(ApiErrorKind::InvalidToken.into());
260 }
261
262 let token_key = &token[32..];
265 let public_key = &vapid.ok_or(VapidError::MissingKey)?.public_key;
266
267 let public_key = decode_public_key(public_key)?;
269 let key_hash = openssl::hash::hash(MessageDigest::sha256(), &public_key)
270 .map_err(ApiErrorKind::TokenHashValidation)?;
271
272 if !openssl::memcmp::eq(&key_hash, token_key) {
274 return Err(VapidError::KeyMismatch.into());
275 }
276
277 Ok(())
278}
279
280fn term_to_label(term: &str) -> String {
282 term.split(' ').fold("".to_owned(), |prev, word: &str| {
283 format!(
284 "{}{}{}",
285 prev,
286 word.get(0..1).unwrap_or_default().to_ascii_uppercase(),
287 word.get(1..).unwrap_or_default()
288 )
289 })
290}
291
292fn validate_vapid_jwt(
299 vapid: &VapidHeaderWithKey,
300 settings: &Settings,
301 metrics: &StatsdClient,
302) -> ApiResult<VapidClaims> {
303 let VapidHeaderWithKey { vapid, public_key } = vapid;
304
305 let public_key = decode_public_key(public_key)?;
306 let mut validation = Validation::new(Algorithm::ES256);
307 validation.set_audience(&[settings.endpoint_url().origin().ascii_serialization()]);
310 validation.set_required_spec_claims(&["exp", "aud"]);
311
312 let token_data = match jsonwebtoken::decode::<VapidClaims>(
313 &vapid.token,
314 &DecodingKey::from_ec_der(&public_key),
315 &validation,
316 ) {
317 Ok(v) => v,
318 Err(e) => match e.kind() {
319 jsonwebtoken::errors::ErrorKind::Json(e) => {
321 metrics
322 .incr_with_tags(MetricName::NotificationAuthBadVapidJson)
323 .with_tag(
324 "error",
325 match e.classify() {
326 serde_json::error::Category::Io => "IO_ERROR",
327 serde_json::error::Category::Syntax => "SYNTAX_ERROR",
328 serde_json::error::Category::Data => "DATA_ERROR",
329 serde_json::error::Category::Eof => "EOF_ERROR",
330 },
331 )
332 .send();
333 if e.is_data() {
334 debug!("VAPID data warning: {:?}", e);
335 return Err(VapidError::InvalidVapid(
336 "A value in the vapid claims is either missing or incorrectly specified (e.g. \"exp\":\"12345\" or \"sub\":null). Please correct and retry.".to_owned(),
337 )
338 .into());
339 }
340 return Err(VapidError::InvalidVapid(e.to_string()).into());
343 }
344 jsonwebtoken::errors::ErrorKind::InvalidAudience => {
345 return Err(VapidError::InvalidAudience.into());
346 }
347 jsonwebtoken::errors::ErrorKind::MissingRequiredClaim(e) => {
348 return Err(VapidError::InvalidVapid(format!("Missing required {e}")).into());
349 }
350 _ => {
351 let label = if e.source().is_none() {
355 let mut label_name = e.to_string();
358 if label_name.contains(':') {
359 label_name =
362 term_to_label(label_name.split(':').next().unwrap_or_default());
363 } else if label_name.contains(' ') {
364 label_name = term_to_label(&label_name);
366 }
367 label_name
368 } else {
369 "Other".to_owned()
371 };
372 metrics
373 .incr_with_tags(MetricName::NotificationAuthBadVapidOther)
374 .with_tag("error", &label)
375 .send();
376 error!("Bad Aud: Unexpected VAPID error: {:?}", &e);
377 return Err(e.into());
378 }
379 },
380 };
381
382 #[cfg(feature = "log_vapid")]
385 if let Some(claims_str) = vapid.token.split('.').next() {
386 use base64::Engine;
387 info!(
388 "Vapid";
389 "sub" => &token_data.claims.sub,
390 "claims" => String::from_utf8(
391 base64::engine::general_purpose::URL_SAFE_NO_PAD
392 .decode(claims_str)
393 .unwrap_or_default()
394 )
395 .unwrap_or("UNKNOWN".to_owned())
396 );
397 };
398
399 if token_data.claims.exp > VapidClaims::default_exp() {
400 return Err(VapidError::FutureExpirationToken.into());
402 }
403
404 Ok(token_data.claims)
405}
406
407#[cfg(test)]
408pub mod tests {
409 use super::{term_to_label, validate_vapid_jwt, VapidClaims};
410 use crate::error::ApiErrorKind;
411 use crate::extractors::subscription::repad_base64;
412 use crate::headers::vapid::{VapidError, VapidHeader, VapidHeaderWithKey, VapidVersionData};
413 use crate::metrics::Metrics;
414 use crate::settings::Settings;
415
416 use autopush_common::util::b64_decode_std;
417 use lazy_static::lazy_static;
418 use serde::{Deserialize, Serialize};
419
420 pub const PUB_KEY: &str =
421 "BM3bVjW_wuZC54alIbqjTbaBNtthriVtdZlchOyOSdbVYeYQu2i5inJdft7jUWIAy4O9xHBbY196Gf-1odb8hds";
422
423 lazy_static! {
424 static ref PRIV_KEY: Vec<u8> = b64_decode_std(
425 "MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgZImOgpRszunnU3j1\
426 oX5UQiX8KU4X2OdbENuvc/t8wpmhRANCAATN21Y1v8LmQueGpSG6o022gTbbYa4l\
427 bXWZXITsjknW1WHmELtouYpyXX7e41FiAMuDvcRwW2Nfehn/taHW/IXb",
428 )
429 .unwrap();
430 }
431
432 pub fn make_vapid(sub: &str, aud: &str, exp: u64, key: String) -> VapidHeaderWithKey {
436 let jwk_header = jsonwebtoken::Header::new(jsonwebtoken::Algorithm::ES256);
437 let enc_key = jsonwebtoken::EncodingKey::from_ec_der(&PRIV_KEY);
438 let claims = VapidClaims {
439 exp,
440 aud: Some(aud.to_string()),
441 sub: Some(sub.to_string()),
442 };
443 let token = jsonwebtoken::encode(&jwk_header, &claims, &enc_key).unwrap();
444
445 VapidHeaderWithKey {
446 public_key: key.to_owned(),
447 vapid: VapidHeader {
448 scheme: "vapid".to_string(),
449 token,
450 version_data: VapidVersionData::Version1,
451 },
452 }
453 }
454
455 #[test]
456 fn repad_base64_1_padding() {
457 assert_eq!(repad_base64("Zm9vYmE"), "Zm9vYmE=")
458 }
459
460 #[test]
461 fn repad_base64_2_padding() {
462 assert_eq!(repad_base64("Zm9vYg"), "Zm9vYg==")
463 }
464
465 #[test]
466 fn vapid_aud_valid() {
467 let public_key = "BM3bVjW_wuZC54alIbqjTbaBNtthriVtdZlchOyOSdbVYeYQu2i5inJdft7jUWIAy4O9xHBbY196Gf-1odb8hds==".to_owned();
469 let domain = "https://push.services.mozilla.org";
470 let test_settings = Settings {
471 endpoint_url: domain.to_owned(),
472 ..Default::default()
473 };
474
475 let header = make_vapid(
476 "mailto:admin@example.com",
477 domain,
478 VapidClaims::default_exp() - 100,
479 public_key,
480 );
481 let result = validate_vapid_jwt(&header, &test_settings, &Metrics::sink());
482 assert!(result.is_ok());
483 }
484
485 #[test]
486 fn vapid_aud_invalid() {
487 let domain = "https://push.services.mozilla.org";
488 let test_settings = Settings {
489 endpoint_url: domain.to_owned(),
490 ..Default::default()
491 };
492 let header = make_vapid(
493 "mailto:admin@example.com",
494 "https://example.com",
495 VapidClaims::default_exp() - 100,
496 PUB_KEY.to_owned(),
497 );
498 assert!(matches!(
499 validate_vapid_jwt(&header, &test_settings, &Metrics::sink())
500 .unwrap_err()
501 .kind,
502 ApiErrorKind::VapidError(VapidError::InvalidAudience)
503 ));
504 }
505
506 #[test]
507 fn vapid_aud_valid_for_alternate_host() {
508 let domain = "https://example.org";
509 let test_settings = Settings {
510 endpoint_url: domain.to_owned(),
511 ..Default::default()
512 };
513 let header = make_vapid(
514 "mailto:admin@example.com",
515 domain,
516 VapidClaims::default_exp() - 100,
517 PUB_KEY.to_owned(),
518 );
519 let result = validate_vapid_jwt(&header, &test_settings, &Metrics::sink());
520 assert!(result.is_ok());
521 }
522
523 #[test]
524 fn vapid_exp_is_string() {
525 #[derive(Debug, Deserialize, Serialize)]
526 struct StrExpVapidClaims {
527 exp: String,
528 aud: String,
529 sub: String,
530 }
531
532 let domain = "https://push.services.mozilla.org";
533 let test_settings = Settings {
534 endpoint_url: domain.to_owned(),
535 ..Default::default()
536 };
537 let jwk_header = jsonwebtoken::Header::new(jsonwebtoken::Algorithm::ES256);
538 let enc_key = jsonwebtoken::EncodingKey::from_ec_der(&PRIV_KEY);
539 let claims = StrExpVapidClaims {
540 exp: (VapidClaims::default_exp() - 100).to_string(),
541 aud: domain.to_owned(),
542 sub: "mailto:admin@example.com".to_owned(),
543 };
544 let token = jsonwebtoken::encode(&jwk_header, &claims, &enc_key).unwrap();
545 let header = VapidHeaderWithKey {
546 public_key: PUB_KEY.to_owned(),
547 vapid: VapidHeader {
548 scheme: "vapid".to_string(),
549 token,
550 version_data: VapidVersionData::Version1,
551 },
552 };
553 let vv = validate_vapid_jwt(&header, &test_settings, &Metrics::sink())
554 .unwrap_err()
555 .kind;
556 assert!(matches![
557 vv,
558 ApiErrorKind::VapidError(VapidError::InvalidVapid(_))
559 ])
560 }
561
562 #[test]
563 fn vapid_public_key_variants() {
564 let public_key_standard = "BM3bVjW/wuZC54alIbqjTbaBNtthriVtdZlchOyOSdbVYeYQu2i5inJdft7jUWIAy4O9xHBbY196Gf+1odb8hds=".to_owned();
566 let public_key_url_safe = "BM3bVjW_wuZC54alIbqjTbaBNtthriVtdZlchOyOSdbVYeYQu2i5inJdft7jUWIAy4O9xHBbY196Gf-1odb8hds=".to_owned();
567 let domain = "https://push.services.mozilla.org";
568 let test_settings = Settings {
569 endpoint_url: domain.to_owned(),
570 ..Default::default()
571 };
572 let jwk_header = jsonwebtoken::Header::new(jsonwebtoken::Algorithm::ES256);
573 let enc_key = jsonwebtoken::EncodingKey::from_ec_der(&PRIV_KEY);
574 let claims = VapidClaims {
575 exp: VapidClaims::default_exp() - 100,
576 aud: Some(domain.to_owned()),
577 sub: Some("mailto:admin@example.com".to_owned()),
578 };
579 let token = jsonwebtoken::encode(&jwk_header, &claims, &enc_key).unwrap();
580 let header = VapidHeaderWithKey {
582 public_key: public_key_standard.clone(),
583 vapid: VapidHeader {
584 scheme: "vapid".to_string(),
585 token: token.clone(),
586 version_data: VapidVersionData::Version1,
587 },
588 };
589 assert!(validate_vapid_jwt(&header, &test_settings, &Metrics::sink()).is_ok());
590 let header = VapidHeaderWithKey {
592 public_key: public_key_standard.trim_end_matches('=').to_owned(),
593 vapid: VapidHeader {
594 scheme: "vapid".to_string(),
595 token: token.clone(),
596 version_data: VapidVersionData::Version1,
597 },
598 };
599 assert!(validate_vapid_jwt(&header, &test_settings, &Metrics::sink()).is_ok());
600 let header = VapidHeaderWithKey {
602 public_key: public_key_url_safe.clone(),
603 vapid: VapidHeader {
604 scheme: "vapid".to_string(),
605 token: token.clone(),
606 version_data: VapidVersionData::Version1,
607 },
608 };
609 assert!(validate_vapid_jwt(&header, &test_settings, &Metrics::sink()).is_ok());
610 let header = VapidHeaderWithKey {
612 public_key: public_key_url_safe.trim_end_matches('=').to_owned(),
613 vapid: VapidHeader {
614 scheme: "vapid".to_string(),
615 token,
616 version_data: VapidVersionData::Version1,
617 },
618 };
619 assert!(validate_vapid_jwt(&header, &test_settings, &Metrics::sink()).is_ok());
620 }
621
622 #[test]
623 fn vapid_missing_sub() {
624 #[derive(Debug, Deserialize, Serialize)]
625 struct NoSubVapidClaims {
626 exp: u64,
627 aud: String,
628 sub: Option<String>,
629 }
630
631 let domain = "https://push.services.mozilla.org";
632 let test_settings = Settings {
633 endpoint_url: domain.to_owned(),
634 ..Default::default()
635 };
636 let jwk_header = jsonwebtoken::Header::new(jsonwebtoken::Algorithm::ES256);
637 let enc_key = jsonwebtoken::EncodingKey::from_ec_der(&PRIV_KEY);
638 let claims = NoSubVapidClaims {
639 exp: VapidClaims::default_exp() - 100,
640 aud: domain.to_owned(),
641 sub: None,
642 };
643 let token = jsonwebtoken::encode(&jwk_header, &claims, &enc_key).unwrap();
644 let header = VapidHeaderWithKey {
645 public_key: PUB_KEY.to_owned(),
646 vapid: VapidHeader {
647 scheme: "vapid".to_string(),
648 token,
649 version_data: VapidVersionData::Version1,
650 },
651 };
652 assert!(validate_vapid_jwt(&header, &test_settings, &Metrics::sink()).is_ok());
653 }
654
655 #[test]
656 fn test_crapitalize() {
657 assert_eq!(
658 "LabelFieldWithoutData",
659 term_to_label("LabelField without data")
660 );
661 assert_eq!("UntouchedField", term_to_label("UntouchedField"));
662 }
663}