├── config.json ├── package.json ├── .vscode └── launch.json ├── LICENSE ├── .gitignore ├── src ├── shared.js ├── safetyNet.js └── playIntegrity.js ├── index.js └── README.md /config.json: -------------------------------------------------------------------------------- 1 | { "errorLevel": "log", 2 | "validCertificateSha256Digest": [] 3 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "playintegritycheckerserver", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "keywords": [], 10 | "author": "", 11 | "license": "ISC", 12 | "dependencies": { 13 | "base64url": "^3.0.1", 14 | "dotenv": "^16.0.1", 15 | "express": "^4.18.1", 16 | "googleapis": "^105.0.0", 17 | "jose": "^4.9.2", 18 | "jws": "^4.0.0" 19 | }, 20 | "type": "module" 21 | } 22 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "node", 9 | "request": "launch", 10 | "name": "Launch Program", 11 | "skipFiles": [ 12 | "/**" 13 | ], 14 | "program": "${workspaceFolder}/index.js" 15 | } 16 | ] 17 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2022 Henrik Herzig 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # here secrets are stored temporary 2 | secret/ 3 | 4 | # Finder mac 5 | .DS_Store 6 | 7 | # Logs 8 | logs 9 | *.log 10 | npm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | lerna-debug.log* 14 | .pnpm-debug.log* 15 | 16 | # Diagnostic reports (https://nodejs.org/api/report.html) 17 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 18 | 19 | # Runtime data 20 | pids 21 | *.pid 22 | *.seed 23 | *.pid.lock 24 | 25 | # Directory for instrumented libs generated by jscoverage/JSCover 26 | lib-cov 27 | 28 | # Coverage directory used by tools like istanbul 29 | coverage 30 | *.lcov 31 | 32 | # nyc test coverage 33 | .nyc_output 34 | 35 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 36 | .grunt 37 | 38 | # Bower dependency directory (https://bower.io/) 39 | bower_components 40 | 41 | # node-waf configuration 42 | .lock-wscript 43 | 44 | # Compiled binary addons (https://nodejs.org/api/addons.html) 45 | build/Release 46 | 47 | # Dependency directories 48 | node_modules/ 49 | jspm_packages/ 50 | 51 | # Snowpack dependency directory (https://snowpack.dev/) 52 | web_modules/ 53 | 54 | # TypeScript cache 55 | *.tsbuildinfo 56 | 57 | # Optional npm cache directory 58 | .npm 59 | 60 | # Optional eslint cache 61 | .eslintcache 62 | 63 | # Optional stylelint cache 64 | .stylelintcache 65 | 66 | # Microbundle cache 67 | .rpt2_cache/ 68 | .rts2_cache_cjs/ 69 | .rts2_cache_es/ 70 | .rts2_cache_umd/ 71 | 72 | # Optional REPL history 73 | .node_repl_history 74 | 75 | # Output of 'npm pack' 76 | *.tgz 77 | 78 | # Yarn Integrity file 79 | .yarn-integrity 80 | 81 | # dotenv environment variable files 82 | .env 83 | .env.development.local 84 | .env.test.local 85 | .env.production.local 86 | .env.local 87 | 88 | # parcel-bundler cache (https://parceljs.org/) 89 | .cache 90 | .parcel-cache 91 | 92 | # Next.js build output 93 | .next 94 | out 95 | 96 | # Nuxt.js build / generate output 97 | .nuxt 98 | dist 99 | 100 | # Gatsby files 101 | .cache/ 102 | # Comment in the public line in if your project uses Gatsby and not Next.js 103 | # https://nextjs.org/blog/next-9-1#public-directory-support 104 | # public 105 | 106 | # vuepress build output 107 | .vuepress/dist 108 | 109 | # vuepress v2.x temp and cache directory 110 | .temp 111 | .cache 112 | 113 | # Docusaurus cache and generated files 114 | .docusaurus 115 | 116 | # Serverless directories 117 | .serverless/ 118 | 119 | # FuseBox cache 120 | .fusebox/ 121 | 122 | # DynamoDB Local files 123 | .dynamodb/ 124 | 125 | # TernJS port file 126 | .tern-port 127 | 128 | # Stores VSCode versions used for testing VSCode extensions 129 | .vscode-test 130 | 131 | # yarn v2 132 | .yarn/cache 133 | .yarn/unplugged 134 | .yarn/build-state.yml 135 | .yarn/install-state.gz 136 | .pnp.* -------------------------------------------------------------------------------- /src/shared.js: -------------------------------------------------------------------------------- 1 | const maxTypeLength = 7; 2 | 3 | import { errorLevel } from "../index.js"; 4 | 5 | /** 6 | * print a message to the console with the date and time 7 | * @param {String} type 8 | * @param {String} title 9 | * @param {String} content 10 | */ 11 | export function logEvent(type, title, content) { 12 | const date = new Date(); 13 | const time = date.toLocaleTimeString(); 14 | const dateString = date.toLocaleDateString(); 15 | const placeholder = " ".repeat(Math.max(maxTypeLength - type.length, 0)); 16 | console.log( 17 | `${dateString} ${time} [${type}] ${placeholder}- ${title}: ${content}` 18 | ); 19 | } 20 | 21 | /** 22 | * generates a nonce 23 | * @param {Number} length length of the nonce 24 | * @returns {Number} generated nonce 25 | */ 26 | export function generateNonce(length) { 27 | // const nonce = crypto.randomBytes(length).toString(); 28 | // .replace(/\+/g, "-") // Convert '+' to '-' 29 | // .replace(/\//g, "_") // Convert '/' to '_' 30 | // .replace(/=+$/, ""); // Remove ending '=' 31 | // return nonce; 32 | var nonce = ""; 33 | var characters = 34 | "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; 35 | var charactersLength = characters.length; 36 | for (var i = 0; i < length; i++) { 37 | nonce += characters.charAt(Math.floor(Math.random() * charactersLength)); 38 | } 39 | return nonce; 40 | } 41 | 42 | /** 43 | * checks if the provided nonce is valid (if the nonce is contained in the @param nonce_list) 44 | * @param {String} nonce 45 | * @param {String[]} nonce_list 46 | * @param {String[]} old_nonce_list 47 | * @returns {Boolean} 48 | */ 49 | export function isNonceValid(nonce, nonce_list, old_nonce_list) { 50 | if (nonce_list.includes(nonce)) { 51 | // move nonce from nonce_list to old_nonce_list 52 | nonce_list.pop(nonce); 53 | old_nonce_list.push(nonce); 54 | logEvent(`INFO`, `Correct Nonce`, `Correct nonce '${nonce}' received`); 55 | } else { 56 | // nonce is not included in nonce_list error is sent 57 | if (old_nonce_list.includes(nonce)) { 58 | logEvent( 59 | `WARNING`, 60 | `Reused Nonce`, 61 | `duplicated use of nonce '${nonce}', potential replay attack` 62 | ); 63 | } else { 64 | logEvent( 65 | `WARNING`, 66 | `Unknown Nonce`, 67 | `nonce '${nonce}' was not previously generated on the server` 68 | ); 69 | } 70 | return false; 71 | } 72 | return true; 73 | } 74 | 75 | /** 76 | * depending on the errorLevel, either send an error to client or log it. If error is sent, function return true to indictae that server can stop processing 77 | * @param {*} res 78 | * @param {String} message 79 | * @returns {boolean} 80 | */ 81 | export function errorAndExit(res, message) { 82 | if (errorLevel == "error") { 83 | logEvent(`WARNING`, `Parsing`, message); 84 | res.status(400).send({ Error: message }); 85 | return true; 86 | } else if (errorLevel == "log") { 87 | logEvent(`WARNING`, `Parsing`, message); 88 | return false; 89 | } 90 | return true; 91 | } 92 | -------------------------------------------------------------------------------- /src/safetyNet.js: -------------------------------------------------------------------------------- 1 | import jws from "jws"; 2 | 3 | // module imports 4 | import { count, validCertificateSha256Digest, packageName } from "../index.js"; 5 | import { logEvent, errorAndExit, isNonceValid } from "./shared.js"; 6 | 7 | export function decryptSafetyNet(token) { 8 | // 1. decode the jws 9 | const decodedJws = jws.decode(token); 10 | const payload = JSON.parse(decodedJws.payload); 11 | // verifySignature(token); 12 | logEvent( 13 | `INFO`, 14 | `(SafetyNet) New Client Request (${count()}) processed`, 15 | payload 16 | ); 17 | return payload; 18 | } 19 | 20 | export async function verifySafetyNet( 21 | decryptedToken, 22 | checkNonce, 23 | nonce_list, 24 | old_nonce_list, 25 | res 26 | ) { 27 | var error = false; 28 | // verify nonce 29 | var nonce = Buffer.from(decryptedToken?.nonce, "base64") 30 | .toString() 31 | .replace(/\+/g, "-") // Convert '+' to '-' 32 | .replace(/\//g, "_") // Convert '/' to '_' 33 | .replace(/=+$/, ""); // Remove ending '=' 34 | if ( 35 | checkNonce == "server" && 36 | !isNonceValid(nonce, nonce_list, old_nonce_list) 37 | ) { 38 | if (errorAndExit(res, `Invalid Nonce`)) return false; 39 | error = true; 40 | } 41 | 42 | // verify timestamp: request isn't older than 10 seconds 43 | if (Date.now() - decryptedToken?.timestampMs > 10000) { 44 | if (errorAndExit(res, `Request too old`)) return false; 45 | error = true; 46 | } 47 | 48 | // verify package name 49 | if (packageName != decryptedToken?.apkPackageName) { 50 | if (errorAndExit(res, `Invalid package name`)) return false; 51 | error = true; 52 | } 53 | 54 | // verify basic integrity 55 | if (decryptedToken?.basicIntegrity == false) { 56 | if (errorAndExit(res, `Basic integrity check failed`)) return false; 57 | error = true; 58 | } 59 | 60 | // log integrity evaluation type 61 | logEvent( 62 | `INFO`, 63 | `Attestation`, 64 | `Using ${decryptedToken?.evaluationType} to evaluate device integrity.` 65 | ); 66 | 67 | if (!decryptedToken?.basicIntegrity) { 68 | if (errorAndExit(res, `Device doesn't meet basic integrity`)) return false; 69 | error = true; 70 | } 71 | 72 | if (!decryptedToken?.ctsProfileMatch) { 73 | logEvent( 74 | `INFO`, 75 | `Attestation`, 76 | `(SafetyNet) Evaluation type is BASIC, skipping CTS profile check` 77 | ); 78 | } else { 79 | if (decryptedToken?.ctsProfileMatch == false) { 80 | if (errorAndExit(res, `CTS profile match failed`)) return false; 81 | error = true; 82 | } 83 | 84 | // verify apk certificate digest 85 | if ( 86 | decryptedToken?.apkCertificateDigestSha256 == null || 87 | !decryptedToken?.apkCertificateDigestSha256?.some((e) => 88 | validCertificateSha256Digest?.includes(e) 89 | ) 90 | ) { 91 | if (errorAndExit(res, `Invalid apk certificate digest`)) return false; 92 | error = true; 93 | } 94 | } 95 | 96 | if (!error) { 97 | logEvent(`INFO`, `Attestation`, `SafetyNet Checks passed`); 98 | return true; 99 | } else { 100 | logEvent(`WARNING`, `Attestation`, `SafetyNet Checks failed`); 101 | return false; 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | // Express server 2 | import express, { json } from "express"; 3 | const app = express(); 4 | const PORT = 8080; 5 | 6 | // local imports 7 | import { 8 | decryptPlayIntegrity, 9 | verifyPlayIntegrity, 10 | } from "./src/playIntegrity.js"; 11 | import { generateNonce, logEvent } from "./src/shared.js"; 12 | import { decryptSafetyNet, verifySafetyNet } from "./src/safetyNet.js"; 13 | 14 | // get environment variables 15 | import "dotenv/config"; 16 | function dieEnv(variable) { 17 | console.log("Environment variable not set: " + variable); 18 | process.exit(1); 19 | } 20 | const googleCredentials = process.env.GOOGLE_APPLICATION_CREDENTIALS; 21 | export const packageName = process.env.PACKAGE_NAME; 22 | export const encodedDecryptionKey = 23 | process.env.BASE64_OF_ENCODED_DECRYPTION_KEY; 24 | export const encodedVerificationKey = 25 | process.env.BASE64_OF_ENCODED_VERIFICATION_KEY; 26 | 27 | if (!packageName) dieEnv("PACKAGE_NAME"); 28 | if (!googleCredentials) dieEnv("GOOGLE_APPLICATION_CREDENTIALS"); 29 | if (!encodedDecryptionKey) dieEnv("BASE64_OF_ENCODED_DECRYPTION_KEY"); 30 | if (!encodedVerificationKey) dieEnv("BASE64_OF_ENCODED_VERIFICATION_KEY"); 31 | 32 | export const privatekey = JSON.parse(googleCredentials); 33 | 34 | import { google } from "googleapis"; 35 | export const playintegrity = google.playintegrity("v1"); 36 | 37 | function dieConf(variable) { 38 | console.log("Configuration variable not set: " + variable); 39 | process.exit(1); 40 | } 41 | 42 | // import config variables 43 | import config from "./config.json" assert { type: "json" }; 44 | var certificates = config.validCertificateSha256Digest; 45 | if (!certificates) { 46 | console.log("Configuration variable not set: validCertificateSha256Digest"); 47 | process.exit(1); 48 | } 49 | if ( 50 | !Array.isArray(certificates) || 51 | !typeof certificates[0] === "string" || 52 | !certificates[0] instanceof String 53 | ) { 54 | console.log( 55 | "Configuration variable validCertificateSha256Digest has to be an array of strings" 56 | ); 57 | process.exit(1); 58 | } 59 | if (!config.errorLevel) dieConf("errorLevel"); 60 | export var validCertificateSha256Digest = certificates; 61 | 62 | export const errorLevel = config.errorLevel; 63 | 64 | /** 65 | * Global variables: counter and nonce list 66 | */ 67 | var counter = 0; 68 | export function count() { 69 | return counter++; 70 | } 71 | let nonce_list = []; 72 | let old_nonce_list = []; 73 | 74 | /** 75 | * Express JS Server 76 | */ 77 | app.listen(PORT, () => 78 | console.log( 79 | "Play Integrity Server Implementation is alive on http://localhost:" + PORT 80 | ) 81 | ); 82 | 83 | /** 84 | * Playintegrity Nonce Generation Endpoint. 85 | */ 86 | app.get("/api/playintegrity/nonce", (req, res) => { 87 | const nonce = generateNonce(50); 88 | nonce_list.push(nonce); 89 | logEvent(`INFO`, `Play Integrity Generated Nonce`, nonce); // nonce.slice(0, 5)+"..."+nonce.slice(-5) 90 | const nonce_base64 = Buffer.from(nonce) 91 | .toString("base64") 92 | .replace(/\+/g, "-") // Convert '+' to '-' 93 | .replace(/\//g, "_") // Convert '/' to '_' 94 | .replace(/=+$/, ""); // Remove ending '=' 95 | res.status(200).send(nonce_base64); 96 | return; 97 | }); 98 | 99 | /** 100 | * Play Integrity check Endpoint. 101 | * 'token' is the token the clinet received from the PlayIntegrity Server in the previous step 102 | * 'mode' is optional and defaults to 'server'. Can be set to 'google' as well. 103 | * 'nonce' is optional and defaults to 'server'. Can be set to 'device' when nonce got generated on the device and shouldn't be evaluated on the server. 104 | */ 105 | app.get("/api/playintegrity/check", async (req, res) => { 106 | const token = req.query.token ?? "none"; 107 | const mode = req.query.mode ?? "google"; 108 | const checkNonce = req.query.nonce ?? "server"; 109 | 110 | // check if token is provided 111 | if (token == "none") { 112 | res.status(400).send({ Error: "No token was provided" }); 113 | return; 114 | } 115 | 116 | // get decrypted token 117 | var decryptedToken = await decryptPlayIntegrity(token, mode, res); 118 | 119 | // send decoded and verified token 120 | if ( 121 | verifyPlayIntegrity( 122 | decryptedToken, 123 | checkNonce, 124 | nonce_list, 125 | old_nonce_list, 126 | res 127 | ) 128 | ) 129 | res.status(200).send(decryptedToken); 130 | return; 131 | }); 132 | 133 | /** 134 | * Safety Net nonce generation endpoint. 135 | */ 136 | app.get("/api/safetynet/nonce", (req, res) => { 137 | const nonce = generateNonce(50); 138 | nonce_list.push(nonce); 139 | logEvent(`INFO`, `SafetyNet Generated Nonce`, nonce); // nonce.slice(0, 5)+"..."+nonce.slice(-5) 140 | res.status(200).send(nonce); 141 | return; 142 | }); 143 | 144 | /** 145 | * Safetynet api endpoint. 'token' is the token the client received from playintegrity server. 146 | * 'nonce' is optional and defaults to 'server'. Can be set to 'device' when nonce got generated on the device and shouldn't be evaluated on the server. 147 | */ 148 | app.get("/api/safetynet/check", async (req, res) => { 149 | const token = req.query.token ?? "none"; 150 | const checkNonce = req.query.nonce ?? "server"; 151 | 152 | // check if token is provided 153 | if (token == "none") { 154 | res.status(400).send({ Error: "No token was provided" }); 155 | return; 156 | } 157 | 158 | // get decrypted token 159 | const decryptedToken = await decryptSafetyNet(token); 160 | 161 | // send decoded and verified token 162 | if ( 163 | verifySafetyNet(decryptedToken, checkNonce, nonce_list, old_nonce_list, res) 164 | ) 165 | res.status(200).send(decryptedToken); 166 | 167 | return; 168 | }); 169 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Simple Play Integrity Checker Server Component 2 | 3 |

