Skip to content

Commit

Permalink
Merge pull request #3 from pimeys/master
Browse files Browse the repository at this point in the history
JWT auth, lots of refactoring etc.
  • Loading branch information
polyitan committed Feb 12, 2017
2 parents f441d43 + 5b5058e commit 4dbd9d4
Show file tree
Hide file tree
Showing 18 changed files with 1,015 additions and 441 deletions.
5 changes: 5 additions & 0 deletions .gitignore
Expand Up @@ -20,3 +20,8 @@
# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries
# More information here http://doc.crates.io/guide.html#cargotoml-vs-cargolock
Cargo.lock

# Certificate files
*.der
*.pem
*.key
14 changes: 11 additions & 3 deletions Cargo.toml
@@ -1,7 +1,7 @@
[package]
name = "apns2"
version = "0.0.1"
authors = ["Sergey Tkachenko <seriy.tkachenko@gmail.com>"]
version = "0.1.0"
authors = ["Sergey Tkachenko <seriy.tkachenko@gmail.com>", "Julius de Bruijn <julius.debruijn@360dialog.com>"]
license = "MIT"
readme = "README.md"
description = "HTTP/2 Apple Push Notification Service for Rust"
Expand All @@ -15,10 +15,18 @@ rustc-serialize = "~0.3"
time = "~0.1"

[dependencies.openssl]
default-features = true
version = "~0.7"
features = ["tlsv1_2", "npn", "alpn"]

[dev-dependencies]
argparse = "*"

[dependencies.solicit]
git = "https://github.com/aagahi/solicit"
git = "https://github.com/pimeys/solicit"
default-features = true
features = ["tls"]

