Last active
June 14, 2023 09:53
-
-
Save schirrmacher/caf2a8bbfdb3d59d95af80dee99d42e7 to your computer and use it in GitHub Desktop.
SafetyNet Attestation Backend Example Implementation for Validating Android Device Authenticity
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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