Skip to content

Commit 7ec9e54

Browse files
author
JR Conlin
authored
feat: allow for standard base64 private keys (#323)
feat: allow for standard base64 private keys FxA sends in standard encoded base64 private key fields. The old Python verison accepted them. We need to accept them as well.
1 parent 3b88878 commit 7ec9e54

1 file changed

Lines changed: 89 additions & 10 deletions

File tree

autoendpoint/src/extractors/subscription.rs

Lines changed: 89 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,21 @@ fn version_1_validation(token: &[u8]) -> ApiResult<()> {
190190
Ok(())
191191
}
192192

193+
/// Decode a public key string
194+
///
195+
/// NOTE: Some customers send a VAPID public key with incorrect padding and
196+
/// in standard base64 encoding. (Both of these violate the VAPID RFC)
197+
/// Prior python versions ignored these errors, so we should too.
198+
fn decode_public_key(public_key: &str) -> ApiResult<Vec<u8>> {
199+
let encoding = if public_key.contains(['/', '+']) {
200+
base64::STANDARD_NO_PAD
201+
} else {
202+
base64::URL_SAFE_NO_PAD
203+
};
204+
base64::decode_config(public_key.trim_end_matches('='), encoding)
205+
.map_err(|e| VapidError::InvalidKey(e.to_string()).into())
206+
}
207+
193208
/// `/webpush/v2/` validations
194209
fn version_2_validation(token: &[u8], vapid: Option<&VapidHeaderWithKey>) -> ApiResult<()> {
195210
if token.len() != 64 {
@@ -203,8 +218,7 @@ fn version_2_validation(token: &[u8], vapid: Option<&VapidHeaderWithKey>) -> Api
203218
let public_key = &vapid.ok_or(VapidError::MissingKey)?.public_key;
204219

205220
// Hash the VAPID public key
206-
let public_key = base64::decode_config(public_key, base64::URL_SAFE_NO_PAD)
207-
.map_err(|e| VapidError::InvalidKey(e.to_string()))?;
221+
let public_key = decode_public_key(public_key)?;
208222
let key_hash = openssl::hash::hash(MessageDigest::sha256(), &public_key)
209223
.map_err(ApiErrorKind::TokenHashValidation)?;
210224

@@ -225,20 +239,15 @@ fn version_2_validation(token: &[u8], vapid: Option<&VapidHeaderWithKey>) -> Api
225239
fn validate_vapid_jwt(vapid: &VapidHeaderWithKey, domain: &Url) -> ApiResult<()> {
226240
let VapidHeaderWithKey { vapid, public_key } = vapid;
227241

228-
// Check the signature and make sure the expiration is in the future
229-
// NOTE: FxA sometimes sends a VAPID public key with incorrect padding.
230-
// Prior versions ignored padding errors, so we should too.
231-
let public_key =
232-
base64::decode_config(public_key.trim_end_matches('='), base64::URL_SAFE_NO_PAD)
233-
.map_err(|e| VapidError::InvalidKey(e.to_string()))?;
234-
// NOTE: This will fail if `exp` is specified as a string instead of a numeric.
242+
let public_key = decode_public_key(public_key)?;
235243
let token_data = match jsonwebtoken::decode::<VapidClaims>(
236244
&vapid.token,
237245
&DecodingKey::from_ec_der(&public_key),
238246
&Validation::new(Algorithm::ES256),
239247
) {
240248
Ok(v) => v,
241249
Err(e) => match e.kind() {
250+
// NOTE: This will fail if `exp` is specified as anything instead of a numeric or if a required field is empty
242251
jsonwebtoken::errors::ErrorKind::Json(e) => {
243252
if e.is_data() {
244253
return Err(VapidError::InvalidVapid(
@@ -254,7 +263,7 @@ fn validate_vapid_jwt(vapid: &VapidHeaderWithKey, domain: &Url) -> ApiResult<()>
254263
},
255264
};
256265

257-
// Make sure the expiration isn't too far into the future
266+
// Check the signature and make sure the expiration is in the future, but not too far
258267
if token_data.claims.exp > (sec_since_epoch() + ONE_DAY_IN_SECONDS) {
259268
// The expiration is too far in the future
260269
return Err(VapidError::FutureExpirationToken.into());
@@ -408,6 +417,76 @@ mod tests {
408417
])
409418
}
410419

420+
#[test]
421+
fn vapid_public_key_variants() {
422+
#[derive(Debug, Deserialize, Serialize)]
423+
struct StrExpVapidClaims {
424+
exp: String,
425+
aud: String,
426+
sub: String,
427+
}
428+
429+
let priv_key = base64::decode_config(
430+
"MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgZImOgpRszunnU3j1\
431+
oX5UQiX8KU4X2OdbENuvc/t8wpmhRANCAATN21Y1v8LmQueGpSG6o022gTbbYa4l\
432+
bXWZXITsjknW1WHmELtouYpyXX7e41FiAMuDvcRwW2Nfehn/taHW/IXb",
433+
base64::STANDARD,
434+
)
435+
.unwrap();
436+
// pretty much matches the kind of key we get from some partners.
437+
let public_key_standard = "BM3bVjW/wuZC54alIbqjTbaBNtthriVtdZlchOyOSdbVYeYQu2i5inJdft7jUWIAy4O9xHBbY196Gf+1odb8hds=".to_owned();
438+
let public_key_url_safe = "BM3bVjW_wuZC54alIbqjTbaBNtthriVtdZlchOyOSdbVYeYQu2i5inJdft7jUWIAy4O9xHBbY196Gf-1odb8hds=".to_owned();
439+
let domain = "https://push.services.mozilla.org";
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: sec_since_epoch() + super::ONE_DAY_IN_SECONDS - 100,
444+
aud: domain.to_owned(),
445+
sub: "mailto:admin@example.com".to_owned(),
446+
};
447+
let token = jsonwebtoken::encode(&jwk_header, &claims, &enc_key).unwrap();
448+
// try standard form with padding
449+
let header = VapidHeaderWithKey {
450+
public_key: public_key_standard.clone(),
451+
vapid: VapidHeader {
452+
scheme: "vapid".to_string(),
453+
token: token.clone(),
454+
version_data: VapidVersionData::Version1,
455+
},
456+
};
457+
assert!(validate_vapid_jwt(&header, &Url::from_str(domain).unwrap()).is_ok());
458+
// try standard form with no padding
459+
let header = VapidHeaderWithKey {
460+
public_key: public_key_standard.trim_end_matches('=').to_owned(),
461+
vapid: VapidHeader {
462+
scheme: "vapid".to_string(),
463+
token: token.clone(),
464+
version_data: VapidVersionData::Version1,
465+
},
466+
};
467+
assert!(validate_vapid_jwt(&header, &Url::from_str(domain).unwrap()).is_ok());
468+
// try URL safe form with padding
469+
let header = VapidHeaderWithKey {
470+
public_key: public_key_url_safe.clone(),
471+
vapid: VapidHeader {
472+
scheme: "vapid".to_string(),
473+
token: token.clone(),
474+
version_data: VapidVersionData::Version1,
475+
},
476+
};
477+
assert!(validate_vapid_jwt(&header, &Url::from_str(domain).unwrap()).is_ok());
478+
// try URL safe form without padding
479+
let header = VapidHeaderWithKey {
480+
public_key: public_key_url_safe.trim_end_matches('=').to_owned(),
481+
vapid: VapidHeader {
482+
scheme: "vapid".to_string(),
483+
token,
484+
version_data: VapidVersionData::Version1,
485+
},
486+
};
487+
assert!(validate_vapid_jwt(&header, &Url::from_str(domain).unwrap()).is_ok());
488+
}
489+
411490
#[test]
412491
fn vapid_missing_sub() {
413492
#[derive(Debug, Deserialize, Serialize)]

0 commit comments

Comments
 (0)