JHH
JHH

Reputation: 9315

Reliably verify a JWS certificate chain and domain

I'm writing backend code to verify a JWS from Google's SafetyNet API, in Node.JS. I was surprised to not find a readily available module for this, so I started looking at some simple verification of the JWS using available libraries:

First of all, Google says these are the steps required:

  1. Extract the SSL certificate chain from the JWS message.
  2. Validate the SSL certificate chain and use SSL hostname matching to verify that the leaf certificate was issued to the hostname attest.android.com.
  3. Use the certificate to verify the signature of the JWS message.
  4. Check the data of the JWS message to make sure it matches the data within your original request. In particular, make sure that the timestamp has been validated and that the nonce, package name, and hashes of the app's signing certificate(s) match the expected values.

(from https://developer.android.com/training/safetynet/attestation#verify-attestation-response)

I found node-jose which offered a simple interface to verify JWS's, and it has an option to allow an embedded key. I'm trying to understand exactly what this process does and if it's sufficient for verifying the authenticity of the JWS?

const {JWS} = require('node-jose');
const result = await JWS.createVerify({allowEmbeddedKey: true}).verify(jws);

if (result.key.kid === 'attest.android.com') {
  // Are we good to go or do we manually need to verify the certificate chain further?
}

Does using the embedded key indeed validate the embedded certificate chain x5c using the root CA, and the signature against the certificate? Or do I need to explicitly obtain a public key from Google to verify the certificate separately?

Then, a somewhat related question concerns Google's API for performing this validation: there is an API https://www.googleapis.com/androidcheck/v1/attestations/verify?key=... that performs this exact operation, but it seems to have been removed from Google's docs, and can only be found referenced in dated articles and SO answers about SafetyNet such as this one which seems to suggest that this API is only for testing, and in production you should perform the certificate verification yourself. Does anyone know if this API is good for production use or not? If everyone is meant to manually verify the JWS, I find it slightly surprising that Google wouldn't offer more documentation and code examples since this process is quite error-prone, and mistakes could have serious effects? So far I've only found some 3rd party examples in Java, but no server-side code examples from Google.

Upvotes: 3

Views: 3729

Answers (1)

Aditya Kumar
Aditya Kumar

Reputation: 71

Here are the steps that you would need to perform as recommended by Google.

Definitely feel free to go through all the reference links to understand the process a bit better. Do look into each library functions used here to know what they are doing and if that is exactly what you want them to do. I've written pseudocode to explain the steps. You might have to run them on a sample attestation token to test them out and change a few things accordingly.

It would also be good to look at the whole node implementation of SafetyNet in one place.

// following steps should be performed
// 1. decode the JWS
// 2. the source of the first certificate in x5c array of jws header 
//    should be attest.google.com
// 3. to make sure if the JWS was not tampered with, validate the signature of JWS (how signature verification is done is explained in the reference links)
//    with the certificate whose source we validated
// 4. if the signature was valid, we need to know if the certificate was valid by 
//    explicitly checking the certificate chain
// 5. Validate the payload by matching the package name, apkCertificateDigest
//    and nonce value (apkCertificateDigest is base64 encoding of the hash of signing app's certificate)
// 6. and now you can trust the ctsProfileMatch and BasicIntegrity flags
// let's see some code in node, though this will not run as-is, 
// it provides an outline on how to do it and which functions to consider when implementing

const pki = require('node-forge').pki;
const jws = require('jws');
const pem = require("pem");
const forge = require('node-forge');

const signedAttestation = "Your signed attestation here";

function deviceAttestationCheck(signedAttestation) {
  // 1. decode the jws
  const decodedJws = jws.decode(signedAttestation);
  const payload = JSON.parse(decodedJws.payload);

  // convert the certificate received in the x5c array into valid certificates by adding 
  // '-----BEGIN CERTIFICATE-----\n' and '-----END CERTIFICATE-----'
  // at the start and end respectively for each certificate in the array
  // and by adding '\n' at every 64 char
  // you'll have to write your own function to do the simple string reformatting
  // get the x5c certificate array
  const x5cArray = decodedJws.header.x5c;
  updatedX5cArray = doTheReformatting(x5cArray);

  // 2. verify the source to be attest.google.com
  certToVerify = updatedX5cArray[0];
  const details = pem.readCertificateInfo(certToVerify);
  // check if details.commanName === "attest.google.com"

  const certs = updatedX5cArray.map((cert) => pki.certificateFromPem(cert));

  // 3. Verify the signature with the certificate that we received
  // the first element of the certificate(certs array) is the one that was issued to us, so we should use that to verify the signature
  const isSignatureValid = jws.verify(signedAttestation, 'RS256', certs[0]);

  // 4. to be sure if the certificate we used to verify the signature is the valid one, we should validate the certificate chain
  const gsr2Reformatted = doTheReformatting(gsr2);
  const rootCert = pki.certificateFromPem(gsr2Reformatted);
  const caStore = pki.createCaStore([rootCert]);

  // NOTE: this pki implementation does not check for certificate revocation list, which is something that you'll need to do separately
  const isChainValid = pki.verifyCertificateChain(caStore, certs);

  // 5. now we can validate the payload
  // check the timestamps, to be within certain time say 1 hour
  // check nonce value, to contain the data that you expect, refer links below
  // check apkPackageName to be your app's package name
  // check apkCertificateDigestSha256 to be from your app - quick tip -look at the function below on how to generate this
  // finally you can trust the ctsProfileMatch - true/false depending on strict security need and basicIntegrity - true, minimum to check

}

// this function takes your signing certificate(should be of the form '----BEGIN CERT....data...---END CERT...') and converts into the SHA256 digest in hex, which looks like - 92:8H:N9:84:YT:94:8N.....
// we need to convert this hex digest to base64 
// 1. 92:8H:N9:84:YT:94:8N.....
// 2. 928hn984yt948n - remove the colon and toLowerCase
// 3. encode it in base64
function certificateToSha256DigestHex(certPem) {
  const cert = pki.certificateFromPem(certPem);
  const der = forge.asn1.toDer(pki.certificateToAsn1(cert)).getBytes();
  const m = forge.md.sha256.create();
  m.start();
  m.update(der);
  const fingerprint = m.digest()
      .toHex()
      .match(/.{2}/g)
      .join(':')
      .toUpperCase();

  return fingerprint
}

// 92:8H:N9:84:YT:94:8N => 928hn984yt948n
function stringToHex(sha256string) {
  return sha256string.split(":").join('').toLowerCase();
}

// this is what google sends you in apkCertificateDigestSha256 array
// 928hn984yt948n => "OIHf9wjfjkjf9fj0a="
function hexToBase64(hexString) {
  return Buffer.from(hexString, 'hex').toString('base64')
}

All the articles that helped me:

  1. Summary for the steps - Here
  2. explanation in depth with implementation - Here
  3. Things you should keep in mind - Here
  4. checklist from google to do it correctly - Here
  5. Deep Dive into the process - Here

Upvotes: 5

Related Questions