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 if let Some(header) = &vapid {
102 if let Some(sub) = header
103 .vapid
104 .insecure_sub()
105 .inspect_err(|e: &VapidError| {
106 let mut tags = Tags::default();
108 tags.tags
109 .insert("error".to_owned(), e.as_metric().to_owned());
110 app_state
111 .metrics
112 .incr_with_tags(MetricName::NotificationAuthError)
113 .with_tag("error", e.as_metric())
114 .send();
115 })
116 .unwrap_or_default()
117 {
118 info!("VAPID sub: {sub}");
119 };
120 app_state.metrics.incr(MetricName::NotificationAuthOk)?;
122 };
123
124 match token_info.api_version {
125 ApiVersion::Version1 => version_1_validation(&token)?,
126 ApiVersion::Version2 => version_2_validation(&token, vapid.as_ref())?,
127 }
128
129 let uaid = Uuid::from_slice(&token[..16]).unwrap();
133 let channel_id = Uuid::from_slice(&token[16..32]).unwrap();
134
135 trace!("UAID: {:?}, CHID: {:?}", uaid, channel_id);
136
137 let user = app_state
138 .db
139 .get_user(&uaid)
140 .await?
141 .ok_or(ApiErrorKind::NoSubscription)?;
142
143 trace!("user: {:?}", &user);
144 validate_user(&user, &channel_id, &app_state).await?;
145
146 if let Some(vapid) = &vapid {
148 validate_vapid_jwt(vapid, &app_state.settings, &app_state.metrics)?;
149
150 app_state
151 .metrics
152 .incr_raw(&format!("updates.vapid.draft{:02}", vapid.vapid.version()))?;
153 }
154
155 Ok(Subscription {
156 user,
157 channel_id,
158 vapid,
159 reliability_id,
160 })
161 }
162 .boxed_local()
163 }
164}
165
166fn repad_base64(data: &str) -> Cow<'_, str> {
168 let trailing_chars = data.len() % 4;
169
170 if trailing_chars != 0 {
171 let mut data = data.to_string();
172
173 for _ in trailing_chars..4 {
174 data.push('=');
175 }
176
177 Cow::Owned(data)
178 } else {
179 Cow::Borrowed(data)
180 }
181}
182
183fn parse_vapid(token_info: &TokenInfo, metrics: &StatsdClient) -> ApiResult<Option<VapidHeader>> {
185 let auth_header = match token_info.auth_header.as_ref() {
186 Some(header) => header,
187 None => return Ok(None),
188 };
189
190 let vapid = VapidHeader::parse(auth_header).inspect_err(|e| {
191 metrics
192 .incr_with_tags(MetricName::NotificationAuthError)
193 .with_tag("error", e.as_metric())
194 .send();
195 })?;
196
197 metrics
198 .incr_with_tags(MetricName::NotificationAuth)
199 .with_tag("vapid", &vapid.version().to_string())
200 .with_tag("scheme", &vapid.scheme)
201 .send();
202
203 Ok(Some(vapid))
204}
205
206fn extract_public_key(vapid: VapidHeader, token_info: &TokenInfo) -> ApiResult<VapidHeaderWithKey> {
208 Ok(match vapid.version_data.clone() {
209 VapidVersionData::Version1 => {
210 let header = token_info.crypto_key_header.as_deref().ok_or_else(|| {
212 ApiErrorKind::InvalidEncryption("Missing Crypto-Key header".to_string())
213 })?;
214 let header_data = CryptoKeyHeader::parse(header).ok_or_else(|| {
215 ApiErrorKind::InvalidEncryption("Invalid Crypto-Key header".to_string())
216 })?;
217 let public_key = header_data.get_by_key("p256ecdsa").ok_or_else(|| {
218 ApiErrorKind::InvalidEncryption(
219 "Missing p256ecdsa in Crypto-Key header".to_string(),
220 )
221 })?;
222
223 VapidHeaderWithKey {
224 vapid,
225 public_key: public_key.to_string(),
226 }
227 }
228 VapidVersionData::Version2 { public_key } => VapidHeaderWithKey { vapid, public_key },
229 })
230}
231
232fn version_1_validation(token: &[u8]) -> ApiResult<()> {
234 if token.len() != 32 {
235 return Err(ApiErrorKind::InvalidToken.into());
237 }
238
239 Ok(())
240}
241
242fn decode_public_key(public_key: &str) -> ApiResult<Vec<u8>> {
248 if public_key.contains(['/', '+']) {
249 b64_decode_std(public_key.trim_end_matches('='))
250 } else {
251 b64_decode_url(public_key.trim_end_matches('='))
252 }
253 .map_err(|e| {
254 error!("decode_public_key: {:?}", e);
255 VapidError::InvalidKey(e.to_string()).into()
256 })
257}
258
259fn version_2_validation(token: &[u8], vapid: Option<&VapidHeaderWithKey>) -> ApiResult<()> {
261 if token.len() != 64 {
262 return Err(ApiErrorKind::InvalidToken.into());
264 }
265
266 let token_key = &token[32..];
269 let public_key = &vapid.ok_or(VapidError::MissingKey)?.public_key;
270
271 let public_key = decode_public_key(public_key)?;
273 let key_hash = openssl::hash::hash(MessageDigest::sha256(), &public_key)
274 .map_err(ApiErrorKind::TokenHashValidation)?;
275
276 if !openssl::memcmp::eq(&key_hash, token_key) {
278 return Err(VapidError::KeyMismatch.into());
279 }
280
281 Ok(())
282}
283
284fn term_to_label(term: &str) -> String {
286 term.split(' ').fold("".to_owned(), |prev, word: &str| {
287 format!(
288 "{}{}{}",
289 prev,
290 word.get(0..1).unwrap_or_default().to_ascii_uppercase(),
291 word.get(1..).unwrap_or_default()
292 )
293 })
294}
295
296fn validate_vapid_jwt(
303 vapid: &VapidHeaderWithKey,
304 settings: &Settings,
305 metrics: &StatsdClient,
306) -> ApiResult<VapidClaims> {
307 let VapidHeaderWithKey { vapid, public_key } = vapid;
308
309 let public_key = decode_public_key(public_key)?;
310 let mut validation = Validation::new(Algorithm::ES256);
311 validation.set_audience(&[settings.endpoint_url().origin().ascii_serialization()]);
314 validation.set_required_spec_claims(&["exp", "aud"]);
315
316 let token_data = match jsonwebtoken::decode::<VapidClaims>(
317 &vapid.token,
318 &DecodingKey::from_ec_der(&public_key),
319 &validation,
320 ) {
321 Ok(v) => v,
322 Err(e) => match e.kind() {
323 jsonwebtoken::errors::ErrorKind::Json(e) => {
325 metrics
326 .incr_with_tags(MetricName::NotificationAuthBadVapidJson)
327 .with_tag(
328 "error",
329 match e.classify() {
330 serde_json::error::Category::Io => "IO_ERROR",
331 serde_json::error::Category::Syntax => "SYNTAX_ERROR",
332 serde_json::error::Category::Data => "DATA_ERROR",
333 serde_json::error::Category::Eof => "EOF_ERROR",
334 },
335 )
336 .send();
337 if e.is_data() {
338 debug!("VAPID data warning: {:?}", e);
339 return Err(VapidError::InvalidVapid(
340 "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(),
341 )
342 .into());
343 }
344 return Err(VapidError::InvalidVapid(e.to_string()).into());
347 }
348 jsonwebtoken::errors::ErrorKind::InvalidAudience => {
349 return Err(VapidError::InvalidAudience.into());
350 }
351 jsonwebtoken::errors::ErrorKind::MissingRequiredClaim(e) => {
352 return Err(VapidError::InvalidVapid(format!("Missing required {}", e)).into());
353 }
354 _ => {
355 let label = if e.source().is_none() {
359 let mut label_name = e.to_string();
362 if label_name.contains(':') {
363 label_name =
366 term_to_label(label_name.split(':').next().unwrap_or_default());
367 } else if label_name.contains(' ') {
368 label_name = term_to_label(&label_name);
370 }
371 label_name
372 } else {
373 "Other".to_owned()
375 };
376 metrics
377 .incr_with_tags(MetricName::NotificationAuthBadVapidOther)
378 .with_tag("error", &label)
379 .send();
380 error!("Bad Aud: Unexpected VAPID error: {:?}", &e);
381 return Err(e.into());
382 }
383 },
384 };
385
386 #[cfg(feature = "log_vapid")]
389 if let Some(claims_str) = vapid.token.split('.').next() {
390 use base64::Engine;
391 info!(
392 "Vapid";
393 "sub" => &token_data.claims.sub,
394 "claims" => String::from_utf8(
395 base64::engine::general_purpose::URL_SAFE_NO_PAD
396 .decode(claims_str)
397 .unwrap_or_default()
398 )
399 .unwrap_or("UNKNOWN".to_owned())
400 );
401 };
402
403 if token_data.claims.exp > VapidClaims::default_exp() {
404 return Err(VapidError::FutureExpirationToken.into());
406 }
407
408 Ok(token_data.claims)
409}
410
411#[cfg(test)]
412pub mod tests {
413 use super::{term_to_label, validate_vapid_jwt, VapidClaims};
414 use crate::error::ApiErrorKind;
415 use crate::extractors::subscription::repad_base64;
416 use crate::headers::vapid::{VapidError, VapidHeader, VapidHeaderWithKey, VapidVersionData};
417 use crate::metrics::Metrics;
418 use crate::settings::Settings;
419
420 use autopush_common::util::b64_decode_std;
421 use lazy_static::lazy_static;
422 use serde::{Deserialize, Serialize};
423
424 pub const PUB_KEY: &str =
425 "BM3bVjW_wuZC54alIbqjTbaBNtthriVtdZlchOyOSdbVYeYQu2i5inJdft7jUWIAy4O9xHBbY196Gf-1odb8hds";
426
427 lazy_static! {
428 static ref PRIV_KEY: Vec<u8> = b64_decode_std(
429 "MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgZImOgpRszunnU3j1\
430 oX5UQiX8KU4X2OdbENuvc/t8wpmhRANCAATN21Y1v8LmQueGpSG6o022gTbbYa4l\
431 bXWZXITsjknW1WHmELtouYpyXX7e41FiAMuDvcRwW2Nfehn/taHW/IXb",
432 )
433 .unwrap();
434 }
435
436 pub fn make_vapid(sub: &str, aud: &str, exp: u64, key: String) -> VapidHeaderWithKey {
440 let jwk_header = jsonwebtoken::Header::new(jsonwebtoken::Algorithm::ES256);
441 let enc_key = jsonwebtoken::EncodingKey::from_ec_der(&PRIV_KEY);
442 let claims = VapidClaims {
443 exp,
444 aud: Some(aud.to_string()),
445 sub: Some(sub.to_string()),
446 };
447 let token = jsonwebtoken::encode(&jwk_header, &claims, &enc_key).unwrap();
448
449 VapidHeaderWithKey {
450 public_key: key.to_owned(),
451 vapid: VapidHeader {
452 scheme: "vapid".to_string(),
453 token,
454 version_data: VapidVersionData::Version1,
455 },
456 }
457 }
458
459 #[test]
460 fn repad_base64_1_padding() {
461 assert_eq!(repad_base64("Zm9vYmE"), "Zm9vYmE=")
462 }
463
464 #[test]
465 fn repad_base64_2_padding() {
466 assert_eq!(repad_base64("Zm9vYg"), "Zm9vYg==")
467 }
468
469 #[test]
470 fn vapid_aud_valid() {
471 let public_key = "BM3bVjW_wuZC54alIbqjTbaBNtthriVtdZlchOyOSdbVYeYQu2i5inJdft7jUWIAy4O9xHBbY196Gf-1odb8hds==".to_owned();
473 let domain = "https://push.services.mozilla.org";
474 let test_settings = Settings {
475 endpoint_url: domain.to_owned(),
476 ..Default::default()
477 };
478
479 let header = make_vapid(
480 "mailto:admin@example.com",
481 domain,
482 VapidClaims::default_exp() - 100,
483 public_key,
484 );
485 let result = validate_vapid_jwt(&header, &test_settings, &Metrics::sink());
486 assert!(result.is_ok());
487 }
488
489 #[test]
490 fn vapid_aud_invalid() {
491 let domain = "https://push.services.mozilla.org";
492 let test_settings = Settings {
493 endpoint_url: domain.to_owned(),
494 ..Default::default()
495 };
496 let header = make_vapid(
497 "mailto:admin@example.com",
498 "https://example.com",
499 VapidClaims::default_exp() - 100,
500 PUB_KEY.to_owned(),
501 );
502 assert!(matches!(
503 validate_vapid_jwt(&header, &test_settings, &Metrics::sink())
504 .unwrap_err()
505 .kind,
506 ApiErrorKind::VapidError(VapidError::InvalidAudience)
507 ));
508 }
509
510 #[test]
511 fn vapid_aud_valid_for_alternate_host() {
512 let domain = "https://example.org";
513 let test_settings = Settings {
514 endpoint_url: domain.to_owned(),
515 ..Default::default()
516 };
517 let header = make_vapid(
518 "mailto:admin@example.com",
519 domain,
520 VapidClaims::default_exp() - 100,
521 PUB_KEY.to_owned(),
522 );
523 let result = validate_vapid_jwt(&header, &test_settings, &Metrics::sink());
524 assert!(result.is_ok());
525 }
526
527 #[test]
528 fn vapid_exp_is_string() {
529 #[derive(Debug, Deserialize, Serialize)]
530 struct StrExpVapidClaims {
531 exp: String,
532 aud: String,
533 sub: String,
534 }
535
536 let domain = "https://push.services.mozilla.org";
537 let test_settings = Settings {
538 endpoint_url: domain.to_owned(),
539 ..Default::default()
540 };
541 let jwk_header = jsonwebtoken::Header::new(jsonwebtoken::Algorithm::ES256);
542 let enc_key = jsonwebtoken::EncodingKey::from_ec_der(&PRIV_KEY);
543 let claims = StrExpVapidClaims {
544 exp: (VapidClaims::default_exp() - 100).to_string(),
545 aud: domain.to_owned(),
546 sub: "mailto:admin@example.com".to_owned(),
547 };
548 let token = jsonwebtoken::encode(&jwk_header, &claims, &enc_key).unwrap();
549 let header = VapidHeaderWithKey {
550 public_key: PUB_KEY.to_owned(),
551 vapid: VapidHeader {
552 scheme: "vapid".to_string(),
553 token,
554 version_data: VapidVersionData::Version1,
555 },
556 };
557 let vv = validate_vapid_jwt(&header, &test_settings, &Metrics::sink())
558 .unwrap_err()
559 .kind;
560 assert!(matches![
561 vv,
562 ApiErrorKind::VapidError(VapidError::InvalidVapid(_))
563 ])
564 }
565
566 #[test]
567 fn vapid_public_key_variants() {
568 #[derive(Debug, Deserialize, Serialize)]
569 struct StrExpVapidClaims {
570 exp: String,
571 aud: String,
572 sub: String,
573 }
574
575 let public_key_standard = "BM3bVjW/wuZC54alIbqjTbaBNtthriVtdZlchOyOSdbVYeYQu2i5inJdft7jUWIAy4O9xHBbY196Gf+1odb8hds=".to_owned();
577 let public_key_url_safe = "BM3bVjW_wuZC54alIbqjTbaBNtthriVtdZlchOyOSdbVYeYQu2i5inJdft7jUWIAy4O9xHBbY196Gf-1odb8hds=".to_owned();
578 let domain = "https://push.services.mozilla.org";
579 let test_settings = Settings {
580 endpoint_url: domain.to_owned(),
581 ..Default::default()
582 };
583 let jwk_header = jsonwebtoken::Header::new(jsonwebtoken::Algorithm::ES256);
584 let enc_key = jsonwebtoken::EncodingKey::from_ec_der(&PRIV_KEY);
585 let claims = VapidClaims {
586 exp: VapidClaims::default_exp() - 100,
587 aud: Some(domain.to_owned()),
588 sub: Some("mailto:admin@example.com".to_owned()),
589 };
590 let token = jsonwebtoken::encode(&jwk_header, &claims, &enc_key).unwrap();
591 let header = VapidHeaderWithKey {
593 public_key: public_key_standard.clone(),
594 vapid: VapidHeader {
595 scheme: "vapid".to_string(),
596 token: token.clone(),
597 version_data: VapidVersionData::Version1,
598 },
599 };
600 assert!(validate_vapid_jwt(&header, &test_settings, &Metrics::sink()).is_ok());
601 let header = VapidHeaderWithKey {
603 public_key: public_key_standard.trim_end_matches('=').to_owned(),
604 vapid: VapidHeader {
605 scheme: "vapid".to_string(),
606 token: token.clone(),
607 version_data: VapidVersionData::Version1,
608 },
609 };
610 assert!(validate_vapid_jwt(&header, &test_settings, &Metrics::sink()).is_ok());
611 let header = VapidHeaderWithKey {
613 public_key: public_key_url_safe.clone(),
614 vapid: VapidHeader {
615 scheme: "vapid".to_string(),
616 token: token.clone(),
617 version_data: VapidVersionData::Version1,
618 },
619 };
620 assert!(validate_vapid_jwt(&header, &test_settings, &Metrics::sink()).is_ok());
621 let header = VapidHeaderWithKey {
623 public_key: public_key_url_safe.trim_end_matches('=').to_owned(),
624 vapid: VapidHeader {
625 scheme: "vapid".to_string(),
626 token,
627 version_data: VapidVersionData::Version1,
628 },
629 };
630 assert!(validate_vapid_jwt(&header, &test_settings, &Metrics::sink()).is_ok());
631 }
632
633 #[test]
634 fn vapid_missing_sub() {
635 #[derive(Debug, Deserialize, Serialize)]
636 struct NoSubVapidClaims {
637 exp: u64,
638 aud: String,
639 sub: Option<String>,
640 }
641
642 let domain = "https://push.services.mozilla.org";
643 let test_settings = Settings {
644 endpoint_url: domain.to_owned(),
645 ..Default::default()
646 };
647 let jwk_header = jsonwebtoken::Header::new(jsonwebtoken::Algorithm::ES256);
648 let enc_key = jsonwebtoken::EncodingKey::from_ec_der(&PRIV_KEY);
649 let claims = NoSubVapidClaims {
650 exp: VapidClaims::default_exp() - 100,
651 aud: domain.to_owned(),
652 sub: None,
653 };
654 let token = jsonwebtoken::encode(&jwk_header, &claims, &enc_key).unwrap();
655 let header = VapidHeaderWithKey {
656 public_key: PUB_KEY.to_owned(),
657 vapid: VapidHeader {
658 scheme: "vapid".to_string(),
659 token,
660 version_data: VapidVersionData::Version1,
661 },
662 };
663 assert!(validate_vapid_jwt(&header, &test_settings, &Metrics::sink()).is_ok());
664 }
665
666 #[test]
667 fn test_crapitalize() {
668 assert_eq!(
669 "LabelFieldWithoutData",
670 term_to_label("LabelField without data")
671 );
672 assert_eq!("UntouchedField", term_to_label("UntouchedField"));
673 }
674}