-
Notifications
You must be signed in to change notification settings - Fork 16
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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.
- Loading branch information
Showing
1 changed file
with
89 additions
and
10 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -190,6 +190,21 @@ fn version_1_validation(token: &[u8]) -> ApiResult<()> { | |
Ok(()) | ||
} | ||
|
||
/// Decode a public key string | ||
/// | ||
/// NOTE: Some customers send a VAPID public key with incorrect padding and | ||
/// in standard base64 encoding. (Both of these violate the VAPID RFC) | ||
/// Prior python versions ignored these errors, so we should too. | ||
fn decode_public_key(public_key: &str) -> ApiResult<Vec<u8>> { | ||
let encoding = if public_key.contains(['/', '+']) { | ||
base64::STANDARD_NO_PAD | ||
} else { | ||
base64::URL_SAFE_NO_PAD | ||
}; | ||
base64::decode_config(public_key.trim_end_matches('='), encoding) | ||
.map_err(|e| VapidError::InvalidKey(e.to_string()).into()) | ||
} | ||
|
||
/// `/webpush/v2/` validations | ||
fn version_2_validation(token: &[u8], vapid: Option<&VapidHeaderWithKey>) -> ApiResult<()> { | ||
if token.len() != 64 { | ||
|
@@ -203,8 +218,7 @@ fn version_2_validation(token: &[u8], vapid: Option<&VapidHeaderWithKey>) -> Api | |
let public_key = &vapid.ok_or(VapidError::MissingKey)?.public_key; | ||
|
||
// Hash the VAPID public key | ||
let public_key = base64::decode_config(public_key, base64::URL_SAFE_NO_PAD) | ||
.map_err(|e| VapidError::InvalidKey(e.to_string()))?; | ||
let public_key = decode_public_key(public_key)?; | ||
let key_hash = openssl::hash::hash(MessageDigest::sha256(), &public_key) | ||
.map_err(ApiErrorKind::TokenHashValidation)?; | ||
|
||
|
@@ -225,20 +239,15 @@ fn version_2_validation(token: &[u8], vapid: Option<&VapidHeaderWithKey>) -> Api | |
fn validate_vapid_jwt(vapid: &VapidHeaderWithKey, domain: &Url) -> ApiResult<()> { | ||
let VapidHeaderWithKey { vapid, public_key } = vapid; | ||
|
||
// Check the signature and make sure the expiration is in the future | ||
// NOTE: FxA sometimes sends a VAPID public key with incorrect padding. | ||
// Prior versions ignored padding errors, so we should too. | ||
let public_key = | ||
base64::decode_config(public_key.trim_end_matches('='), base64::URL_SAFE_NO_PAD) | ||
.map_err(|e| VapidError::InvalidKey(e.to_string()))?; | ||
// NOTE: This will fail if `exp` is specified as a string instead of a numeric. | ||
let public_key = decode_public_key(public_key)?; | ||
let token_data = match jsonwebtoken::decode::<VapidClaims>( | ||
&vapid.token, | ||
&DecodingKey::from_ec_der(&public_key), | ||
&Validation::new(Algorithm::ES256), | ||
) { | ||
Ok(v) => v, | ||
Err(e) => match e.kind() { | ||
// NOTE: This will fail if `exp` is specified as anything instead of a numeric or if a required field is empty | ||
jsonwebtoken::errors::ErrorKind::Json(e) => { | ||
if e.is_data() { | ||
return Err(VapidError::InvalidVapid( | ||
|
@@ -254,7 +263,7 @@ fn validate_vapid_jwt(vapid: &VapidHeaderWithKey, domain: &Url) -> ApiResult<()> | |
}, | ||
}; | ||
|
||
// Make sure the expiration isn't too far into the future | ||
// Check the signature and make sure the expiration is in the future, but not too far | ||
if token_data.claims.exp > (sec_since_epoch() + ONE_DAY_IN_SECONDS) { | ||
// The expiration is too far in the future | ||
return Err(VapidError::FutureExpirationToken.into()); | ||
|
@@ -408,6 +417,76 @@ mod tests { | |
]) | ||
} | ||
|
||
#[test] | ||
fn vapid_public_key_variants() { | ||
#[derive(Debug, Deserialize, Serialize)] | ||
struct StrExpVapidClaims { | ||
exp: String, | ||
aud: String, | ||
sub: String, | ||
} | ||
|
||
let priv_key = base64::decode_config( | ||
"MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgZImOgpRszunnU3j1\ | ||
oX5UQiX8KU4X2OdbENuvc/t8wpmhRANCAATN21Y1v8LmQueGpSG6o022gTbbYa4l\ | ||
bXWZXITsjknW1WHmELtouYpyXX7e41FiAMuDvcRwW2Nfehn/taHW/IXb", | ||
base64::STANDARD, | ||
) | ||
.unwrap(); | ||
// pretty much matches the kind of key we get from some partners. | ||
let public_key_standard = "BM3bVjW/wuZC54alIbqjTbaBNtthriVtdZlchOyOSdbVYeYQu2i5inJdft7jUWIAy4O9xHBbY196Gf+1odb8hds=".to_owned(); | ||
let public_key_url_safe = "BM3bVjW_wuZC54alIbqjTbaBNtthriVtdZlchOyOSdbVYeYQu2i5inJdft7jUWIAy4O9xHBbY196Gf-1odb8hds=".to_owned(); | ||
let domain = "https://push.services.mozilla.org"; | ||
let jwk_header = jsonwebtoken::Header::new(jsonwebtoken::Algorithm::ES256); | ||
let enc_key = jsonwebtoken::EncodingKey::from_ec_der(&priv_key); | ||
let claims = VapidClaims { | ||
exp: sec_since_epoch() + super::ONE_DAY_IN_SECONDS - 100, | ||
aud: domain.to_owned(), | ||
sub: "mailto:[email protected]".to_owned(), | ||
}; | ||
let token = jsonwebtoken::encode(&jwk_header, &claims, &enc_key).unwrap(); | ||
// try standard form with padding | ||
let header = VapidHeaderWithKey { | ||
public_key: public_key_standard.clone(), | ||
vapid: VapidHeader { | ||
scheme: "vapid".to_string(), | ||
token: token.clone(), | ||
version_data: VapidVersionData::Version1, | ||
}, | ||
}; | ||
assert!(validate_vapid_jwt(&header, &Url::from_str(domain).unwrap()).is_ok()); | ||
// try standard form with no padding | ||
let header = VapidHeaderWithKey { | ||
public_key: public_key_standard.trim_end_matches('=').to_owned(), | ||
vapid: VapidHeader { | ||
scheme: "vapid".to_string(), | ||
token: token.clone(), | ||
version_data: VapidVersionData::Version1, | ||
}, | ||
}; | ||
assert!(validate_vapid_jwt(&header, &Url::from_str(domain).unwrap()).is_ok()); | ||
// try URL safe form with padding | ||
let header = VapidHeaderWithKey { | ||
public_key: public_key_url_safe.clone(), | ||
vapid: VapidHeader { | ||
scheme: "vapid".to_string(), | ||
token: token.clone(), | ||
version_data: VapidVersionData::Version1, | ||
}, | ||
}; | ||
assert!(validate_vapid_jwt(&header, &Url::from_str(domain).unwrap()).is_ok()); | ||
// try URL safe form without padding | ||
let header = VapidHeaderWithKey { | ||
public_key: public_key_url_safe.trim_end_matches('=').to_owned(), | ||
vapid: VapidHeader { | ||
scheme: "vapid".to_string(), | ||
token, | ||
version_data: VapidVersionData::Version1, | ||
}, | ||
}; | ||
assert!(validate_vapid_jwt(&header, &Url::from_str(domain).unwrap()).is_ok()); | ||
} | ||
|
||
#[test] | ||
fn vapid_missing_sub() { | ||
#[derive(Debug, Deserialize, Serialize)] | ||
|