Skip to content

Commit 803992c

Browse files
author
Neo
committed
[phase-3] monetization server: /api/v1/iap/verify + ASN V2 webhook + entitlement primitive
- gateway/iap.py: full JWS x5c chain validation to the PINNED Apple Root CA - G3 (bundled PEM) — signature links, validity windows, basic constraints, leaf marker OID (default ON), alg pinned to ES256 before any crypto. Tier truth lives ONLY in the server's product map: the redos consumable and unknown ids never map to a tier. - runtime/monetization/entitlement_store.py: iap_transactions idempotency ledger (replay of the same signed transaction records nothing) + iap_entitlements per subscription lineage. active_tier_for() is the entitlement primitive — built, gating nothing yet. - routes: POST /api/v1/iap/verify (subscriptions AND the redos consumable; optional session binding via wallet-session token) and POST /api/v1/iap/asn (SUBSCRIBED/DID_RENEW extend, EXPIRED expires, REFUND flips the row + marks the ledger, REVOKE revokes; the nested signedTransactionInfo is verified independently before any write). Both public-path exempt: their auth IS the Apple-signed JWS chain. - Fail-closed: iap.bundle_id or roots unset -> 503; any verification failure -> 401 generic. Never a granted tier. - tests/test_iap_verify.py: 31 tests on a self-signed fixture chain — wrong-bundle, forged chain, wrong-key, alg none/HS256, CA-leaf, tampered payload, replay idempotency, refund flips the row, ASN spoof/renew/expire, unconfigured 503. Suite: 578 passed.
1 parent fba1a7a commit 803992c

7 files changed

Lines changed: 1225 additions & 0 deletions

File tree

CREDENTIALS_NEEDED.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,14 @@ Set under `services.<name>.*` in `openmatrix.config.json`. Each service returns
105105
| `auth.apple.bundle_id` (`com.opnmatrx.mtrx`) | `openmatrix.config.json` | **Required** for `POST /api/v1/auth/apple` — the identity-token audience check. Unset → route fails closed (503). |
106106
| `auth.apple.team_id` + `key_id` + `private_key_p8` (Sign in with Apple key) | `openmatrix.config.json` / secret | Token **revocation** on account deletion (`DELETE /api/v1/auth/account`). App Review requires working deletion once server accounts are live. Unconfigured → local data still deleted, Apple revocation skipped with a WARNING. |
107107

108+
## 9. IAP verification — monetization server (Phase 3)
109+
110+
| Credential | Where | Unlocks |
111+
|---|---|---|
112+
| `iap.bundle_id` (`com.opnmatrx.mtrx`) | `openmatrix.config.json` | **Required** for `POST /api/v1/iap/verify` + `POST /api/v1/iap/asn` — the signed-transaction bundle check. Unset → both routes fail closed (503). |
113+
| `iap.environment` (`Production` or `Sandbox`) | `openmatrix.config.json` | Optional: restricts accepted payloads to one App Store environment. Unset → both accepted (each row records its environment). |
114+
| ASN V2 webhook URL registered in App Store Connect → `https://<gateway>/api/v1/iap/asn` | App Store Connect → App Information | Renewal/expiry/refund/revoke flips reaching the server. No shared secret — the webhook authenticates by its Apple-signed JWS chain (pinned root at `gateway/certs/AppleRootCA-G3.pem`). |
115+
108116
## 7. Per-component contract addresses (app) — fill AFTER `deploy_all.py`
109117

110118
`scripts/deploy_all.py` deploys the platform contracts and writes