4 | 5 | 6 | 7 |

8 | 9 | Server component for SPIC - Simple Play Integrity Checker which receives the encrypted json verdicts, decrypts and verifies them locally on the server or sends them to a Google API for decryption and verification and sends the response back to the client. It is also used for nonce generation as the initial step of attestation. 10 | 11 | # Disclaimer 12 | If you plan on using the Play Integrity / SafetyNet Attestation API in your own app, you should propably use a encrypted connection between the server and the client. Local checks on the Android Devices shouldn't be implemented either. Ideally you should pair this API with another authentication method. Be warned: This implementation is just a proof of concept! 13 | # Setup 14 | 15 | This server is written in JavaScript using the node package manager. first run `npm install` to install all necessary dependencies. Next you should define the follwing environment variables in a `.env` file at the root of the project: 16 | 17 | ``` 18 | PACKAGE_NAME= 19 | GOOGLE_APPLICATION_CREDENTIALS= 20 | BASE64_OF_ENCODED_DECRYPTION_KEY= 21 | BASE64_OF_ENCODED_VERIFICATION_KEY= 22 | ``` 23 | 24 | - `PACKAGE_NAME` android app package name 25 | - `GOOGLE_APPLICATION_CREDENTIALS` JSON contents of the service account from Google Cloud Project. Should be the samed linked to the play console where the android app is maintained (instructions to download the file: See **Set up a google cloud project** below) 26 | - `BASE64_OF_ENCODED_DECRYPTION_KEY` playIntegrity decryption key which can be obtained from the Google Play Console 27 | - `BASE64_OF_ENCODED_VERIFICATION_KEY`playIntegrity verification key which can be obtained from the Google Play Console 28 | 29 | A `config.json` file should also be created at the root of the project wit the following entries set: 30 | 31 | ```json 32 | { 33 | "errorLevel": "log", 34 | "validCertificateSha256Digest": [ 35 | "CERTIFICATE1", 36 | "CERTIFICATE2", 37 | "..." 38 | ] 39 | } 40 | ``` 41 | - `errorLevel` defines the behaviour of the server if a request from an unsecure device is detected 42 | - `log`: only logs the invalid fields in the verdict and send the verdict back to the client as it is 43 | - `error`: also logs the invalid fields but returnes an error code to the client 44 | 45 | - `validCertificateSha256Digest` tells the server the known Sha256 Certificate so they can be checked against the ones found in the verdict from the client 46 | ## Set up a Google Play Console Project 47 | - Create a new Google Play Console Project 48 | - to obtain the decryption and verification key, navigate within th Google Play Console to **Release** -> **Setup** -> **AppIntegrity** -> **Response encryption** 49 | - click on **Change** and choose **Manage and download my response encryption keys**. 50 | - follow the instructions to create a private-public key pair in order to download the encrypted keys. 51 | 52 | ## Set up a Google Cloud Project 53 | - Create a new Google Cloud Project 54 | - within Google Play Console, link the new Google Cloud Project to it 55 | - Navigate to **APIs & Services** -> **Enabled APIs & Services** -> **Enable APIs & Services** and enable the Play Integrity API there 56 | - within the Play Integrity API page navigate to **Credentials** -> **Create Credentials** -> **Service Account**. Set a name there and leave the rest on default values 57 | - Navigate to **Keys** -> **Add Key** -> **Create New Key** 58 | Go to Keys -> Add Key -> Create new key. The json that downloads automactially is the json you need for the Environment Variable. 59 | 60 | After everything has been set up, run `npm run` to start the server. The server will listen on port 8080 by default. 61 | 62 | # Server Console Output 63 | The server will log any incoming requests and the validation it does on them. It will also log any errors that occur. 64 | 65 | Example of a valid SafetyNet Request: 66 | ``` 67 | 11/23/2022 9:13:33 PM [INFO] - (SafetyNet) Generated Nonce: 'KKRxe...uisUX' 68 | 11/23/2022 9:13:34 PM [INFO] - (SafetyNet) New Client Request (1) processed 69 | 11/23/2022 9:13:34 PM [INFO] - Correct Nonce: Correct nonce 'KKRxe...uisUX' received 70 | 11/23/2022 9:13:34 PM [INFO] - Attestation: Using BASIC,HARDWARE_BACKED to evaluate device integrity 71 | 11/23/2022 9:13:34 PM [INFO] - Attestation: SafetyNet Checks passed 72 | ``` 73 | 74 | Example of an invalid PlayIntegrity Request: 75 | ``` 76 | 11/23/2022 7:45:22 PM [INFO] - (Play Integrity) Generated Nonce: 'bzZYN...p5TGo' 77 | 11/23/2022 7:45:24 PM [INFO] - (PlayIntegrity) New Client Request (0) processed 78 | 11/23/2022 7:45:22 PM [INFO] - Correct Nonce: Correct nonce 'bzZYN...p5TGo' received 79 | 11/23/2022 7:45:22 PM [INFO] - Attestation: Attested Device has valid requestDetails 80 | 11/23/2022 7:45:22 PM [WARNING] - Parsing: appRecognitionVerdict is UNEVALUATED. 81 | 11/23/2022 7:45:22 PM [WARNING] - Parsing: Package name is missing 82 | 11/23/2022 7:45:22 PM [WARNING] - Parsing: CertificateSha256Digest is missing 83 | 11/23/2022 7:45:22 PM [WARNING] - Parsing: Attested Device does not meet requirements: deviceRecognitionVerdict field is empty 84 | 11/23/2022 7:45:22 PM [WARNING] - Parsing: appLicensingVerdict is UNEVALUATED 85 | 11/23/2022 7:45:22 PM [WARNING] - Attestation: PlayIntegrity Checks failed 86 | ``` 87 | 88 | # License 89 | MIT License 90 | 91 | ``` 92 | Copyright (c) 2023 Henrik Herzig 93 | 94 | Permission is hereby granted, free of charge, to any person obtaining a copy 95 | of this software and associated documentation files (the "Software"), to deal 96 | in the Software without restriction, including without limitation the rights 97 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 98 | copies of the Software, and to permit persons to whom the Software is 99 | furnished to do so, subject to the following conditions: 100 | 101 | The above copyright notice and this permission notice shall be included in all 102 | copies or substantial portions of the Software. 103 | 104 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 105 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 106 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 107 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 108 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 109 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 110 | SOFTWARE. 111 | ``` -------------------------------------------------------------------------------- /src/playIntegrity.js: -------------------------------------------------------------------------------- 1 | import * as jose from "jose"; 2 | import crypto from "crypto"; 3 | import { google } from "googleapis"; 4 | 5 | // module imports 6 | import { 7 | count, 8 | validCertificateSha256Digest, 9 | playintegrity, 10 | privatekey, 11 | packageName, 12 | encodedVerificationKey, 13 | encodedDecryptionKey 14 | } from "../index.js"; 15 | import { logEvent, isNonceValid, errorAndExit } from "./shared.js"; 16 | 17 | /** 18 | * 19 | * @param {String} token 20 | * @param {String} mode 21 | */ 22 | export async function decryptPlayIntegrity(token, mode, res) { 23 | if (mode == "server") { 24 | return await decryptPlayIntegrityServer(token); 25 | } else if (mode == "google") { 26 | return await decryptPlayIntegrityGoogle(token).catch((e) => { 27 | console.log(e); 28 | res 29 | .status(400) 30 | .send({ error: "A Google API error occured: " + e.message }); 31 | return; 32 | }); 33 | } else { 34 | logEvent( 35 | `WARNING`, 36 | `Unknown mode (Play Integrity)`, 37 | `unknown mode '${mode}' requested` 38 | ); 39 | res.status(400).send({ Error: `Unknown mode ${mode}` }); 40 | return; 41 | } 42 | } 43 | 44 | /** 45 | * decrypts the play integrity token on googles server with a google service account 46 | * @param {String} integrityToken 47 | * @returns 48 | */ 49 | async function decryptPlayIntegrityGoogle(integrityToken) { 50 | let jwtClient = new google.auth.JWT( 51 | privatekey.client_email, 52 | null, 53 | privatekey.private_key, 54 | ["https://www.googleapis.com/auth/playintegrity"] 55 | ); 56 | 57 | google.options({ auth: jwtClient }); 58 | 59 | const response = await playintegrity.v1.decodeIntegrityToken({ 60 | packageName: packageName, 61 | requestBody: { 62 | integrityToken: integrityToken, 63 | }, 64 | }); 65 | logEvent( 66 | `INFO`, 67 | `New Client Request (${count()}) processed`, 68 | JSON.stringify(response.data.tokenPayloadExternal) 69 | ); 70 | 71 | return response.data.tokenPayloadExternal; 72 | } 73 | 74 | /** 75 | * decrypts the play integrity token locally on the server 76 | * @param {String} token 77 | * @returns 78 | */ 79 | async function decryptPlayIntegrityServer(token) { 80 | const decryptionKey = Buffer.from(encodedDecryptionKey, "base64"); 81 | const { plaintext, protectedHeader } = await jose.compactDecrypt( 82 | token, 83 | decryptionKey 84 | ); 85 | const { payload, Header = protectedHeader } = await jose.compactVerify( 86 | plaintext, 87 | crypto.createPublicKey( 88 | "-----BEGIN PUBLIC KEY-----\n" + 89 | encodedVerificationKey + 90 | "\n-----END PUBLIC KEY-----" 91 | ) 92 | ); 93 | const payloadText = new TextDecoder().decode(payload); 94 | const payloadJson = JSON.parse(payloadText); 95 | logEvent( 96 | `INFO`, 97 | `(PlayIntegrity) New Client Request (${count()}) processed`, 98 | payloadJson 99 | ); 100 | return payloadJson; 101 | } 102 | 103 | export async function verifyPlayIntegrity( 104 | decryptedToken, 105 | checkNonce, 106 | nonce_list, 107 | old_nonce_list, 108 | res 109 | ) { 110 | /* requestDetails */ 111 | 112 | // check if requestDetails exists in decryptedToken 113 | var requestDetails = decryptedToken?.requestDetails 114 | if (requestDetails == null) { 115 | if (errorAndExit(res, `requestDetails not found in recieved token`)) 116 | return false; 117 | } else { 118 | var error = false; 119 | // check if nonce is valid, otherwise send error 120 | var nonce = Buffer.from(requestDetails?.nonce, "base64") 121 | .toString() 122 | .replace(/\+/g, "-") // Convert '+' to '-' 123 | .replace(/\//g, "_") // Convert '/' to '_' 124 | .replace(/=+$/, ""); // Remove ending '=' 125 | if ( 126 | checkNonce == "server" && 127 | !isNonceValid(nonce, nonce_list, old_nonce_list) 128 | ) { 129 | if (errorAndExit(res, `Invalid Nonce`)) return false; 130 | error = true; 131 | } 132 | 133 | // check request package name 134 | if (packageName != requestDetails?.requestPackageName) { 135 | if (errorAndExit(res, `Invalid package name`)) return false; 136 | error = true; 137 | } 138 | 139 | // check request isn't older than 10 seconds 140 | if (Date.now() - requestDetails?.timestampMs > 10000) { 141 | if (errorAndExit(res, `Request too old`)) return false; 142 | error = true; 143 | } 144 | 145 | // all checks successfull, log this in console 146 | if (!error) { 147 | logEvent( 148 | `INFO`, 149 | `Attestation`, 150 | `Attested Device has valid requestDetails` 151 | ); 152 | } 153 | } 154 | 155 | /* appIntegrity */ 156 | // check if appIntegrity exists in decryptedToken 157 | var appIntegrity = decryptedToken?.appIntegrity; 158 | if (appIntegrity == null) { 159 | if (errorAndExit(res, `appIntegrity not found in recieved token`)) 160 | return false; 161 | } else { 162 | var error = false; 163 | // check if appRecognitionVerdict is UNEVALUATED 164 | var appRecognitionVerdict = appIntegrity?.appRecognitionVerdict; 165 | if (appRecognitionVerdict != "PLAY_RECOGNIZED") { 166 | if ( 167 | errorAndExit(res, `appRecognitionVerdict is ${appRecognitionVerdict}.`) 168 | ) 169 | return false; 170 | error = true; 171 | } 172 | 173 | // check package name 174 | if (packageName != appIntegrity?.packageName) { 175 | if (errorAndExit(res, `Invalid package name`)) return false; 176 | error = true; 177 | } 178 | 179 | // check certificateSha256Digest 180 | if ( 181 | appIntegrity?.certificateSha256Digest == null || 182 | appIntegrity.certificateSha256Digest.some((e) => 183 | validCertificateSha256Digest.includes(e) 184 | ) 185 | ) { 186 | if (errorAndExit(res, `Invalid certificateSha256Digest`)) return false; 187 | error = true; 188 | } 189 | if (!error) { 190 | // all checks successfull, log this in console 191 | logEvent( 192 | `INFO`, 193 | `Attestation`, 194 | `Attested Device has valid requestDetails` 195 | ); 196 | } 197 | } 198 | 199 | var deviceIntegrity = decryptedToken?.deviceIntegrity; 200 | if (deviceIntegrity == null) { 201 | if (errorAndExit(res, `deviceIntegrity not found in recieved token`)) 202 | return false; 203 | } else { 204 | // check if deviceRecognitionVerdict is UNEVALUATED 205 | var deviceRecognitionVerdict = deviceIntegrity?.deviceRecognitionVerdict; 206 | if (deviceRecognitionVerdict?.includes("MEETS_VIRTUAL_INTEGRITY")){ 207 | if (errorAndExit(res, `Emulator got attested`)) return false; 208 | } else if ( 209 | deviceRecognitionVerdict?.includes("MEETS_DEVICE_INTEGRITY") || 210 | deviceRecognitionVerdict?.includes("MEETS_BASIC_INTEGRITY") || 211 | deviceRecognitionVerdict?.includes("MEETS_STRONG_INTEGRITY") 212 | ) { 213 | logEvent( 214 | `INFO`, 215 | `Attestation`, 216 | `Attested Device has valid deviceRecognitionVerdict: ${deviceRecognitionVerdict}` 217 | ); 218 | } else { 219 | if ( 220 | errorAndExit( 221 | res, 222 | `Attested Device doesn't meet requirements. deviceRecognitionVerdict field is empty` 223 | ) 224 | ) 225 | return false; 226 | } 227 | } 228 | 229 | var accountIntegrity = decryptedToken?.accountDetails; 230 | if (accountIntegrity == null) { 231 | if (errorAndExit(res, `accountIntegrity not found in recieved token`)) 232 | return false; 233 | } else { 234 | var appLicensingVerdict = accountIntegrity?.appLicensingVerdict; 235 | if (appLicensingVerdict != "LICENSED") { 236 | if (errorAndExit(res, `appLicensingVerdict is ${appLicensingVerdict}`)) 237 | return false; 238 | } else { 239 | logEvent( 240 | `INFO`, 241 | `Attestation`, 242 | `Attested Device uses an licensed version of the Android App` 243 | ); 244 | } 245 | } 246 | return true; 247 | } 248 | --------------------------------------------------------------------------------