[dependencies.btls]
features = ["gnutls-ecdsa"]
git = "https://gitlab.com/ilari_l/btls.git"
70 changes: 32 additions & 38 deletions README.md
@@ -1,49 +1,43 @@
[![Build Status](https://travis-ci.org/polyitan/apns2.svg?branch=master)](https://travis-ci.org/polyitan/apns2)

# apns2
HTTP/2 Apple Push Notification Service for Rust

## Install (Important: not published in Cargo yet!!!)
Add this to your Cargo.toml:
```toml
[dependencies]
apns2 = "0.0.1"
```
and this to your crate root:
```rust
extern crate apns2;

use apns2::{Provider, DeviceToken};
use apns2::{Notification, NotificationOptions};
use apns2::{Payload, APS, APSAlert, APSLocalizedAlert};
use apns2::{Response, APNSError};
```
## Generate cert and key files
At first you need export APNs Certificate from KeyChain to .p12 format. And convert to .pem:
Supports certificate-based and token-based authentication. Depends on a forked
solicit to support rust-openssl 0.7 and btls which is not yet released or
stable. We use this right now, but use at your own risk. Plans are to get rid
of solicit and use the tokio http2 client when stable and available.

### Certificate & Private Key Authentication

If having the certificate straight from Apple as PKCS12 database, it must be
converted to PEM files containing the certificate and the private key.

```shell
openssl pkcs12 -in push.p12 -clcerts -out push_cert.pem
openssl pkcs12 -in push.p12 -nocerts -nodes | openssl rsa > push_key.pem
openssl pkcs12 -in push_key.p12 -nodes -out push_key.key -nocerts
openssl pkcs12 -in push_cert.p12 -out push_cert.pem
openssl x509 -outform der -in push_cert.pem -out push_cert.crt
```

## Usage
#### Sending a push notification
```rust
let provider = Provider::new(true, "/path/to/push_cert.pem", "/path/to/push_key.key");
let alert = APSAlert::Plain("Message!".to_string());
let payload = Payload::new(alert, Some(1), "default");
let token = DeviceToken::new("xxxx...xxxx");
let options = NotificationOptions::default();
let notification = Notification::new(payload, token, options);
provider.push(notification, |result| {
match result {
Ok(res) => {
println!("Ok: {:?}", res);
},
Err(res) => {
println!("Error: {:?}", res);
}
}
});
The connection is now open for push notifications and should be kept open for
multiple notifications to prevent Apple treating the traffic as DOS. The connection
is only valid for the application where the certificate was created to.

### JWT Token Authentication

To use the PKCS8 formatted private key for token generation, one must
convert it into DER format.

```shell
openssl pkcs8 -nocrypt -in key.p8 -out newtest.der -outform DER
```

The connection can be used to send push notifications into any application
by changing the token. The token is valid for one hour until it has to be
renewed.

All responses are channels which can be blocked to receive the response. For better
throughput it is a good idea to handle the responses in another thread.

## License
[MIT License](https://github.com/tkabit/apns2/blob/master/LICENSE)
51 changes: 51 additions & 0 deletions examples/certificate_client.rs
@@ -0,0 +1,51 @@
extern crate apns2;
extern crate argparse;

use argparse::{ArgumentParser, Store, StoreTrue};
use apns2::client::CertificateClient;
use apns2::payload::{Payload, APSAlert};
use apns2::notification::{Notification, NotificationOptions};
use std::fs::File;
use std::time::Duration;

// An example client connectiong to APNs with a certificate and key
fn main() {
let mut certificate_pem_file = String::new();
let mut key_pem_file = String::new();
let mut device_token = String::new();
let mut message = String::from("Ch-check it out!");
let mut sandbox = false;

{
let mut ap = ArgumentParser::new();
ap.set_description("APNs certificate-based push");
ap.refer(&mut certificate_pem_file).add_option(&["-c", "--certificate"], Store, "Certificate PEM file location");
ap.refer(&mut key_pem_file).add_option(&["-k", "--key"], Store, "Private key PEM file location");
ap.refer(&mut device_token).add_option(&["-d", "--device_token"], Store, "APNs device token");
ap.refer(&mut message).add_option(&["-m", "--message"], Store, "Notification message");
ap.refer(&mut sandbox).add_option(&["-s", "--sandbox"], StoreTrue, "Use the development APNs servers");
ap.parse_args_or_exit();
}

// Read the private key and certificate from the disk
let mut cert_file = File::open(certificate_pem_file).unwrap();
let mut key_file = File::open(key_pem_file).unwrap();

// Create a new client to APNs
let client = CertificateClient::new(sandbox, &mut cert_file, &mut key_file).unwrap();

// APNs payload
let payload = Payload::new(APSAlert::Plain(message), "default", Some(1u32), None, None);

let options = NotificationOptions {
..Default::default()
};

// Fire the request, return value is a mpsc rx channel
let request = client.push(Notification::new(payload, &device_token, options));

// Read the response and block maximum of 2000 milliseconds, throwing an exception for a timeout
let response = request.recv_timeout(Duration::from_millis(2000));

println!("{:?}", response);
}
59 changes: 59 additions & 0 deletions examples/token_client.rs
@@ -0,0 +1,59 @@
extern crate apns2;
extern crate argparse;

use argparse::{ArgumentParser, Store, StoreTrue};
use apns2::client::TokenClient;
use apns2::apns_token::APNSToken;
use apns2::payload::{Payload, APSAlert};
use apns2::notification::{Notification, NotificationOptions};
use std::fs::File;
use std::time::Duration;

// An example client connectiong to APNs with a JWT token
fn main() {
let mut der_file_location = String::new();
let mut team_id = String::new();
let mut key_id = String::new();
let mut device_token = String::new();
let mut message = String::from("Ch-check it out!");
let mut ca_certs = String::from("/etc/ssl/cert.pem");
let mut sandbox = false;

{
let mut ap = ArgumentParser::new();
ap.set_description("APNs token-based push");
ap.refer(&mut der_file_location).add_option(&["-e", "--der"], Store, "Private key file in DER format");
ap.refer(&mut team_id).add_option(&["-t", "--team_id"], Store, "APNs team ID");
ap.refer(&mut key_id).add_option(&["-k", "--key_id"], Store, "APNs key ID");
ap.refer(&mut device_token).add_option(&["-d", "--device_token"], Store, "APNs device token");
ap.refer(&mut message).add_option(&["-m", "--message"], Store, "Notification message");
ap.refer(&mut sandbox).add_option(&["-s", "--sandbox"], StoreTrue, "Use the development APNs servers");
ap.refer(&mut ca_certs).add_option(&["-c", "--ca_certs"], Store, "The system CA certificates PEM file");
ap.parse_args_or_exit();
}

// Read the private key from disk
let der_file = File::open(der_file_location).unwrap();

// Create a new token struct with the private key, team id and key id
// The token is valid for an hour and needs to be renewed after that
let apns_token = APNSToken::new(der_file, team_id.as_ref(), key_id.as_ref()).unwrap();

// Create a new client to APNs, giving the system CA certs
let client = TokenClient::new(sandbox, &ca_certs).unwrap();

// APNs payload
let payload = Payload::new(APSAlert::Plain(message), "default", Some(1u32), None, None);

let options = NotificationOptions {
..Default::default()
};

// Fire the request, return value is a mpsc rx channel
let request = client.push(Notification::new(payload, &device_token, options), apns_token.signature());

// Read the response and block maximum of 2000 milliseconds, throwing an exception for a timeout
let response = request.recv_timeout(Duration::from_millis(2000));

println!("{:?}", response);
}
161 changes: 161 additions & 0 deletions src/apns_token.rs
@@ -0,0 +1,161 @@
//! A module for APNS JWT token management.

use btls::server_keys::LocalKeyPair;
use btls::jose_jws::{sign_jws, JsonNode};
use std::convert::From;
use btls::error::KeyReadError;
use time::get_time;
use std::collections::BTreeMap;
use std::io::Read;

const SIG_ECDSA_SHA256: u16 = 0x0403;

pub struct APNSToken {
signature: Option<String>,
issued_at: Option<i64>,
key_id: String,
team_id: String,
secret: LocalKeyPair,
}

#[derive(Debug)]
pub enum APNSTokenError {
SignError,
KeyParseError(String),
KeyOpenError(String),
KeyReadError(String),
KeyGenerationError,
KeyError,
}

impl From<KeyReadError> for APNSTokenError {
fn from(e: KeyReadError) -> APNSTokenError {
match e {
KeyReadError::ParseError(e, _) => APNSTokenError::KeyParseError(e),
KeyReadError::OpenError(e, _) => APNSTokenError::KeyOpenError(e),
KeyReadError::ReadError(e, _) => APNSTokenError::KeyReadError(e),
KeyReadError::KeyGenerationFailed => APNSTokenError::KeyGenerationError,
_ => APNSTokenError::KeyError,
}
}
}

impl APNSToken {
/// Create a new APNSToken.
///
/// A generator for JWT tokens when using the token-based authentication in APNs.
/// The private key should be in DER binary format and can be provided in any
/// format implementing the Read trait.
///
/// # Example
/// ```no_run
/// # extern crate apns2;
/// # fn main() {
/// use apns2::apns_token::APNSToken;
/// use std::fs::File;
///
/// let der_file = File::open("/path/to/apns.der").unwrap();
/// APNSToken::new(der_file, "TEAMID1234", "KEYID12345").unwrap();
/// # }
/// ```
pub fn new<S,R>(mut pk_der: R, key_id: S, team_id: S) -> Result<APNSToken, APNSTokenError>
where S: Into<String>, R: Read {

let mut token = APNSToken {
signature: None,
issued_at: None,
key_id: key_id.into(),
team_id: team_id.into(),
secret: LocalKeyPair::new(&mut pk_der, "apns_private_key")?,
};

match token.renew() {
Err(e) => Err(e),
_ => Ok(token),
}
}

/// Generates an authentication signature.
///
/// # Example
/// ```no_run
/// # extern crate apns2;
/// # fn main() {
/// use apns2::apns_token::APNSToken;
/// use std::fs::File;
///
/// let der_file = File::open("/path/to/apns.der").unwrap();
/// let apns_token = APNSToken::new(der_file, "TEAMID1234", "KEYID12345").unwrap();
/// let signature = apns_token.signature();
/// # }
/// ```
pub fn signature(&self) -> &str {
match self.signature {
Some(ref sig) => sig,
None => ""
}
}

/// Sets a new timestamp for the token. APNs tokens are valid for 60 minutes until
/// they need to be renewed.
///
/// # Example
/// ```no_run
/// # extern crate apns2;
/// # fn main() {
/// use apns2::apns_token::APNSToken;
/// use std::fs::File;
///
/// let der_file = File::open("/path/to/apns.der").unwrap();
/// let mut apns_token = APNSToken::new(der_file, "TEAMID1234", "KEYID12345").unwrap();
/// apns_token.renew().unwrap();
/// # }
/// ```
pub fn renew(&mut self) -> Result<(), APNSTokenError> {
let issued_at = get_time().sec;

let mut headers: BTreeMap<String, JsonNode> = BTreeMap::new();
headers.insert("alg".to_string(), JsonNode::String("ES256".to_string()));
headers.insert("kid".to_string(), JsonNode::String(self.key_id.to_string()));

let mut payload: BTreeMap<String, JsonNode> = BTreeMap::new();
payload.insert("iss".to_string(), JsonNode::String(self.team_id.to_string()));
payload.insert("iat".to_string(), JsonNode::Number(issued_at));

let jwt_headers = JsonNode::Dictionary(headers);
let jwt_payload = JsonNode::Dictionary(payload).serialize();

match sign_jws(&jwt_headers, jwt_payload.as_bytes(), &self.secret, SIG_ECDSA_SHA256).read() {
Ok(Ok(token)) => {
self.signature = Some(token.to_compact());
self.issued_at = Some(issued_at);
Ok(())
}
_ => Err(APNSTokenError::SignError)
}
}

/// Info about the token expiration. If older than one hour, returns true.
///
/// # Example
/// ```no_run
/// # extern crate apns2;
/// # fn main() {
/// use apns2::apns_token::APNSToken;
/// use std::fs::File;
///
/// let der_file = File::open("/path/to/apns.der").unwrap();
/// let mut apns_token = APNSToken::new(der_file, "TEAMID1234", "KEYID12345").unwrap();
/// if apns_token.is_expired() {
/// apns_token.renew();
/// }
/// # }
/// ```
pub fn is_expired(&self) -> bool {
if let Some(issued_at) = self.issued_at {
(get_time().sec - issued_at) > 3600
} else {
true
}
}
}

0 comments on commit 4dbd9d4

Please sign in to comment.