Skip to content

Instantly share code, notes, and snippets.

@schirrmacher
Last active June 14, 2023 09:53
Show Gist options
  • Save schirrmacher/caf2a8bbfdb3d59d95af80dee99d42e7 to your computer and use it in GitHub Desktop.
Save schirrmacher/caf2a8bbfdb3d59d95af80dee99d42e7 to your computer and use it in GitHub Desktop.
SafetyNet Attestation Backend Example Implementation for Validating Android Device Authenticity
import moment = require("moment");
import config from "../../../config";
import BaseService from "../BaseService";
import { DeviceCheckService, DeviceCheckParams } from "./DeviceCheckService";
import { buildQueryParams } from "../helper/QueryHelper";
import { isSuccessStatus } from "../helper/ResponseHelper";
import { isTokenReplayed } from "../helper/DatabaseHelper";
export class GoogleSafetyNetAttestationService extends BaseService implements DeviceCheckService {
public loggingTag = "GoogleSafetyNetAttestation";
public host = "https://www.googleapis.com/androidcheck/v1/attestations/verify";
public async shouldProceed(params: DeviceCheckParams): Promise<boolean> {
const { timestamp, token, binding } = params;
// The SafetyNet Attestation API uses the following workflow:
// 1. The SafetyNet Attestation API receives a call from your app. This call includes a nonce.
// 2. The SafetyNet Attestation service evaluates the runtime environment and requests a signed attestation of the assessment results from Google's servers.
// 3. Google's servers send the signed attestation to the SafetyNet Attestation service on the device.
// 4. The SafetyNet Attestation service returns this signed attestation to your app.
// 5. Your app forwards the signed attestation to your server.
// 6. This server validates the response and uses it for anti-abuse decisions. Your server communicates its findings to your app.
// we have to implement step 6 here
const jwtComponents = token.split(".");
// JOSE Header + Claims Object + Signature => 3
if (jwtComponents.length !== 3) {
this.logger.error(`${this.loggingTag}: invalid token format`);
return false;
}
// https://tools.ietf.org/html/rfc7519#section-4
const jwtClaims = JSON.parse(Buffer.from(jwtComponents[1], "base64").toString());
if (jwtClaims.error) {
this.logger.error(`${this.loggingTag}: JWT contains error: ${jwtClaims.error}`);
return false;
}
// The nonce is defined by us.
// It contains the following information (outer encoding is done by Google):
// Base64Encode( Base64Encode(<random identifier>).Base64Encode(<binding>) )
// random identifier: used for detecting replay attacks.
// binding: Used so that the token cannot be used for other contexts.
const jwtNonce = Buffer.from(jwtClaims.nonce, "base64").toString();
const jwtNonceComponents = jwtNonce.split(".");
const jwtRandomIdentifier = Buffer.from(jwtNonceComponents[0], "base64").toString();
const jwtBinding = Buffer.from(jwtNonceComponents[1], "base64").toString();
const jwtAppPackageName = jwtClaims.apkPackageName;
const elapsedSecondsSinceJwtCreation = moment(timestamp).diff(moment(jwtClaims.timestampMs), "seconds");
if (elapsedSecondsSinceJwtCreation >= config.google.jwtExpirationTime) {
this.logger.error(`${this.loggingTag}: JWT expired (${elapsedSecondsSinceJwtCreation} seconds elapsed): creation timestamp ${jwtClaims.timestampMs} / current timestamp ${timestamp}`);
return false;
}
if (isTokenReplayed(jwtRandomIdentifier)) {
this.logger.error(`${this.loggingTag}: replayed token ${jwtRandomIdentifier}`);
return false;
}
if (jwtBinding !== binding) {
this.logger.error(`${this.loggingTag}: invalid binding: expected ${jwtBinding} instead of ${binding}`);
return false;
}
if (config.google.androidAppPackageName !== jwtAppPackageName) {
this.logger.error(`${this.loggingTag}: unexpected app package name: "${config.google.androidAppPackageName}" not equal to "${jwtAppPackageName}"`);
return false;
}
// Verify JWT before checking its content:
const googleResponse = await this.post({
url: this.host + buildQueryParams({ key: config.google.androidAppApiKey }),
body: { signedAttestation: token },
});
if (!isSuccessStatus(googleResponse.status)) {
let errorMessage = `${this.loggingTag}: Google SafetyNetAttestation API status invalid (${googleResponse.status})`;
errorMessage += ": maybe rate limit has been reached";
this.logger.error(errorMessage);
throw new Error(errorMessage);
}
if (!googleResponse.data.isValidSignature) {
this.logger.error(`${this.loggingTag}: suspicious behavior detected: invalid signature`);
return false;
}
// From here JWT is validated. Now check its content.
// Device Status Value of ctsProfileMatch Value of basicIntegrity
// Certified, genuine device that passes CTS true true
// Certified device with unlocked bootloader false true
// Genuine but uncertified device, such as when the manufacturer doesn't apply for certification false true
// Device with custom ROM (not rooted) false true
// Emulator false false
// No device (such as a protocol emulating script) false false
// Signs of system integrity compromise, one of which may be rooting false false
// Signs of other active attacks, such as API hooking false false
// check ctsProfileMatch depending on your requirements (see table)
if (!jwtClaims.ctsProfileMatch) {
this.logger.error(`${this.loggingTag}: suspicious behavior detected: ctsProfileMatch not true`);
return false;
}
if (!jwtClaims.basicIntegrity) {
this.logger.error(`${this.loggingTag}: suspicious behavior detected: basicIntegrity not given`);
return false;
}
return true;
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment