Skip to content

Commit

Permalink
feat: Validate autoendpoint JWT tokens (#154)
Browse files Browse the repository at this point in the history
* Use an enum for API version and add VapidHeaderWithKey

* Perform expiration time validation on the JWT

* Perform JWT claims extraction

* Use the jsonwebtoken library for JWT validation

* Add back VapidError::FutureExpirationToken and associated check

The JWT library doesn't handle this error.

* Record another metric for vapid version

* Simplify handling of VAPID v2 in extract_public_key

Closes #103
  • Loading branch information
AzureMarker committed Jun 26, 2020
1 parent 8efa42c commit 04fee7f
Show file tree
Hide file tree
Showing 6 changed files with 179 additions and 38 deletions.
75 changes: 75 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions autoendpoint/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ docopt = "1.1.0"
fernet = "0.1.3"
futures = { version = "0.3", features = ["compat"] }
hex = "0.4.2"
jsonwebtoken = "7.1.1"
lazy_static = "1.4.0"
openssl = "0.10"
regex = "1.3"
Expand Down
7 changes: 5 additions & 2 deletions autoendpoint/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,11 @@ pub enum ApiErrorKind {
#[error(transparent)]
Uuid(#[from] uuid::Error),

#[error(transparent)]
Jwt(#[from] jsonwebtoken::errors::Error),

#[error("Error while validating token")]
TokenHashValidation(#[from] openssl::error::ErrorStack),
TokenHashValidation(#[source] openssl::error::ErrorStack),

#[error("Database error: {0}")]
Database(#[source] autopush_common::errors::Error),
Expand Down Expand Up @@ -102,7 +105,7 @@ impl ApiErrorKind {

ApiErrorKind::NoSubscription => StatusCode::GONE,

ApiErrorKind::VapidError(_) => StatusCode::UNAUTHORIZED,
ApiErrorKind::VapidError(_) | ApiErrorKind::Jwt(_) => StatusCode::UNAUTHORIZED,

ApiErrorKind::InvalidToken | ApiErrorKind::InvalidApiVersion => StatusCode::NOT_FOUND,

Expand Down
85 changes: 64 additions & 21 deletions autoendpoint/src/server/extractors/subscription.rs
Original file line number Diff line number Diff line change
@@ -1,27 +1,28 @@
use crate::error::{ApiError, ApiErrorKind, ApiResult};
use crate::server::extractors::token_info::TokenInfo;
use crate::server::extractors::token_info::{ApiVersion, TokenInfo};
use crate::server::extractors::user::validate_user;
use crate::server::headers::crypto_key::CryptoKeyHeader;
use crate::server::headers::vapid::{VapidHeader, VapidVersionData};
use crate::server::headers::vapid::{VapidHeader, VapidHeaderWithKey, VapidVersionData};
use crate::server::{ServerState, VapidError};
use actix_http::{Payload, PayloadStream};
use actix_web::web::Data;
use actix_web::{FromRequest, HttpRequest};
use autopush_common::db::DynamoDbUser;
use autopush_common::util::sec_since_epoch;
use cadence::{Counted, StatsdClient};
use futures::compat::Future01CompatExt;
use futures::future::LocalBoxFuture;
use futures::FutureExt;
use openssl::hash;
use jsonwebtoken::{Algorithm, DecodingKey, Validation};
use openssl::hash::MessageDigest;
use std::borrow::Cow;
use uuid::Uuid;

/// Extracts subscription data from `TokenInfo` and verifies auth/crypto headers
pub struct Subscription {
pub user: DynamoDbUser,
pub channel_id: Uuid,
pub vapid: Option<VapidHeader>,
pub public_key: Option<String>,
pub vapid: Option<VapidHeaderWithKey>,
}

impl FromRequest for Subscription {
Expand All @@ -44,17 +45,14 @@ impl FromRequest for Subscription {
.decrypt(&repad_base64(&token_info.token))
.map_err(|_| ApiErrorKind::InvalidToken)?;

// Parse VAPID and extract public key
let vapid = parse_vapid(&token_info, &state.metrics)?;
let public_key = vapid
.as_ref()
// Parse VAPID and extract public key.
let vapid: Option<VapidHeaderWithKey> = parse_vapid(&token_info, &state.metrics)?
.map(|vapid| extract_public_key(vapid, &token_info))
.transpose()?;

if token_info.api_version == "v2" {
version_2_validation(&token, public_key.as_deref())?;
} else {
version_1_validation(&token)?;
match token_info.api_version {
ApiVersion::Version1 => version_1_validation(&token)?,
ApiVersion::Version2 => version_2_validation(&token, vapid.as_ref())?,
}

// Load and validate user data
Expand All @@ -68,11 +66,19 @@ impl FromRequest for Subscription {
.map_err(ApiErrorKind::Database)?;
validate_user(&user, &channel_id, &state).await?;

// Validate the VAPID JWT token and record the version
if let Some(vapid) = &vapid {
validate_vapid_jwt(vapid)?;

state
.metrics
.incr(&format!("updates.vapid.draft{:02}", vapid.vapid.version()))?;
}

Ok(Subscription {
user,
channel_id,
vapid,
public_key,
})
}
.boxed_local()
Expand Down Expand Up @@ -115,8 +121,8 @@ fn parse_vapid(token_info: &TokenInfo, metrics: &StatsdClient) -> ApiResult<Opti
}

/// Extract the VAPID public key from the headers
fn extract_public_key(vapid: &VapidHeader, token_info: &TokenInfo) -> ApiResult<String> {
Ok(match &vapid.version_data {
fn extract_public_key(vapid: VapidHeader, token_info: &TokenInfo) -> ApiResult<VapidHeaderWithKey> {
Ok(match vapid.version_data.clone() {
VapidVersionData::Version1 => {
// VAPID v1 stores the public key in the Crypto-Key header
let header = token_info.crypto_key_header.as_deref().ok_or_else(|| {
Expand All @@ -131,9 +137,12 @@ fn extract_public_key(vapid: &VapidHeader, token_info: &TokenInfo) -> ApiResult<
)
})?;

public_key.to_string()
VapidHeaderWithKey {
vapid,
public_key: public_key.to_string(),
}
}
VapidVersionData::Version2 { public_key } => public_key.clone(),
VapidVersionData::Version2 { public_key } => VapidHeaderWithKey { vapid, public_key },
})
}

Expand All @@ -148,7 +157,7 @@ fn version_1_validation(token: &[u8]) -> ApiResult<()> {
}

/// `/webpush/v2/` validations
fn version_2_validation(token: &[u8], public_key: Option<&str>) -> ApiResult<()> {
fn version_2_validation(token: &[u8], vapid: Option<&VapidHeaderWithKey>) -> ApiResult<()> {
if token.len() != 64 {
// Corrupted token
return Err(ApiErrorKind::InvalidToken.into());
Expand All @@ -157,12 +166,13 @@ fn version_2_validation(token: &[u8], public_key: Option<&str>) -> ApiResult<()>
// Verify that the sender is authorized to send notifications.
// The last 32 bytes of the token is the hashed public key.
let token_key = &token[32..];
let public_key = public_key.ok_or(ApiErrorKind::VapidError(VapidError::MissingKey))?;
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(|_| VapidError::InvalidKey)?;
let key_hash = hash::hash(hash::MessageDigest::sha256(), &public_key)?;
let key_hash = openssl::hash::hash(MessageDigest::sha256(), &public_key)
.map_err(ApiErrorKind::TokenHashValidation)?;

// Verify that the VAPID public key equals the (expected) token public key
if !openssl::memcmp::eq(&key_hash, &token_key) {
Expand All @@ -171,3 +181,36 @@ fn version_2_validation(token: &[u8], public_key: Option<&str>) -> ApiResult<()>

Ok(())
}

/// Validate the VAPID JWT token. Specifically,
/// - Check the signature
/// - Make sure it hasn't expired
/// - Make sure the expiration isn't too far into the future
///
/// This is mostly taken care of by the jsonwebtoken library
fn validate_vapid_jwt(vapid: &VapidHeaderWithKey) -> ApiResult<()> {
let VapidHeaderWithKey { vapid, public_key } = vapid;

#[derive(serde::Deserialize)]
struct Claims {
exp: u64,
}

// Check the signature and make sure the expiration is in the future
let token_data = jsonwebtoken::decode::<Claims>(
&vapid.token,
&DecodingKey::from_ec_der(public_key.as_bytes()),
&Validation::new(Algorithm::ES256),
)?;

// Make sure the expiration isn't too far into the future
let now = sec_since_epoch();
const ONE_DAY_IN_SECONDS: u64 = 60 * 60 * 24;

if token_data.claims.exp - now > ONE_DAY_IN_SECONDS {
// The expiration is too far in the future
return Err(VapidError::FutureExpirationToken.into());
}

Ok(())
}
Loading

0 comments on commit 04fee7f

Please sign in to comment.