Tuesday, February 04, 2014

OSX - Safari Push Notifications

Recently Apple has expanded the push notification service to its computers, as the OSX users among you may have noticed. After some discussions we tried to implement this on our server, so for our website too.

Unfortunately Apple provides just some php samples files, which is of course a help, but as our server is written in Java it was a bit complex to get it running. I'll provide in this post the main parts of the source needed to get it.

This sample code uses the default ZIP functions of java and the signing functions provided by BouncyCastle. Most parts of the code are kind of obvious and therefore omitted (any case, if you need them just drop me a message)

Let's start with the Zipper:
private class Zipper {

private final ByteArrayOutputStream bos;
private final ZipOutputStream zipfile;
private final Map manifest;

public Zipper() {
bos = new ByteArrayOutputStream();
zipfile = new ZipOutputStream(bos);
manifest = new HashMap();
}

public byte[] getManifest() throws UnsupportedEncodingException {
Set keys = manifest.keySet();
int i = 0;
StringBuilder sb = new StringBuilder();
for (String key : keys) {
sb.append(i <= 0 ? "{" : ",");
sb.append("\"" + key + "\": \"" + manifest.get(key) + "\"");
i++;
}
sb.append("}");
return sb.toString().getBytes("UTF8");
}

public byte[] finalizeZip() throws IOException {
zipfile.finish();
bos.flush();
zipfile.close();
manifest.clear();
return bos.toByteArray();
}

public void addFileToZip(String path, String filename, byte[] file, boolean addToManifest) throws IOException {
if (file != null) {
String completeFilename = path.length() > 0 ? path + "/" + filename : filename;
ZipEntry zipEntry = new ZipEntry(completeFilename);
CRC32 crc = new CRC32();
crc.update(file);
zipEntry.setCrc(crc.getValue());
zipfile.putNextEntry(zipEntry);
zipfile.write(file, 0, file.length);
zipfile.flush();
zipfile.closeEntry();

if (addToManifest) {
try {
manifest.put(completeFilename, SHAsum(file));
} catch (NoSuchAlgorithmException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
}
} else {
LOGGER.error("File " + filename + " was null!");
}
}

}

It is done as a private class within the class that actually returns the ZIP to the client. It does the zipping and immediately calculates the checksums that are needed for the manifest. For completeness here the simple Checksum creation methods

public static String SHAsum(byte[] convertme) throws NoSuchAlgorithmException {
MessageDigest md = MessageDigest.getInstance("SHA-1");
return byteArray2Hex(md.digest(convertme));
}

private static String byteArray2Hex(final byte[] hash) {
Formatter formatter = new Formatter();
for (byte b : hash) {
formatter.format("%02x", b);
}
String formattedstring = formatter.toString();
formatter.close();
return formattedstring;

}

This manifest will be created with the method "getManifest()" in the zipper class and signed using the next class which is again private and called PKCS7Encrypter

private class PKCS7Encrypter {

private final byte[] _store;
private final String _storepass;

public PKCS7Encrypter(byte[] store, String storepass) {
_store = store;
_storepass = storepass;
}

private KeyStore getKeystore() throws KeyStoreException, NoSuchAlgorithmException, CertificateException,
IOException {
if (_store == null) {
LOGGER.error("Could not find store file (.p12)");
return null;
}
// First load the keystore object by providing the p12 file path
KeyStore clientStore = KeyStore.getInstance("PKCS12");
// replace testPass with the p12 password/pin
clientStore.load(new ByteArrayInputStream(_store), _storepass.toCharArray());
return clientStore;
}

private X509CertificateHolder getCert(KeyStore keystore, String aliaz) throws GeneralSecurityException,
IOException {
java.security.cert.Certificate c = keystore.getCertificate(aliaz);
return new X509CertificateHolder(c.getEncoded());
}

private PrivateKey getPrivateKey(KeyStore keystore, String aliaz) throws GeneralSecurityException, IOException {
return (PrivateKey) keystore.getKey(aliaz, _storepass.toCharArray());
}

public byte[] sign(byte[] dataToSign) throws IOException, GeneralSecurityException, OperatorCreationException,
CMSException {
KeyStore clientStore = getKeystore();
if (clientStore == null) {
return null;
}
Enumeration aliases = clientStore.aliases();
String aliaz = "";
while (aliases.hasMoreElements()) {
aliaz = aliases.nextElement();
if (clientStore.isKeyEntry(aliaz)) {
break;
}
}

CMSTypedData msg = new CMSProcessableByteArray(dataToSign); // Data to sign

X509CertificateHolder x509Certificate = getCert(clientStore, aliaz);
List certList = new ArrayList();
certList.add(x509Certificate); // Adding the X509 Certificate

Store certs = new JcaCertStore(certList);

CMSSignedDataGenerator gen = new CMSSignedDataGenerator();
// Initializing the the BC's Signer
ContentSigner sha1Signer = new JcaContentSignerBuilder("SHA1withRSA").setProvider("BC").build(
getPrivateKey(clientStore, aliaz));

gen.addSignerInfoGenerator(new JcaSignerInfoGeneratorBuilder(new JcaDigestCalculatorProviderBuilder()
.setProvider("BC").build()).build(sha1Signer, x509Certificate));
// adding the certificate
gen.addCertificates(certs);
// Getting the signed data
CMSSignedData sigData = gen.generate(msg, false);
return sigData.getEncoded();
}


}

This class needs to be fed with the .p12-file that you can extract from the Mac OSX keystore. It shouldn't be just the private key, but the certificate with private key; and the password used to export it.

With this methods (and some simple methods to create the rest of the .pushPackage file you'll now be able to create the ZIP and send it as a response

// Create the ZIP file
Zipper zip = new Zipper();
zip.addFileToZip("icon.iconset", "icon_16x16.png", getResource("images/icons/icon_16x16.png"));
zip.addFileToZip("icon.iconset", "icon_16x16@2x.png", getResource("images/icons/icon_16x16@2x.png"));
zip.addFileToZip("icon.iconset", "icon_32x32.png", getResource("images/icons/icon_32x32.png"));
zip.addFileToZip("icon.iconset", "icon_32x32@2x.png", getResource("images/icons/icon_32x32@2x.png"));
zip.addFileToZip("icon.iconset", "icon_128x128.png", getResource("images/icons/icon_128x128.png"));
zip.addFileToZip("icon.iconset", "icon_128x128@2x.png", getResource("images/icons/icon_128x128@2x.png"));

zip.addFileToZip("", "website.json", getWebsiteJson(""));
byte[] manifest = zip.getManifest();
zip.addFileToZip("", "manifest.json", manifest, false);
try {
PKCS7Encrypter encrypter = new PKCS7Encrypter(getResource(STOREPATH), STOREPASS);
zip.addFileToZip("", "signature", encrypter.sign(manifest));
} catch (Exception e) {
LOGGER.error("Signature Error: " + e.getLocalizedMessage());
e.printStackTrace();
}


getBinaryFile(zip.finalizeZip(), "MyPage.pushpackage", response);

One last thing is missing! Java by default does not accept keys in the length we'll need them for this usage. So you'll end up in getting "java.security.InvalidKeyException:illegal Key Size" exceptions.

Fortunately Oracle provides a solution to this. You'll need to download the "Java Cryptography Extension (JCE) Unlimited Strength Jurisdiction Policy Files 6" zip(be aware of taking the right ones for your JDK). Behind this download you'll find 2 files which are "local_policy.jar" and "US_export_policy.jar". These need to end up in your "$JAVA_HOME/jre/lib/security" folder. Both of them will already be in this folder and need to be replaced (Save the original files before doing that)

BTW When you test all this implement the logging REST callback provided by Apple.
@SuppressWarnings("unchecked")
@RequestMapping(method = RequestMethod.POST, value = "/log")
public void logErrors(@PathVariable("version") String version, @RequestBody Map json,
HttpServletRequest request, HttpServletResponse response) throws IOException {
if (LOGGER.isDebugEnabled())
LOGGER.debug("logErrors()");

Object logs = json.get("logs");
if (logs != null) {
for (String logEntry : (ArrayList) logs) {
LOGGER.error("Safari Push messages error: " + logEntry);
}
}

}
This will save you hours of searching for errors

1 comment:

Hannes Tribus said...

Hello Hannes

Excellent post! Helped me a lot. Thanks for that.

One question, please:
Do you send send the actual push-notifications from a Java-Application, too?
And if so - would you be up to also share that code?
I'm specially interested in a Java-solution for the TCP-socket-connection which has to be created...

Thanks again!
Stephan