gateway/certs/AppleRootCA-G3.pem

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
-----BEGIN CERTIFICATE-----
2+
MIICQzCCAcmgAwIBAgIILcX8iNLFS5UwCgYIKoZIzj0EAwMwZzEbMBkGA1UEAwwS
3+
QXBwbGUgUm9vdCBDQSAtIEczMSYwJAYDVQQLDB1BcHBsZSBDZXJ0aWZpY2F0aW9u
4+
IEF1dGhvcml0eTETMBEGA1UECgwKQXBwbGUgSW5jLjELMAkGA1UEBhMCVVMwHhcN
5+
MTQwNDMwMTgxOTA2WhcNMzkwNDMwMTgxOTA2WjBnMRswGQYDVQQDDBJBcHBsZSBS
6+
b290IENBIC0gRzMxJjAkBgNVBAsMHUFwcGxlIENlcnRpZmljYXRpb24gQXV0aG9y
7+
aXR5MRMwEQYDVQQKDApBcHBsZSBJbmMuMQswCQYDVQQGEwJVUzB2MBAGByqGSM49
8+
AgEGBSuBBAAiA2IABJjpLz1AcqTtkyJygRMc3RCV8cWjTnHcFBbZDuWmBSp3ZHtf
9+
TjjTuxxEtX/1H7YyYl3J6YRbTzBPEVoA/VhYDKX1DyxNB0cTddqXl5dvMVztK517
10+
IDvYuVTZXpmkOlEKMaNCMEAwHQYDVR0OBBYEFLuw3qFYM4iapIqZ3r6966/ayySr
11+
MA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMAoGCCqGSM49BAMDA2gA
12+
MGUCMQCD6cHEFl4aXTQY2e3v9GwOAEZLuN+yRhHFD/3meoyhpmvOwgPUnPWTxnS4
13+
at+qIxUCMG1mihDK1A3UT82NQz60imOlM27jbdoXt2QfyFMm+YhidDkLF1vLUagM
14+
6BgD56KyKA==
15+
-----END CERTIFICATE-----

gateway/iap.py

