-
Notifications
You must be signed in to change notification settings - Fork 2
feat: play integrity #79
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
15 commits
Select commit
Hold shift + click to select a range
574ba55
feat: play integrity
noahpodgurski 1a64397
minor fixes
noahpodgurski 10e3bda
fix tests if env MLPA_DEBUG = true
noahpodgurski fe6fd1a
add play integrity test
noahpodgurski 973d395
Merge branch 'main' into feat-play-integrity
noahpodgurski 8d601ef
update play_integrity_service_account_file name, add MLPA_DEBUG accep…
noahpodgurski 9f75be8
Merge branch 'main' into feat-play-integrity
noahpodgurski fd94c10
compare against actual expected hash
noahpodgurski 2feb087
fix tests
noahpodgurski 257a82d
readd args
noahpodgurski af395ef
readd args
noahpodgurski 23718e5
fix args
noahpodgurski 17f211f
rm package_name bad logic
noahpodgurski ba3d3f3
simplify authorize.py logic checks
noahpodgurski f0ccc99
increase access token default expiration to 1 hr
noahpodgurski File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or 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
This file contains hidden or 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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,117 @@ | ||
| """ | ||
| E2E Play Integrity flow against a running MLPA server. | ||
| Requires a real Play Integrity token and configured service account file on the server. | ||
| """ | ||
|
|
||
| import argparse | ||
| import json | ||
| import os | ||
| from typing import Optional | ||
|
|
||
| import httpx | ||
|
|
||
| from mlpa.core.config import env | ||
|
|
||
| DEFAULT_BASE_URL = f"http://0.0.0.0:{env.PORT or 8080}" | ||
| DEFAULT_SERVICE_TYPE = "ai" | ||
|
|
||
|
|
||
| def _print_json(payload: dict) -> None: | ||
| print(json.dumps(payload, indent=2)) | ||
|
|
||
|
|
||
| def _require_value(value: Optional[str], name: str) -> str: | ||
| if value: | ||
| return value | ||
| raise SystemExit(f"Missing required value for {name}.") | ||
|
|
||
|
|
||
| def run(args: argparse.Namespace) -> None: | ||
| integrity_token = _require_value( | ||
| args.integrity_token or os.getenv("MLPA_PLAY_INTEGRITY_TOKEN"), | ||
| "integrity_token", | ||
| ) | ||
| user_id = _require_value( | ||
| args.user_id or os.getenv("MLPA_PLAY_USER_ID"), | ||
| "user_id", | ||
| ) | ||
|
|
||
| verify_response = httpx.post( | ||
| f"{args.base_url}/verify/play", | ||
| json={"integrity_token": integrity_token, "user_id": user_id}, | ||
| timeout=args.timeout_s, | ||
| ) | ||
| verify_response.raise_for_status() | ||
| access_token = verify_response.json().get("access_token") | ||
| if not access_token: | ||
| raise SystemExit("No access_token returned from /verify/play.") | ||
|
|
||
| headers = { | ||
| "authorization": f"Bearer {access_token}", | ||
| "use-play-integrity": "true", | ||
| "service-type": args.service_type, | ||
| } | ||
| payload = { | ||
| "model": args.model or env.MODEL_NAME, | ||
| "messages": [{"role": "user", "content": args.message}], | ||
| "stream": args.stream, | ||
| } | ||
|
|
||
| if args.stream: | ||
| with httpx.stream( | ||
| "POST", | ||
| f"{args.base_url}/v1/chat/completions", | ||
| headers=headers, | ||
| json=payload, | ||
| timeout=args.timeout_s, | ||
| ) as response: | ||
| response.raise_for_status() | ||
| for line in response.iter_lines(): | ||
| if line: | ||
| print(line) | ||
| else: | ||
| response = httpx.post( | ||
| f"{args.base_url}/v1/chat/completions", | ||
| headers=headers, | ||
| json=payload, | ||
| timeout=args.timeout_s, | ||
| ) | ||
| response.raise_for_status() | ||
| _print_json(response.json()) | ||
|
|
||
|
|
||
| def build_parser() -> argparse.ArgumentParser: | ||
| parser = argparse.ArgumentParser( | ||
| description="E2E Play Integrity verification + chat completion." | ||
| ) | ||
| subparsers = parser.add_subparsers(dest="command") | ||
|
|
||
| run_parser = subparsers.add_parser("run", help="Verify and request a completion.") | ||
| run_parser.add_argument("--integrity-token", dest="integrity_token") | ||
| run_parser.add_argument("--user-id", dest="user_id") | ||
| run_parser.add_argument( | ||
| "--base-url", dest="base_url", default="http://localhost:8080" | ||
| ) | ||
| run_parser.add_argument("--timeout-s", dest="timeout_s", type=int, default=30) | ||
| run_parser.add_argument( | ||
| "--service-type", dest="service_type", default=DEFAULT_SERVICE_TYPE | ||
| ) | ||
| run_parser.add_argument("--model", dest="model") | ||
| run_parser.add_argument("--message", dest="message", default="What is 2+2?") | ||
| run_parser.add_argument("--stream", dest="stream", action="store_true") | ||
| run_parser.set_defaults(func=run) | ||
|
|
||
| return parser | ||
|
|
||
|
|
||
| def main() -> None: | ||
| parser = build_parser() | ||
| args = parser.parse_args() | ||
| if not getattr(args, "command", None): | ||
| parser.print_help() | ||
| raise SystemExit(2) | ||
| args.func(args) | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| main() |
This file contains hidden or 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
This file contains hidden or 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
This file contains hidden or 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
This file contains hidden or 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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| from mlpa.core.routers.play.play import router as play_router | ||
|
|
||
| __all__ = ["play_router"] |
This file contains hidden or 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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,108 @@ | ||
| import hashlib | ||
| from functools import lru_cache | ||
|
|
||
| import httpx | ||
| from fastapi import APIRouter, HTTPException | ||
| from fastapi.concurrency import run_in_threadpool | ||
| from google.auth.transport.requests import Request | ||
| from google.oauth2 import service_account | ||
| from pydantic import BaseModel | ||
|
|
||
| from mlpa.core.classes import PlayIntegrityRequest | ||
| from mlpa.core.config import env | ||
| from mlpa.core.http_client import get_http_client | ||
| from mlpa.core.utils import issue_mlpa_access_token, raise_and_log | ||
|
|
||
| router = APIRouter() | ||
|
|
||
| PLAY_INTEGRITY_SCOPE = "https://www.googleapis.com/auth/playintegrity" | ||
| ALLOWED_DEVICE_VERDICTS = { | ||
| "MEETS_DEVICE_INTEGRITY", | ||
| "MEETS_BASIC_INTEGRITY", | ||
| "MEETS_STRONG_INTEGRITY", | ||
| } | ||
|
|
||
|
|
||
| @lru_cache(maxsize=1) | ||
| def _get_service_account_credentials(): | ||
| return service_account.Credentials.from_service_account_file( | ||
| env.PLAY_INTEGRITY_SERVICE_ACCOUNT_FILE, | ||
| scopes=[PLAY_INTEGRITY_SCOPE], | ||
| ) | ||
|
|
||
|
|
||
| def _get_play_integrity_access_token() -> str: | ||
| credentials = _get_service_account_credentials() | ||
| if not credentials.valid: | ||
| credentials.refresh(Request()) | ||
| if not credentials.token: | ||
| raise HTTPException(status_code=500, detail="Failed to fetch access token") | ||
| return credentials.token | ||
|
|
||
|
|
||
| async def _decode_integrity_token(integrity_token: str) -> dict: | ||
| access_token = await run_in_threadpool(_get_play_integrity_access_token) | ||
| client = get_http_client() | ||
| try: | ||
| response = await client.post( | ||
| f"https://playintegrity.googleapis.com/v1/{env.PLAY_INTEGRITY_PACKAGE_NAME}:decodeIntegrityToken", | ||
| headers={ | ||
| "Authorization": f"Bearer {access_token}", | ||
| "Content-Type": "application/json", | ||
| }, | ||
| json={"integrity_token": integrity_token}, | ||
| timeout=env.PLAY_INTEGRITY_REQUEST_TIMEOUT_SECONDS, | ||
| ) | ||
| response.raise_for_status() | ||
| except httpx.HTTPStatusError as e: | ||
| raise_and_log(e, False, 401) | ||
| except Exception as e: | ||
| raise_and_log(e, False, 502, "Play Integrity validation service unavailable") | ||
| return response.json() | ||
|
|
||
|
|
||
| def _validate_integrity_payload(payload: dict, expected_hash: str) -> None: | ||
| request_details = payload.get("requestDetails", {}) | ||
|
ti3x marked this conversation as resolved.
|
||
| package_name = request_details.get("requestPackageName") | ||
| if package_name != env.PLAY_INTEGRITY_PACKAGE_NAME: | ||
| raise HTTPException(status_code=401, detail="Invalid package name") | ||
|
|
||
| token_request_hash = request_details.get("requestHash") | ||
| if token_request_hash != expected_hash: | ||
| raise HTTPException(status_code=401, detail="Invalid request hash") | ||
|
|
||
| app_integrity = payload.get("appIntegrity", {}) | ||
| acceptable_recognition_verdicts = [ | ||
| "PLAY_RECOGNIZED", | ||
| ] | ||
| if env.MLPA_DEBUG: | ||
| acceptable_recognition_verdicts.append("UNRECOGNIZED_VERSION") | ||
| if ( | ||
| app_integrity.get("appRecognitionVerdict") | ||
| not in acceptable_recognition_verdicts | ||
| ): | ||
| raise HTTPException(status_code=401, detail="App not recognized by Play") | ||
|
|
||
| device_integrity = payload.get("deviceIntegrity", {}) | ||
| device_verdicts = set(device_integrity.get("deviceRecognitionVerdict", [])) | ||
| if not device_verdicts.intersection(ALLOWED_DEVICE_VERDICTS): | ||
| raise HTTPException(status_code=401, detail="Device integrity check failed") | ||
|
|
||
|
|
||
| @router.post("/play", tags=["Play Integrity"]) | ||
| async def verify_play_integrity(payload: PlayIntegrityRequest): | ||
| decoded = await _decode_integrity_token(payload.integrity_token) | ||
| token_payload = decoded.get("tokenPayloadExternal") or decoded.get("tokenPayload") | ||
| if not token_payload: | ||
| raise HTTPException(status_code=401, detail="Invalid Play Integrity token") | ||
|
|
||
| expected_hash = hashlib.sha256(payload.user_id.encode("utf-8")).hexdigest() | ||
|
|
||
| _validate_integrity_payload(token_payload, expected_hash) | ||
|
|
||
| access_token = issue_mlpa_access_token(payload.user_id) | ||
| return { | ||
| "access_token": access_token, | ||
| "token_type": "Bearer", | ||
| "expires_in": env.MLPA_ACCESS_TOKEN_TTL_SECONDS, | ||
| } | ||
This file contains hidden or 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
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this allows empty value, but if it is empty, lower on https://github.com/Firefox-AI/MLPA/pull/79/changes#diff-b6cf33ca99c89749f7b57bcb0e80bf6fdc324b9f25577c2cb7af154baa0a62d9R37 may bypass the validation
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What do you mean?
play_user_idis only non null ifextract_user_from_play_integrity_jwtsucceedsThere was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@noahpodgurski so I was wondering if it's possible for play_user_id to be empty, 'cause if not, and it can't be invalid after extract_user_from_play_integrity_jwt succeeds, then maybe we don't need if play_user_id: on line 37 of authorize.py
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@noahpodgurski went over this with more analysis, think it's fine for now to handle the "" user_id situation: