## Summary - Mobile access is granted by `POST /api/finance/store/confirm/` after the app finishes a StoreKit or Play Billing purchase. - App Store signed transactions are checked against Apple Root CA G3. Play purchase tokens are checked with the Play Developer API. - The RevenueCat webhook no longer grants a plan. Stripe Checkout and the Stripe webhook stay for the website. - A purchase account token must match the signed-in user, and a transaction already linked to another account is rejected. Access ends when the verified period end passes unless the app confirms again. Client: ai_ml_operations/chat_web_app#122 ## Config - `APPLE_IAP_BUNDLE_ID` (default `ai.hesychia.chat`) - `GOOGLE_PLAY_PACKAGE_NAME` - `GOOGLE_PLAY_SERVICE_ACCOUNT_JSON` (JSON or a file path) - Plan `revenuecat_product_id` or `REVENUECAT_PRODUCT_PLAN_MAP` maps the store product to a plan ## Test plan - [x] `python manage.py test monetization.tests.test_store_purchases monetization.tests.test_revenuecat monetization.tests.test_plans_quotas` - [ ] Confirm an App Store sandbox transaction and a Play license-test purchase against a configured backend - [ ] Confirm a token already linked to another user is rejected - [ ] Confirm `GET /subscription/` returns `store_account_token` and flips a lapsed store sub to canceled Reviewed-on: #79
This commit was merged in pull request #79.
This commit is contained in:
1 parent
bb5c43a4e7
commit
4fc2b87bb4
14 files changed
+1141
-119
No files matched your search
@@ -247,6 +247,8 @@ def needs_checkout(user) -> bool:
|
||||
UserSubscription.Source.ADMIN,
|
||||
UserSubscription.Source.STRIPE,
|
||||
UserSubscription.Source.REVENUECAT,
|
||||
UserSubscription.Source.APP_STORE,
|
||||
UserSubscription.Source.PLAY_STORE,
|
||||
):
|
||||
return False
|
||||
return True
|
||||
@@ -457,8 +459,9 @@ def assign_plan_from_revenuecat(
|
||||
cancel_at_period_end: bool | None = None,
|
||||
current_period_end=None,
|
||||
keep_existing_plan_if_unknown: bool = False,
|
||||
source: str | None = None,
|
||||
) -> UserSubscription:
|
||||
"""Assign a catalog plan from a RevenueCat store purchase event."""
|
||||
"""Assign a catalog plan from a verified store purchase."""
|
||||
seed_subscription_plans(update_existing=False)
|
||||
existing = (
|
||||
UserSubscription.objects.filter(user=user).select_related("plan").first()
|
||||
@@ -493,7 +496,7 @@ def assign_plan_from_revenuecat(
|
||||
sub = assign_plan(
|
||||
user,
|
||||
plan=plan,
|
||||
source=UserSubscription.Source.REVENUECAT,
|
||||
source=source or UserSubscription.Source.REVENUECAT,
|
||||
status=status,
|
||||
revenuecat_original_transaction_id=revenuecat_original_transaction_id or "",
|
||||
log_auth_event=False,
|
||||
|
||||
@@ -0,0 +1,617 @@
|
||||
"""Verify App Store and Play Store purchases and grant entitlements.
|
||||
|
||||
The mobile app completes the purchase with StoreKit or Play Billing, then
|
||||
POSTs the signed transaction or purchase token. This module checks that
|
||||
payload with Apple or Google and writes the subscription ledger. Store
|
||||
webhooks are not accepted.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone as dt_timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import quote
|
||||
|
||||
import jwt
|
||||
import requests
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
from cryptography.hazmat.primitives.asymmetric import ec, padding
|
||||
from django.conf import settings
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
|
||||
from monetization.models import Invoice, Payment, UserSubscription
|
||||
from monetization.services.plans import (
|
||||
assign_plan_from_revenuecat,
|
||||
log_subscription_auth_event,
|
||||
resolve_plan_from_revenuecat_product,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# uuid5(NAMESPACE_URL, this name). Do not change; clients receive the result.
|
||||
_STORE_ACCOUNT_NAMESPACE = uuid.uuid5(
|
||||
uuid.NAMESPACE_URL,
|
||||
"https://chat.aimloperations.com/store-account",
|
||||
)
|
||||
_APP_STORE_CERT_OID = x509.ObjectIdentifier("1.2.840.113635.100.6.11.1")
|
||||
_PLAY_SCOPE = "https://www.googleapis.com/auth/androidpublisher"
|
||||
_BUNDLED_APPLE_ROOT = (
|
||||
Path(__file__).resolve().parent.parent / "certs" / "apple_root_g3.pem"
|
||||
)
|
||||
|
||||
_STORE_SOURCES = frozenset(
|
||||
{
|
||||
UserSubscription.Source.APP_STORE,
|
||||
UserSubscription.Source.PLAY_STORE,
|
||||
UserSubscription.Source.REVENUECAT,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class StoreNotConfiguredError(Exception):
|
||||
"""Apple or Google credentials required to verify a purchase are missing."""
|
||||
|
||||
|
||||
class StorePurchaseRejected(Exception):
|
||||
"""The store payload is not an active purchase for this user."""
|
||||
|
||||
|
||||
class StoreVerificationError(Exception):
|
||||
"""The store API could not be reached."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VerifiedStorePurchase:
|
||||
source: str
|
||||
store_label: str
|
||||
product_id: str
|
||||
transaction_id: str
|
||||
original_transaction_id: str
|
||||
expires_at: datetime | None
|
||||
purchased_at: datetime | None
|
||||
cancel_at_period_end: bool
|
||||
status: str
|
||||
price_cents: int
|
||||
currency: str
|
||||
account_token: str
|
||||
|
||||
|
||||
def store_account_token(user_id: int) -> str:
|
||||
"""Stable store account id for this user. Pass it to StoreKit and Play."""
|
||||
return str(uuid.uuid5(_STORE_ACCOUNT_NAMESPACE, str(user_id)))
|
||||
|
||||
|
||||
def expire_lapsed_store_subscription(sub: UserSubscription | None):
|
||||
"""End store access once the verified period has passed.
|
||||
|
||||
Renewals arrive when the app confirms the store entitlement again.
|
||||
"""
|
||||
if sub is None or sub.source not in _STORE_SOURCES:
|
||||
return sub
|
||||
if sub.status != UserSubscription.Status.ACTIVE or not sub.current_period_end:
|
||||
return sub
|
||||
if sub.current_period_end > timezone.now():
|
||||
return sub
|
||||
prev_status = sub.status
|
||||
sub.status = UserSubscription.Status.CANCELED
|
||||
sub.cancel_at_period_end = False
|
||||
sub.save(update_fields=["status", "cancel_at_period_end", "last_modified"])
|
||||
if prev_status != UserSubscription.Status.CANCELED:
|
||||
log_subscription_auth_event(
|
||||
sub.user,
|
||||
started=False,
|
||||
detail=(
|
||||
f"plan={sub.plan.slug if sub.plan_id else 'none'} "
|
||||
f"source={sub.source} status={sub.status} "
|
||||
"reason=store_period_ended"
|
||||
),
|
||||
)
|
||||
return sub
|
||||
|
||||
|
||||
def confirm_store_purchase(user, payload: dict[str, Any]) -> UserSubscription:
|
||||
"""Verify one store purchase for the authenticated user and grant it."""
|
||||
store = (payload.get("store") or "").strip().lower()
|
||||
product_id = (payload.get("product_id") or "").strip()
|
||||
if store == UserSubscription.Source.APP_STORE:
|
||||
verified = verify_apple_signed_transaction(
|
||||
signed_transaction=(payload.get("signed_transaction") or "").strip(),
|
||||
product_id=product_id,
|
||||
transaction_id=(payload.get("transaction_id") or "").strip(),
|
||||
)
|
||||
elif store == UserSubscription.Source.PLAY_STORE:
|
||||
verified = verify_google_subscription(
|
||||
product_id=product_id,
|
||||
purchase_token=(payload.get("purchase_token") or "").strip(),
|
||||
transaction_id=(payload.get("transaction_id") or "").strip(),
|
||||
)
|
||||
else:
|
||||
raise StorePurchaseRejected("store must be app_store or play_store.")
|
||||
if verified.product_id != product_id:
|
||||
raise StorePurchaseRejected("Store product does not match this purchase.")
|
||||
return grant_verified_store_purchase(user, verified)
|
||||
|
||||
|
||||
def verify_apple_signed_transaction(
|
||||
*,
|
||||
signed_transaction: str,
|
||||
product_id: str,
|
||||
transaction_id: str = "",
|
||||
) -> VerifiedStorePurchase:
|
||||
"""Verify a StoreKit 2 signed transaction against Apple's root."""
|
||||
if not signed_transaction:
|
||||
raise StorePurchaseRejected("App Store purchases require signed_transaction.")
|
||||
bundle_id = (getattr(settings, "APPLE_IAP_BUNDLE_ID", "") or "").strip()
|
||||
if not bundle_id:
|
||||
raise StoreNotConfiguredError("APPLE_IAP_BUNDLE_ID is not configured.")
|
||||
roots = _load_apple_roots()
|
||||
if not roots:
|
||||
raise StoreNotConfiguredError("No Apple root certificate is configured.")
|
||||
|
||||
try:
|
||||
header = jwt.get_unverified_header(signed_transaction)
|
||||
except jwt.PyJWTError as exc:
|
||||
raise StorePurchaseRejected("Invalid App Store signed transaction.") from exc
|
||||
x5c = header.get("x5c") if isinstance(header, dict) else None
|
||||
if not isinstance(x5c, list) or not x5c:
|
||||
raise StorePurchaseRejected("App Store transaction is missing its certificate.")
|
||||
try:
|
||||
certs = [_load_der_cert(item) for item in x5c]
|
||||
leaf = _trusted_leaf(certs, roots)
|
||||
except StorePurchaseRejected:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise StorePurchaseRejected("App Store certificate chain is not trusted.") from exc
|
||||
_require_app_store_oid(leaf)
|
||||
|
||||
try:
|
||||
claims = jwt.decode(
|
||||
signed_transaction,
|
||||
leaf.public_key(),
|
||||
algorithms=["ES256"],
|
||||
options={"verify_aud": False, "verify_exp": False},
|
||||
)
|
||||
except jwt.PyJWTError as exc:
|
||||
raise StorePurchaseRejected("App Store transaction signature is invalid.") from exc
|
||||
if not isinstance(claims, dict):
|
||||
raise StorePurchaseRejected("App Store transaction payload is invalid.")
|
||||
|
||||
claim_bundle = str(claims.get("bundleId") or "")
|
||||
if claim_bundle != bundle_id:
|
||||
raise StorePurchaseRejected("App Store bundle id does not match this app.")
|
||||
claim_product = str(claims.get("productId") or "")
|
||||
if product_id and claim_product != product_id:
|
||||
raise StorePurchaseRejected("App Store product does not match this purchase.")
|
||||
claim_txn = str(claims.get("transactionId") or "")
|
||||
if transaction_id and claim_txn and transaction_id != claim_txn:
|
||||
raise StorePurchaseRejected("App Store transaction id does not match.")
|
||||
if not claim_txn:
|
||||
raise StorePurchaseRejected("App Store transaction id is missing.")
|
||||
|
||||
expires_at = _ms_to_dt(claims.get("expiresDate"))
|
||||
purchased_at = _ms_to_dt(claims.get("purchaseDate"))
|
||||
revoked = _ms_to_dt(claims.get("revocationDate")) is not None
|
||||
now = timezone.now()
|
||||
if revoked or (expires_at is not None and expires_at <= now):
|
||||
status = UserSubscription.Status.CANCELED
|
||||
else:
|
||||
status = UserSubscription.Status.ACTIVE
|
||||
|
||||
price_cents = 0
|
||||
raw_price = claims.get("price")
|
||||
try:
|
||||
if raw_price is not None:
|
||||
price_cents = max(0, int(raw_price) // 10)
|
||||
except (TypeError, ValueError):
|
||||
price_cents = 0
|
||||
currency = str(claims.get("currency") or "usd").lower()
|
||||
original = str(claims.get("originalTransactionId") or claim_txn)
|
||||
return VerifiedStorePurchase(
|
||||
source=UserSubscription.Source.APP_STORE,
|
||||
store_label="APP_STORE",
|
||||
product_id=claim_product,
|
||||
transaction_id=claim_txn,
|
||||
original_transaction_id=original,
|
||||
expires_at=expires_at,
|
||||
purchased_at=purchased_at,
|
||||
cancel_at_period_end=False,
|
||||
status=status,
|
||||
price_cents=price_cents,
|
||||
currency=currency,
|
||||
account_token=str(claims.get("appAccountToken") or ""),
|
||||
)
|
||||
|
||||
|
||||
def verify_google_subscription(
|
||||
*,
|
||||
product_id: str,
|
||||
purchase_token: str,
|
||||
transaction_id: str = "",
|
||||
) -> VerifiedStorePurchase:
|
||||
"""Verify a Play Billing purchase token with the Android Publisher API."""
|
||||
if not purchase_token:
|
||||
raise StorePurchaseRejected("Play Store purchases require purchase_token.")
|
||||
if not product_id:
|
||||
raise StorePurchaseRejected("Play Store purchases require product_id.")
|
||||
body = fetch_google_subscription(purchase_token)
|
||||
return _verified_from_google(
|
||||
body,
|
||||
product_id=product_id,
|
||||
purchase_token=purchase_token,
|
||||
transaction_id=transaction_id,
|
||||
)
|
||||
|
||||
|
||||
def fetch_google_subscription(purchase_token: str) -> dict[str, Any]:
|
||||
"""GET purchases.subscriptionsv2 for this token. Tests patch this."""
|
||||
package_name = (getattr(settings, "GOOGLE_PLAY_PACKAGE_NAME", "") or "").strip()
|
||||
raw = (getattr(settings, "GOOGLE_PLAY_SERVICE_ACCOUNT_JSON", "") or "").strip()
|
||||
if not package_name or not raw:
|
||||
raise StoreNotConfiguredError(
|
||||
"Google Play verification is not configured "
|
||||
"(GOOGLE_PLAY_PACKAGE_NAME and GOOGLE_PLAY_SERVICE_ACCOUNT_JSON)."
|
||||
)
|
||||
info = _load_service_account_info(raw)
|
||||
try:
|
||||
access_token = _google_access_token(info)
|
||||
url = (
|
||||
"https://androidpublisher.googleapis.com/androidpublisher/v3/"
|
||||
f"applications/{quote(package_name, safe='')}/purchases/"
|
||||
f"subscriptionsv2/tokens/{quote(purchase_token, safe='')}"
|
||||
)
|
||||
response = requests.get(
|
||||
url,
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
timeout=15,
|
||||
)
|
||||
except (requests.RequestException, ValueError, OSError) as exc:
|
||||
logger.exception("Play Store verification request failed")
|
||||
raise StoreVerificationError("Play Store verification failed.") from exc
|
||||
if response.status_code == 404:
|
||||
raise StorePurchaseRejected("Play Store purchase was not found.")
|
||||
if response.status_code >= 400:
|
||||
logger.error(
|
||||
"Play Store verification HTTP %s",
|
||||
response.status_code,
|
||||
)
|
||||
if response.status_code >= 500:
|
||||
raise StoreVerificationError("Play Store verification failed.")
|
||||
raise StorePurchaseRejected("Play Store rejected this purchase token.")
|
||||
try:
|
||||
body = response.json()
|
||||
except ValueError as exc:
|
||||
raise StoreVerificationError("Play Store returned an invalid response.") from exc
|
||||
if not isinstance(body, dict):
|
||||
raise StoreVerificationError("Play Store returned an invalid response.")
|
||||
return body
|
||||
|
||||
|
||||
def _verified_from_google(
|
||||
body: dict[str, Any],
|
||||
*,
|
||||
product_id: str,
|
||||
purchase_token: str,
|
||||
transaction_id: str,
|
||||
) -> VerifiedStorePurchase:
|
||||
line_items = body.get("lineItems") or []
|
||||
match = None
|
||||
if isinstance(line_items, list):
|
||||
for item in line_items:
|
||||
if isinstance(item, dict) and str(item.get("productId") or "") == product_id:
|
||||
match = item
|
||||
break
|
||||
if match is None:
|
||||
raise StorePurchaseRejected("Play Store product does not match this purchase.")
|
||||
|
||||
state = str(body.get("subscriptionState") or "")
|
||||
expires_at = _parse_rfc3339(match.get("expiryTime"))
|
||||
now = timezone.now()
|
||||
expired = expires_at is not None and expires_at <= now
|
||||
cancel_at_period_end = False
|
||||
if state in {"SUBSCRIPTION_STATE_PENDING", "SUBSCRIPTION_STATE_PENDING_PURCHASE_CANCELED"}:
|
||||
raise StorePurchaseRejected("Play Store purchase is still pending.")
|
||||
if state in {"SUBSCRIPTION_STATE_ACTIVE", "SUBSCRIPTION_STATE_IN_GRACE_PERIOD"} and not expired:
|
||||
status = UserSubscription.Status.ACTIVE
|
||||
elif state == "SUBSCRIPTION_STATE_CANCELED" and not expired:
|
||||
status = UserSubscription.Status.ACTIVE
|
||||
cancel_at_period_end = True
|
||||
elif state == "SUBSCRIPTION_STATE_ON_HOLD":
|
||||
status = UserSubscription.Status.PAST_DUE
|
||||
else:
|
||||
status = UserSubscription.Status.CANCELED
|
||||
|
||||
auto_renew = (match.get("autoRenewingPlan") or {}) if isinstance(match, dict) else {}
|
||||
if isinstance(auto_renew, dict) and auto_renew.get("autoRenewEnabled") is False and not expired:
|
||||
if status == UserSubscription.Status.ACTIVE:
|
||||
cancel_at_period_end = True
|
||||
|
||||
order_id = str(match.get("latestSuccessfulOrderId") or transaction_id or purchase_token)
|
||||
identifiers = body.get("externalAccountIdentifiers") or {}
|
||||
account_token = ""
|
||||
if isinstance(identifiers, dict):
|
||||
account_token = str(identifiers.get("obfuscatedExternalAccountId") or "")
|
||||
price_cents, currency = _google_price(auto_renew if isinstance(auto_renew, dict) else {})
|
||||
return VerifiedStorePurchase(
|
||||
source=UserSubscription.Source.PLAY_STORE,
|
||||
store_label="PLAY_STORE",
|
||||
product_id=product_id,
|
||||
transaction_id=order_id,
|
||||
original_transaction_id=purchase_token,
|
||||
expires_at=expires_at,
|
||||
purchased_at=None,
|
||||
cancel_at_period_end=cancel_at_period_end,
|
||||
status=status,
|
||||
price_cents=price_cents,
|
||||
currency=currency,
|
||||
account_token=account_token,
|
||||
)
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def grant_verified_store_purchase(user, verified: VerifiedStorePurchase) -> UserSubscription:
|
||||
plan = resolve_plan_from_revenuecat_product(verified.product_id)
|
||||
if plan is None:
|
||||
raise StorePurchaseRejected("This store product is not mapped to a plan.")
|
||||
|
||||
expected = store_account_token(user.pk)
|
||||
if verified.account_token and verified.account_token.lower() != expected.lower():
|
||||
raise StorePurchaseRejected("Purchase account token does not match this user.")
|
||||
|
||||
original = verified.original_transaction_id
|
||||
if original:
|
||||
taken = (
|
||||
UserSubscription.objects.select_for_update()
|
||||
.filter(revenuecat_original_transaction_id=original)
|
||||
.exclude(user=user)
|
||||
.exists()
|
||||
)
|
||||
if taken:
|
||||
raise StorePurchaseRejected(
|
||||
"This purchase is already linked to another account."
|
||||
)
|
||||
|
||||
sub = assign_plan_from_revenuecat(
|
||||
user,
|
||||
plan_slug=plan.slug,
|
||||
product_id=verified.product_id,
|
||||
revenuecat_original_transaction_id=original,
|
||||
status=verified.status,
|
||||
cancel_at_period_end=verified.cancel_at_period_end,
|
||||
current_period_end=verified.expires_at,
|
||||
keep_existing_plan_if_unknown=False,
|
||||
source=verified.source,
|
||||
)
|
||||
|
||||
if verified.status != UserSubscription.Status.ACTIVE:
|
||||
return sub
|
||||
|
||||
amount = verified.price_cents or plan.price_cents
|
||||
currency = (verified.currency or plan.currency or "usd").lower()
|
||||
event_id = f"{verified.source}:{verified.transaction_id}"
|
||||
invoice, _created = Invoice.objects.update_or_create(
|
||||
revenuecat_event_id=event_id,
|
||||
defaults={
|
||||
"user": user,
|
||||
"company": getattr(user, "company", None),
|
||||
"provider": verified.source,
|
||||
"status": Invoice.Status.PAID,
|
||||
"currency": currency,
|
||||
"amount_due": amount,
|
||||
"amount_paid": amount,
|
||||
"period_start": verified.purchased_at,
|
||||
"period_end": verified.expires_at,
|
||||
"revenuecat_store": verified.store_label,
|
||||
"description": f"{plan.name} — {verified.store_label}",
|
||||
"hosted_invoice_url": "",
|
||||
},
|
||||
)
|
||||
Payment.objects.update_or_create(
|
||||
revenuecat_transaction_id=verified.transaction_id,
|
||||
defaults={
|
||||
"user": user,
|
||||
"company": getattr(user, "company", None),
|
||||
"invoice": invoice,
|
||||
"provider": verified.source,
|
||||
"status": Payment.Status.SUCCEEDED,
|
||||
"currency": currency,
|
||||
"amount": amount,
|
||||
"paid_at": verified.purchased_at or timezone.now(),
|
||||
"failure_message": "",
|
||||
},
|
||||
)
|
||||
return sub
|
||||
|
||||
|
||||
def _load_apple_roots() -> list[x509.Certificate]:
|
||||
pems: list[str] = []
|
||||
extra = (getattr(settings, "APPLE_IAP_ROOT_PEM", "") or "").strip()
|
||||
if extra:
|
||||
pems.append(extra)
|
||||
if _BUNDLED_APPLE_ROOT.is_file():
|
||||
pems.append(_BUNDLED_APPLE_ROOT.read_text(encoding="utf-8"))
|
||||
roots: list[x509.Certificate] = []
|
||||
for pem in pems:
|
||||
try:
|
||||
roots.append(x509.load_pem_x509_certificate(pem.encode("utf-8")))
|
||||
except ValueError:
|
||||
logger.error("Ignoring an unreadable Apple root certificate")
|
||||
return roots
|
||||
|
||||
|
||||
def _load_der_cert(value: Any) -> x509.Certificate:
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise StorePurchaseRejected("App Store certificate is missing.")
|
||||
try:
|
||||
der = base64.b64decode(value)
|
||||
return x509.load_der_x509_certificate(der)
|
||||
except (ValueError, TypeError) as exc:
|
||||
raise StorePurchaseRejected("App Store certificate is invalid.") from exc
|
||||
|
||||
|
||||
def _trusted_leaf(
|
||||
certs: list[x509.Certificate],
|
||||
roots: list[x509.Certificate],
|
||||
) -> x509.Certificate:
|
||||
if not certs:
|
||||
raise StorePurchaseRejected("App Store certificate chain is empty.")
|
||||
now = datetime.now(dt_timezone.utc)
|
||||
for cert in certs:
|
||||
if cert.not_valid_before_utc > now or cert.not_valid_after_utc < now:
|
||||
raise StorePurchaseRejected("App Store certificate is not currently valid.")
|
||||
for index in range(len(certs) - 1):
|
||||
_verify_signed_by(certs[index], certs[index + 1])
|
||||
if certs[index].issuer != certs[index + 1].subject:
|
||||
raise StorePurchaseRejected("App Store certificate issuer mismatch.")
|
||||
last = certs[-1]
|
||||
root_fps = {root.fingerprint(hashes.SHA256()) for root in roots}
|
||||
if last.fingerprint(hashes.SHA256()) in root_fps:
|
||||
return certs[0]
|
||||
for root in roots:
|
||||
try:
|
||||
_verify_signed_by(last, root)
|
||||
except Exception:
|
||||
continue
|
||||
if last.issuer == root.subject:
|
||||
return certs[0]
|
||||
raise StorePurchaseRejected("App Store certificate chain is not trusted.")
|
||||
|
||||
|
||||
def _verify_signed_by(child: x509.Certificate, issuer: x509.Certificate) -> None:
|
||||
public_key = issuer.public_key()
|
||||
signature_hash = child.signature_hash_algorithm
|
||||
if signature_hash is None:
|
||||
raise StorePurchaseRejected("App Store certificate has no signature hash.")
|
||||
if isinstance(public_key, ec.EllipticCurvePublicKey):
|
||||
public_key.verify(
|
||||
child.signature,
|
||||
child.tbs_certificate_bytes,
|
||||
ec.ECDSA(signature_hash),
|
||||
)
|
||||
return
|
||||
public_key.verify(
|
||||
child.signature,
|
||||
child.tbs_certificate_bytes,
|
||||
padding.PKCS1v15(),
|
||||
signature_hash,
|
||||
)
|
||||
|
||||
|
||||
def _require_app_store_oid(leaf: x509.Certificate) -> None:
|
||||
try:
|
||||
leaf.extensions.get_extension_for_oid(_APP_STORE_CERT_OID)
|
||||
except x509.ExtensionNotFound as exc:
|
||||
raise StorePurchaseRejected(
|
||||
"Signed transaction certificate is not an App Store certificate."
|
||||
) from exc
|
||||
|
||||
|
||||
def _google_access_token(info: dict[str, Any]) -> str:
|
||||
"""OAuth JWT bearer grant for a Play Console service account."""
|
||||
client_email = info.get("client_email")
|
||||
private_key = info.get("private_key")
|
||||
token_uri = info.get("token_uri") or "https://oauth2.googleapis.com/token"
|
||||
if not client_email or not private_key:
|
||||
raise StoreNotConfiguredError(
|
||||
"GOOGLE_PLAY_SERVICE_ACCOUNT_JSON is missing client_email or private_key."
|
||||
)
|
||||
now = int(time.time())
|
||||
assertion = jwt.encode(
|
||||
{
|
||||
"iss": client_email,
|
||||
"scope": _PLAY_SCOPE,
|
||||
"aud": token_uri,
|
||||
"iat": now,
|
||||
"exp": now + 3600,
|
||||
},
|
||||
private_key,
|
||||
algorithm="RS256",
|
||||
)
|
||||
try:
|
||||
response = requests.post(
|
||||
token_uri,
|
||||
data={
|
||||
"grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer",
|
||||
"assertion": assertion,
|
||||
},
|
||||
timeout=15,
|
||||
)
|
||||
except requests.RequestException as exc:
|
||||
raise StoreVerificationError("Play Store verification failed.") from exc
|
||||
if response.status_code >= 400:
|
||||
raise StoreVerificationError("Play Store authentication failed.")
|
||||
try:
|
||||
token = response.json().get("access_token")
|
||||
except ValueError as exc:
|
||||
raise StoreVerificationError("Play Store authentication failed.") from exc
|
||||
if not token:
|
||||
raise StoreVerificationError("Play Store authentication failed.")
|
||||
return str(token)
|
||||
|
||||
|
||||
def _load_service_account_info(raw: str) -> dict[str, Any]:
|
||||
text = raw
|
||||
if not raw.lstrip().startswith("{"):
|
||||
path = Path(raw)
|
||||
if path.is_file():
|
||||
text = path.read_text(encoding="utf-8")
|
||||
try:
|
||||
info = json.loads(text)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise StoreNotConfiguredError(
|
||||
"GOOGLE_PLAY_SERVICE_ACCOUNT_JSON is not valid JSON."
|
||||
) from exc
|
||||
if not isinstance(info, dict):
|
||||
raise StoreNotConfiguredError(
|
||||
"GOOGLE_PLAY_SERVICE_ACCOUNT_JSON must be a JSON object."
|
||||
)
|
||||
return info
|
||||
|
||||
|
||||
def _ms_to_dt(value: Any) -> datetime | None:
|
||||
if value is None or value == "":
|
||||
return None
|
||||
try:
|
||||
ms = int(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if ms <= 0:
|
||||
return None
|
||||
return datetime.fromtimestamp(ms / 1000.0, tz=dt_timezone.utc)
|
||||
|
||||
|
||||
def _parse_rfc3339(value: Any) -> datetime | None:
|
||||
if not value or not isinstance(value, str):
|
||||
return None
|
||||
try:
|
||||
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
return None
|
||||
if parsed.tzinfo is None:
|
||||
return parsed.replace(tzinfo=dt_timezone.utc)
|
||||
return parsed
|
||||
|
||||
|
||||
def _google_price(auto_renew: dict[str, Any]) -> tuple[int, str]:
|
||||
price = auto_renew.get("recurringPrice") if isinstance(auto_renew, dict) else None
|
||||
if not isinstance(price, dict):
|
||||
return 0, "usd"
|
||||
currency = str(price.get("currencyCode") or "usd").lower()
|
||||
try:
|
||||
units = int(price.get("units") or 0)
|
||||
except (TypeError, ValueError):
|
||||
units = 0
|
||||
try:
|
||||
nanos = int(price.get("nanos") or 0)
|
||||
except (TypeError, ValueError):
|
||||
nanos = 0
|
||||
cents = units * 100 + nanos // 10_000_000
|
||||
return max(0, cents), currency
|
||||
Reference in new issue
Block a user