Lines changed: 318 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,318 @@
1+
"""Phase 3: App Store server-side IAP verification (monetization server).
2+
3+
Verifies App Store Server JWS payloads — the ``signedTransaction`` the app
4+
ships after a purchase, and the ``signedPayload`` App Store Server
5+
Notifications V2 posts to our webhook. Verification is the full chain:
6+
7+
1. The JWS header's ``x5c`` chain must terminate at a PINNED trusted root
8+
(Apple Root CA - G3, bundled at ``gateway/certs/AppleRootCA-G3.pem``);
9+
every link's signature, validity window, and basic constraints are checked.
10+
2. The JWS signature itself must verify against the leaf certificate's
11+
P-256 key, with the algorithm pinned to ES256 (alg-confusion rejected).
12+
3. The decoded payload's ``bundleId`` must equal the configured bundle id.
13+
14+
Trust comes ONLY from the pinned root — the x5c chain is attacker-supplied
15+
bytes until it is proven to terminate there. Tier decisions come ONLY from
16+
our own product map (``classify_product``): a payload can never name its own
17+
tier, and unknown/consumable product ids never map to one.
18+
19+
Fail-closed: no ``iap.bundle_id`` or no trusted roots -> IAPNotConfigured
20+
(the routes answer 503) — never a verified claim, never a granted tier.
21+
"""
22+
23+
from __future__ import annotations
24+
25+
import base64
26+
import binascii
27+
import json
28+
import logging
29+
import os
30+
import time
31+
from typing import Any
32+
33+
logger = logging.getLogger(__name__)
34+
35+
# Apple marker OID present on App Store receipt/transaction signing leaves.
36+
APPLE_LEAF_MARKER_OID = "1.2.840.113635.100.6.11.1"
37+
38+
_BUNDLED_ROOT_PATH = os.path.join(os.path.dirname(__file__), "certs",
39+
"AppleRootCA-G3.pem")
40+
_MAX_JWS_BYTES = 64 * 1024
41+
_MAX_CHAIN_LEN = 5
42+
43+
#: product id -> (product_type, tier-or-None). The ONLY source of tier truth.
44+
#: The $0.99 solitaire do-over Consumable is accepted and recorded but maps to
45+
#: no tier — a consumable purchase can never escalate to Pro/Enterprise.
46+
DEFAULT_PRODUCTS: dict[str, tuple[str, str | None]] = {
47+
"com.opnmatrx.mtrx.pro.monthly": ("subscription", "pro"),
48+
"com.opnmatrx.mtrx.enterprise.monthly": ("subscription", "enterprise"),
49+
"com.opnmatrx.mtrx.solitaire.redos5": ("consumable", None),
50+
}
51+
52+
53+
class IAPError(Exception):
54+
"""JWS verification failed (generic — no internal detail leaks)."""
55+
56+
57+
class IAPNotConfigured(Exception):
58+
"""iap.bundle_id / trusted roots unset — routes must fail closed (503)."""
59+
60+
61+
# ── Config ──────────────────────────────────────────────────────────
62+
63+
def iap_config(config: dict) -> dict:
64+
return ((config or {}).get("iap") or {})
65+
66+
67+
def iap_bundle_id(config: dict) -> str:
68+
return str(iap_config(config).get("bundle_id", "")).strip()
69+
70+
71+
def iap_environment(config: dict) -> str:
72+
"""Optional 'Production'/'Sandbox' restriction; '' accepts either."""
73+
return str(iap_config(config).get("environment", "")).strip()
74+
75+
76+
def require_apple_oids(config: dict) -> bool:
77+
"""Leaf-marker OID check. ON by default; test fixture chains (self-signed,
78+
no Apple OIDs) turn it off explicitly alongside their own trusted root."""
79+
return bool(iap_config(config).get("require_apple_oids", True))
80+
81+
82+
def product_map(config: dict) -> dict[str, tuple[str, str | None]]:
83+
"""DEFAULT_PRODUCTS plus any ``iap.products`` config additions
84+
({product_id: {"type": ..., "tier": ...}})."""
85+
merged = dict(DEFAULT_PRODUCTS)
86+
for pid, spec in (iap_config(config).get("products") or {}).items():
87+
if isinstance(spec, dict):
88+
tier = spec.get("tier") or None
89+
merged[str(pid)] = (str(spec.get("type", "unknown")), tier)
90+
return merged
91+
92+
93+
def classify_product(product_id: str, config: dict) -> tuple[str, str | None]:
94+
"""(product_type, tier|None) for a product id. Unknown ids are recorded as
95+
("unknown", None) — NEVER a tier. Tier truth lives here, not in payloads."""
96+
return product_map(config).get(product_id, ("unknown", None))
97+
98+
99+
def trusted_roots_pem(config: dict) -> list[bytes]:
100+
"""Trusted root certificates: ``iap.trusted_roots_pem`` (list of PEM
101+
strings — used by tests) or the bundled Apple Root CA - G3."""
102+
override = iap_config(config).get("trusted_roots_pem")
103+
if override:
104+
return [p.encode() if isinstance(p, str) else bytes(p) for p in override]
105+
try:
106+
with open(_BUNDLED_ROOT_PATH, "rb") as fh:
107+
return [fh.read()]
108+
except OSError:
109+
return []
110+
111+
112+
def iap_configured(config: dict) -> bool:
113+
return bool(iap_bundle_id(config)) and bool(trusted_roots_pem(config))
114+
115+
116+
# ── JWS verification ────────────────────────────────────────────────
117+
118+
def _b64url_decode(segment: str) -> bytes:
119+
pad = "=" * (-len(segment) % 4)
120+
try:
121+
return base64.urlsafe_b64decode(segment + pad)
122+
except (binascii.Error, ValueError) as exc:
123+
raise IAPError("malformed JWS segment") from exc
124+
125+
126+
def _load_chain(header: dict) -> list[Any]:
127+
from cryptography import x509
128+
129+
x5c = header.get("x5c")
130+
if not isinstance(x5c, list) or not (1 <= len(x5c) <= _MAX_CHAIN_LEN):
131+
raise IAPError("missing or invalid x5c chain")
132+
certs = []
133+
for entry in x5c:
134+
try:
135+
certs.append(x509.load_der_x509_certificate(
136+
base64.b64decode(str(entry), validate=True)))
137+
except Exception as exc:
138+
raise IAPError("malformed certificate in x5c") from exc
139+
return certs
140+
141+
142+
def _check_validity(cert: Any, now: float) -> None:
143+
from datetime import datetime, timezone
144+
145+
t = datetime.fromtimestamp(now, tz=timezone.utc)
146+
if t < cert.not_valid_before_utc or t > cert.not_valid_after_utc:
147+
raise IAPError("certificate outside validity window")
148+
149+
150+
def _is_ca(cert: Any) -> bool:
151+
from cryptography import x509
152+
153+
try:
154+
bc = cert.extensions.get_extension_for_class(x509.BasicConstraints)
155+
return bool(bc.value.ca)
156+
except x509.ExtensionNotFound:
157+
return False
158+
159+
160+
def _has_oid(cert: Any, dotted: str) -> bool:
161+
return any(ext.oid.dotted_string == dotted for ext in cert.extensions)
162+
163+
164+
def _verify_chain(certs: list[Any], roots_pem: list[bytes], *, now: float,
165+
check_oids: bool) -> Any:
166+
"""Validate the x5c chain against the pinned roots; return the leaf cert.
167+
168+
Every adjacent pair must be a real signature link, every cert must be
169+
inside its validity window, intermediates must be CA certs and the leaf
170+
must not be, and the chain must END at a pinned root — either the last
171+
x5c cert IS a trusted root (byte-equal) or it is directly issued by one.
172+
"""
173+
from cryptography import x509
174+
175+
roots = []
176+
for pem in roots_pem:
177+
try:
178+
roots.append(x509.load_pem_x509_certificate(pem))
179+
except Exception as exc:
180+
raise IAPError("bad trusted root configuration") from exc
181+
if not roots:
182+
raise IAPNotConfigured()
183+
184+
for cert in certs:
185+
_check_validity(cert, now)
186+
187+
leaf = certs[0]
188+
if _is_ca(leaf):
189+
raise IAPError("leaf certificate must not be a CA")
190+
for issuer_candidate in certs[1:]:
191+
if not _is_ca(issuer_candidate):
192+
raise IAPError("intermediate certificate must be a CA")
193+
194+
# Signature links within the presented chain.
195+
for child, issuer in zip(certs, certs[1:]):
196+
try:
197+
child.verify_directly_issued_by(issuer)
198+
except Exception as exc:
199+
raise IAPError("broken certificate chain") from exc
200+
201+
# Anchor: the last presented cert is a pinned root, or issued by one.
202+
last = certs[-1]
203+
root_ders = {r.public_bytes(_der_encoding()) for r in roots}
204+
if last.public_bytes(_der_encoding()) not in root_ders:
205+
anchored = False
206+
for root in roots:
207+
try:
208+
last.verify_directly_issued_by(root)
209+
_check_validity(root, now)
210+
anchored = True
211+
break
212+
except Exception:
213+
continue
214+
if not anchored:
215+
raise IAPError("chain does not terminate at a trusted root")
216+
217+
if check_oids and not _has_oid(leaf, APPLE_LEAF_MARKER_OID):
218+
raise IAPError("leaf lacks the Apple App Store marker extension")
219+
return leaf
220+
221+
222+
def _der_encoding():
223+
from cryptography.hazmat.primitives.serialization import Encoding
224+
return Encoding.DER
225+
226+
227+
def verify_signed_payload(jws: str, *, config: dict,
228+
now: float | None = None) -> dict:
229+
"""Verify an App Store Server JWS and return its decoded payload.
230+
231+
Raises IAPNotConfigured when bundle id / roots are unset (route -> 503)
232+
and IAPError on ANY verification failure (route -> 401). The payload's
233+
bundleId is checked by the caller via check_bundle() — ASN nests it under
234+
data.bundleId while transactions carry it top-level.
235+
"""
236+
if not iap_bundle_id(config):
237+
raise IAPNotConfigured()
238+
roots = trusted_roots_pem(config)
239+
if not roots:
240+
raise IAPNotConfigured()
241+
242+
if not isinstance(jws, str) or not jws or len(jws) > _MAX_JWS_BYTES:
243+
raise IAPError("invalid JWS")
244+
parts = jws.split(".")
245+
if len(parts) != 3:
246+
raise IAPError("invalid JWS")
247+
248+
try:
249+
header = json.loads(_b64url_decode(parts[0]))
250+
except (json.JSONDecodeError, UnicodeDecodeError) as exc:
251+
raise IAPError("malformed JWS header") from exc
252+
253+
# Algorithm pinned BEFORE any cryptography: only ES256, never a downgrade.
254+
if header.get("alg") != "ES256":
255+
raise IAPError("unexpected JWS algorithm")
256+
257+
now = time.time() if now is None else now
258+
certs = _load_chain(header)
259+
leaf = _verify_chain(certs, roots, now=now,
260+
check_oids=require_apple_oids(config))
261+
262+
from cryptography.hazmat.primitives.asymmetric import ec
263+
264+
public_key = leaf.public_key()
265+
if not isinstance(public_key, ec.EllipticCurvePublicKey) or \
266+
not isinstance(public_key.curve, ec.SECP256R1):
267+
raise IAPError("leaf key is not P-256")
268+
269+
try:
270+
import jwt
271+
payload = jwt.decode(jws, key=public_key, algorithms=["ES256"],
272+
options={"verify_aud": False})
273+
except Exception as exc:
274+
raise IAPError("JWS signature verification failed") from exc
275+
if not isinstance(payload, dict):
276+
raise IAPError("unexpected JWS payload")
277+
return payload
278+
279+
280+
def check_bundle(payload_bundle_id: Any, config: dict) -> None:
281+
"""The payload's bundleId must equal the configured one, exactly."""
282+
expected = iap_bundle_id(config)
283+
if not expected:
284+
raise IAPNotConfigured()
285+
if not isinstance(payload_bundle_id, str) or payload_bundle_id != expected:
286+
raise IAPError("bundle id mismatch")
287+
288+
289+
def check_environment(payload_env: Any, config: dict) -> None:
290+
"""When iap.environment is configured, the payload must match it."""
291+
expected = iap_environment(config)
292+
if expected and payload_env != expected:
293+
raise IAPError("environment mismatch")
294+
295+
296+
# ── Payload helpers ─────────────────────────────────────────────────
297+
298+
def ms_to_epoch(value: Any) -> float:
299+
"""Apple dates are milliseconds since epoch; absent/garbage -> 0.0."""
300+
try:
301+
return float(value) / 1000.0
302+
except (TypeError, ValueError):
303+
return 0.0
304+
305+
306+
def transaction_fields(payload: dict) -> dict[str, Any]:
307+
"""Normalize a JWSTransactionDecodedPayload into the fields we store."""
308+
return {
309+
"transaction_id": str(payload.get("transactionId", "")),
310+
"original_transaction_id": str(payload.get("originalTransactionId", "")),
311+
"product_id": str(payload.get("productId", "")),
312+
"bundle_id": payload.get("bundleId"),
313+
"environment": str(payload.get("environment", "")),
314+
"purchase_date": ms_to_epoch(payload.get("purchaseDate")),
315+
"expires_date": ms_to_epoch(payload.get("expiresDate")),
316+
"quantity": int(payload.get("quantity") or 1),
317+
"revocation_date": ms_to_epoch(payload.get("revocationDate")),
318+
}

0 commit comments

Comments
 (0)