voxa-code 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- server/__init__.py +0 -0
- server/apns.py +89 -0
- server/app.py +589 -0
- server/appattest.py +310 -0
- server/appstore.py +141 -0
- server/attested_store.py +60 -0
- server/auth.py +70 -0
- server/ax_controller.py +202 -0
- server/billing.py +177 -0
- server/call_manager.py +91 -0
- server/certs/AppleRootCA-G3.pem +15 -0
- server/certs/Apple_App_Attestation_Root_CA.pem +14 -0
- server/claude_controller.py +156 -0
- server/cli.py +365 -0
- server/cloud_app.py +345 -0
- server/config.py +56 -0
- server/device_registry.py +52 -0
- server/gemini_operator.py +677 -0
- server/hooks.py +202 -0
- server/orchestrator.py +315 -0
- server/push_routes.py +50 -0
- server/ratelimit.py +41 -0
- server/relay.py +157 -0
- server/relay_client.py +89 -0
- server/remote_operator.py +128 -0
- server/session_hub.py +33 -0
- server/terminal_watcher.py +241 -0
- server/terminals.py +510 -0
- server/tmux_controller.py +580 -0
- server/transcript_monitor.py +134 -0
- server/transcripts.py +143 -0
- server/users.py +90 -0
- server/voxa_cloud.py +132 -0
- server/waitlist.py +130 -0
- static/app.js +388 -0
- static/favicon.svg +1 -0
- static/index.html +253 -0
- static/pcm-worklet.js +69 -0
- static/pro.html +29 -0
- static/pro2.html +33 -0
- static/voxa-mark-white.svg +1 -0
- voxa_code-0.1.0.dist-info/METADATA +227 -0
- voxa_code-0.1.0.dist-info/RECORD +47 -0
- voxa_code-0.1.0.dist-info/WHEEL +5 -0
- voxa_code-0.1.0.dist-info/entry_points.txt +2 -0
- voxa_code-0.1.0.dist-info/licenses/LICENSE +21 -0
- voxa_code-0.1.0.dist-info/top_level.txt +2 -0
server/appattest.py
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
"""Verify Apple App Attest attestations and assertions (real cryptography).
|
|
2
|
+
|
|
3
|
+
App Attest binds an anonymous device account to a key generated inside the
|
|
4
|
+
device's Secure Enclave. The flow, per Apple's "Validating Apps That Connect to
|
|
5
|
+
Your Server":
|
|
6
|
+
|
|
7
|
+
* ATTESTATION (once, at registration): the app calls
|
|
8
|
+
`DCAppAttestService.attestKey` with a server-issued challenge and sends us the
|
|
9
|
+
CBOR attestation object. We verify the embedded X.509 chain roots to Apple's
|
|
10
|
+
App Attest root, that the challenge-derived nonce matches the one Apple signed
|
|
11
|
+
into the leaf certificate, that the key id equals the SHA-256 of the attested
|
|
12
|
+
public key, and that the relying-party id hash matches our app id. On success
|
|
13
|
+
we trust and store the device's public key.
|
|
14
|
+
|
|
15
|
+
* ASSERTION (per request, later): the app signs a challenge/payload with the
|
|
16
|
+
same key; we verify the ECDSA signature with the stored public key and that
|
|
17
|
+
the signature counter strictly increases (replay guard).
|
|
18
|
+
|
|
19
|
+
Everything fails CLOSED: any missing field, malformed structure, or mismatch
|
|
20
|
+
returns None. We never trust a leaf signature without rooting the chain to the
|
|
21
|
+
bundled Apple App Attest root (an attacker controls the leaf otherwise).
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import hashlib
|
|
27
|
+
import logging
|
|
28
|
+
import os
|
|
29
|
+
|
|
30
|
+
import cbor2
|
|
31
|
+
from cryptography import x509
|
|
32
|
+
from cryptography.exceptions import InvalidSignature
|
|
33
|
+
from cryptography.hazmat.primitives import hashes, serialization
|
|
34
|
+
from cryptography.hazmat.primitives.asymmetric import ec, padding
|
|
35
|
+
from cryptography.x509.oid import ObjectIdentifier
|
|
36
|
+
|
|
37
|
+
logger = logging.getLogger(__name__)
|
|
38
|
+
|
|
39
|
+
# Apple's App Attest root, shipped so verification works out of the box. Override
|
|
40
|
+
# with VOXA_APPATTEST_ROOT (e.g. tests point it at a synthetic root).
|
|
41
|
+
_BUNDLED_ROOT = os.path.join(
|
|
42
|
+
os.path.dirname(__file__), "certs", "Apple_App_Attestation_Root_CA.pem"
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
# OID Apple stamps the attestation nonce into on the leaf certificate.
|
|
46
|
+
_NONCE_OID = ObjectIdentifier("1.2.840.113635.100.8.2")
|
|
47
|
+
|
|
48
|
+
# aaguid values Apple sets in authData. Production keys use "appattest" + NULs;
|
|
49
|
+
# development keys use the literal "appattestdevelop".
|
|
50
|
+
_AAGUID_PROD = b"appattest\x00\x00\x00\x00\x00\x00\x00"
|
|
51
|
+
_AAGUID_DEV = b"appattestdevelop"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _root_path() -> str:
|
|
55
|
+
return os.environ.get("VOXA_APPATTEST_ROOT", "").strip() or _BUNDLED_ROOT
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _der_len(data: bytes, i: int) -> tuple[int, int]:
|
|
59
|
+
"""Read a DER length starting at index i; return (length, index_after_length)."""
|
|
60
|
+
first = data[i]
|
|
61
|
+
i += 1
|
|
62
|
+
if first < 0x80:
|
|
63
|
+
return first, i
|
|
64
|
+
n = first & 0x7F
|
|
65
|
+
length = int.from_bytes(data[i : i + n], "big")
|
|
66
|
+
return length, i + n
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _extract_attest_nonce(der: bytes) -> bytes | None:
|
|
70
|
+
"""Pull the nonce octet string out of the leaf's App Attest extension.
|
|
71
|
+
|
|
72
|
+
The extension value is DER: SEQUENCE { [1] EXPLICIT OCTET STRING nonce }.
|
|
73
|
+
"""
|
|
74
|
+
try:
|
|
75
|
+
i = 0
|
|
76
|
+
if der[i] != 0x30: # SEQUENCE
|
|
77
|
+
return None
|
|
78
|
+
i += 1
|
|
79
|
+
_, i = _der_len(der, i)
|
|
80
|
+
if der[i] != 0xA1: # [1] context-specific, constructed
|
|
81
|
+
return None
|
|
82
|
+
i += 1
|
|
83
|
+
_, i = _der_len(der, i)
|
|
84
|
+
if der[i] != 0x04: # OCTET STRING
|
|
85
|
+
return None
|
|
86
|
+
i += 1
|
|
87
|
+
ln, i = _der_len(der, i)
|
|
88
|
+
return der[i : i + ln]
|
|
89
|
+
except Exception:
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _verify_chain(chain: list[x509.Certificate]) -> bool:
|
|
94
|
+
"""Verify the presented cert chain (leaf -> ... -> top) roots to the pinned
|
|
95
|
+
Apple App Attest root. Every link's signature must check out AND the top of
|
|
96
|
+
the chain must be, or be signed by, our trusted root. Fails closed."""
|
|
97
|
+
root_path = _root_path()
|
|
98
|
+
if not os.path.exists(root_path):
|
|
99
|
+
logger.error("App Attest root cert not found at %s; refusing attestation", root_path)
|
|
100
|
+
return False
|
|
101
|
+
if not chain:
|
|
102
|
+
return False
|
|
103
|
+
try:
|
|
104
|
+
with open(root_path, "rb") as f:
|
|
105
|
+
root = x509.load_pem_x509_certificate(f.read())
|
|
106
|
+
|
|
107
|
+
def signed_by(parent: x509.Certificate, child: x509.Certificate) -> bool:
|
|
108
|
+
pub = parent.public_key()
|
|
109
|
+
try:
|
|
110
|
+
if isinstance(pub, ec.EllipticCurvePublicKey):
|
|
111
|
+
pub.verify(child.signature, child.tbs_certificate_bytes,
|
|
112
|
+
ec.ECDSA(child.signature_hash_algorithm))
|
|
113
|
+
else:
|
|
114
|
+
pub.verify(child.signature, child.tbs_certificate_bytes,
|
|
115
|
+
padding.PKCS1v15(), child.signature_hash_algorithm)
|
|
116
|
+
return True
|
|
117
|
+
except Exception:
|
|
118
|
+
return False
|
|
119
|
+
|
|
120
|
+
for child, parent in zip(chain, chain[1:]):
|
|
121
|
+
if not signed_by(parent, child):
|
|
122
|
+
return False
|
|
123
|
+
|
|
124
|
+
top = chain[-1]
|
|
125
|
+
if top.fingerprint(hashes.SHA256()) == root.fingerprint(hashes.SHA256()):
|
|
126
|
+
return True
|
|
127
|
+
return signed_by(root, top)
|
|
128
|
+
except Exception:
|
|
129
|
+
logger.warning("App Attest chain verification error", exc_info=True)
|
|
130
|
+
return False
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _cose_ec_public_key(cose: bytes):
|
|
134
|
+
"""Parse a COSE_Key (EC2/P-256) and return (cryptography EC public key, raw
|
|
135
|
+
uncompressed point 0x04||x||y). Returns None on any mismatch."""
|
|
136
|
+
try:
|
|
137
|
+
key = cbor2.loads(cose)
|
|
138
|
+
if not isinstance(key, dict):
|
|
139
|
+
return None
|
|
140
|
+
if key.get(1) != 2: # kty must be EC2
|
|
141
|
+
return None
|
|
142
|
+
if key.get(-1) != 1: # crv must be P-256
|
|
143
|
+
return None
|
|
144
|
+
x = key.get(-2)
|
|
145
|
+
y = key.get(-3)
|
|
146
|
+
if not isinstance(x, (bytes, bytearray)) or not isinstance(y, (bytes, bytearray)):
|
|
147
|
+
return None
|
|
148
|
+
if len(x) != 32 or len(y) != 32:
|
|
149
|
+
return None
|
|
150
|
+
numbers = ec.EllipticCurvePublicNumbers(
|
|
151
|
+
int.from_bytes(x, "big"), int.from_bytes(y, "big"), ec.SECP256R1())
|
|
152
|
+
pub = numbers.public_key()
|
|
153
|
+
raw = b"\x04" + bytes(x) + bytes(y)
|
|
154
|
+
return pub, raw
|
|
155
|
+
except Exception:
|
|
156
|
+
return None
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def verify_attestation(attestation: bytes, key_id: bytes, challenge: bytes,
|
|
160
|
+
app_id: str, *, allow_dev: bool = True) -> dict | None:
|
|
161
|
+
"""Verify an App Attest attestation object.
|
|
162
|
+
|
|
163
|
+
Returns {"public_key_pem": <PEM str>, "counter": int, "receipt": bytes|None}
|
|
164
|
+
on success, else None (fails closed on any mismatch).
|
|
165
|
+
"""
|
|
166
|
+
try:
|
|
167
|
+
obj = cbor2.loads(attestation)
|
|
168
|
+
except Exception:
|
|
169
|
+
logger.warning("App Attest: attestation is not valid CBOR")
|
|
170
|
+
return None
|
|
171
|
+
if not isinstance(obj, dict) or obj.get("fmt") != "apple-appattest":
|
|
172
|
+
logger.warning("App Attest: unexpected fmt")
|
|
173
|
+
return None
|
|
174
|
+
att_stmt = obj.get("attStmt")
|
|
175
|
+
auth_data = obj.get("authData")
|
|
176
|
+
if not isinstance(att_stmt, dict) or not isinstance(auth_data, (bytes, bytearray)):
|
|
177
|
+
return None
|
|
178
|
+
auth_data = bytes(auth_data)
|
|
179
|
+
x5c = att_stmt.get("x5c")
|
|
180
|
+
if not isinstance(x5c, list) or len(x5c) < 1:
|
|
181
|
+
return None
|
|
182
|
+
|
|
183
|
+
# (1) Build + verify the certificate chain to Apple's root.
|
|
184
|
+
try:
|
|
185
|
+
chain = [x509.load_der_x509_certificate(bytes(c)) for c in x5c]
|
|
186
|
+
except Exception:
|
|
187
|
+
logger.warning("App Attest: could not parse x5c chain")
|
|
188
|
+
return None
|
|
189
|
+
if not _verify_chain(chain):
|
|
190
|
+
logger.warning("App Attest: chain did not root to trusted Apple root; rejecting")
|
|
191
|
+
return None
|
|
192
|
+
cred_cert = chain[0]
|
|
193
|
+
|
|
194
|
+
# (2) nonce = SHA256(authData || SHA256(challenge)).
|
|
195
|
+
client_data_hash = hashlib.sha256(bytes(challenge)).digest()
|
|
196
|
+
nonce = hashlib.sha256(auth_data + client_data_hash).digest()
|
|
197
|
+
|
|
198
|
+
# (3) The leaf must carry exactly this nonce in Apple's extension.
|
|
199
|
+
try:
|
|
200
|
+
ext = cred_cert.extensions.get_extension_for_oid(_NONCE_OID)
|
|
201
|
+
cert_nonce = _extract_attest_nonce(ext.value.value)
|
|
202
|
+
except Exception:
|
|
203
|
+
logger.warning("App Attest: leaf missing nonce extension")
|
|
204
|
+
return None
|
|
205
|
+
if cert_nonce is None or cert_nonce != nonce:
|
|
206
|
+
logger.warning("App Attest: nonce mismatch (wrong challenge or forged attestation)")
|
|
207
|
+
return None
|
|
208
|
+
|
|
209
|
+
# (4) Parse authData.
|
|
210
|
+
if len(auth_data) < 37:
|
|
211
|
+
return None
|
|
212
|
+
rp_id_hash = auth_data[0:32]
|
|
213
|
+
if rp_id_hash != hashlib.sha256(app_id.encode()).digest():
|
|
214
|
+
logger.warning("App Attest: rpIdHash != sha256(app_id)")
|
|
215
|
+
return None
|
|
216
|
+
# flags = auth_data[32] (unused for attestation beyond presence)
|
|
217
|
+
sign_count = int.from_bytes(auth_data[33:37], "big")
|
|
218
|
+
# Attested credential data follows: aaguid(16) credIdLen(2) credId COSEKey.
|
|
219
|
+
if len(auth_data) < 37 + 16 + 2:
|
|
220
|
+
return None
|
|
221
|
+
aaguid = auth_data[37:53]
|
|
222
|
+
if aaguid == _AAGUID_PROD:
|
|
223
|
+
pass
|
|
224
|
+
elif aaguid == _AAGUID_DEV:
|
|
225
|
+
if not allow_dev:
|
|
226
|
+
logger.warning("App Attest: development aaguid rejected (allow_dev off)")
|
|
227
|
+
return None
|
|
228
|
+
else:
|
|
229
|
+
logger.warning("App Attest: unexpected aaguid")
|
|
230
|
+
return None
|
|
231
|
+
cred_id_len = int.from_bytes(auth_data[53:55], "big")
|
|
232
|
+
cred_id_start = 55
|
|
233
|
+
cred_id_end = cred_id_start + cred_id_len
|
|
234
|
+
if len(auth_data) < cred_id_end:
|
|
235
|
+
return None
|
|
236
|
+
cred_id = auth_data[cred_id_start:cred_id_end]
|
|
237
|
+
if cred_id != bytes(key_id):
|
|
238
|
+
logger.warning("App Attest: credId != key_id")
|
|
239
|
+
return None
|
|
240
|
+
cose = auth_data[cred_id_end:]
|
|
241
|
+
|
|
242
|
+
# (5) Extract the P-256 public key and confirm key_id == sha256(raw pubkey).
|
|
243
|
+
parsed = _cose_ec_public_key(cose)
|
|
244
|
+
if parsed is None:
|
|
245
|
+
logger.warning("App Attest: bad COSE public key")
|
|
246
|
+
return None
|
|
247
|
+
pub, raw = parsed
|
|
248
|
+
if hashlib.sha256(raw).digest() != bytes(key_id):
|
|
249
|
+
logger.warning("App Attest: key_id != sha256(public key)")
|
|
250
|
+
return None
|
|
251
|
+
|
|
252
|
+
pem = pub.public_bytes(
|
|
253
|
+
encoding=serialization.Encoding.PEM,
|
|
254
|
+
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
|
255
|
+
).decode()
|
|
256
|
+
return {
|
|
257
|
+
"public_key_pem": pem,
|
|
258
|
+
"counter": sign_count,
|
|
259
|
+
"receipt": att_stmt.get("receipt"),
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def verify_assertion(assertion: bytes, client_data: bytes, public_key_pem: str,
|
|
264
|
+
app_id: str, prev_counter: int) -> int | None:
|
|
265
|
+
"""Verify an App Attest assertion signed by a previously attested key.
|
|
266
|
+
|
|
267
|
+
Returns the new (strictly increasing) signature counter, or None if the
|
|
268
|
+
signature is invalid, the rpIdHash is wrong, or the counter did not advance.
|
|
269
|
+
"""
|
|
270
|
+
try:
|
|
271
|
+
obj = cbor2.loads(assertion)
|
|
272
|
+
except Exception:
|
|
273
|
+
return None
|
|
274
|
+
if not isinstance(obj, dict):
|
|
275
|
+
return None
|
|
276
|
+
signature = obj.get("signature")
|
|
277
|
+
authenticator_data = obj.get("authenticatorData")
|
|
278
|
+
if not isinstance(signature, (bytes, bytearray)) or \
|
|
279
|
+
not isinstance(authenticator_data, (bytes, bytearray)):
|
|
280
|
+
return None
|
|
281
|
+
signature = bytes(signature)
|
|
282
|
+
authenticator_data = bytes(authenticator_data)
|
|
283
|
+
|
|
284
|
+
# nonce = SHA256(authenticatorData || SHA256(clientData)); the ECDSA signature
|
|
285
|
+
# is over that nonce. Verifying over the concatenation with ES256 is identical
|
|
286
|
+
# (ES256 applies SHA256 first), so let cryptography hash it.
|
|
287
|
+
client_data_hash = hashlib.sha256(bytes(client_data)).digest()
|
|
288
|
+
try:
|
|
289
|
+
pub = serialization.load_pem_public_key(public_key_pem.encode())
|
|
290
|
+
except Exception:
|
|
291
|
+
return None
|
|
292
|
+
if not isinstance(pub, ec.EllipticCurvePublicKey):
|
|
293
|
+
return None
|
|
294
|
+
try:
|
|
295
|
+
pub.verify(signature, authenticator_data + client_data_hash,
|
|
296
|
+
ec.ECDSA(hashes.SHA256()))
|
|
297
|
+
except InvalidSignature:
|
|
298
|
+
return None
|
|
299
|
+
except Exception:
|
|
300
|
+
return None
|
|
301
|
+
|
|
302
|
+
if len(authenticator_data) < 37:
|
|
303
|
+
return None
|
|
304
|
+
rp_id_hash = authenticator_data[0:32]
|
|
305
|
+
if rp_id_hash != hashlib.sha256(app_id.encode()).digest():
|
|
306
|
+
return None
|
|
307
|
+
sign_count = int.from_bytes(authenticator_data[33:37], "big")
|
|
308
|
+
if sign_count <= prev_counter: # replay / non-advancing counter
|
|
309
|
+
return None
|
|
310
|
+
return sign_count
|
server/appstore.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Verify Apple StoreKit 2 signed transactions (JWS).
|
|
2
|
+
|
|
3
|
+
A StoreKit 2 transaction is a JWS whose protected header carries the signing
|
|
4
|
+
certificate chain in `x5c` (leaf -> intermediate -> Apple root). Verification:
|
|
5
|
+
1. take the leaf cert from x5c, use its public key to check the JWS signature,
|
|
6
|
+
2. confirm the x5c chain roots to Apple's real root CA (MANDATORY),
|
|
7
|
+
3. read the payload (productId, transactionId, bundleId, expiresDate).
|
|
8
|
+
|
|
9
|
+
Step 2 is mandatory and fails closed: the leaf cert travels inside the token, so
|
|
10
|
+
leaf-signature-only verification is trivially forgeable (an attacker signs with
|
|
11
|
+
their own key + cert). We ship Apple's public root ("Apple Root CA - G3") in
|
|
12
|
+
`certs/AppleRootCA-G3.pem` and verify the presented chain against it. Override
|
|
13
|
+
with APPLE_ROOT_CERT only to point at a different trusted root (e.g. tests).
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import base64
|
|
19
|
+
import json
|
|
20
|
+
import logging
|
|
21
|
+
import os
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
# Apple's public StoreKit root, shipped with the package so verification works
|
|
26
|
+
# out of the box (SHA-256 63:34:3A:BF:...:91:79). APPLE_ROOT_CERT overrides it.
|
|
27
|
+
_BUNDLED_ROOT = os.path.join(os.path.dirname(__file__), "certs", "AppleRootCA-G3.pem")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _root_path() -> str:
|
|
31
|
+
return os.environ.get("APPLE_ROOT_CERT", "").strip() or _BUNDLED_ROOT
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _b64url_json(segment: str) -> dict:
|
|
35
|
+
pad = "=" * (-len(segment) % 4)
|
|
36
|
+
return json.loads(base64.urlsafe_b64decode(segment + pad))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def decode_unverified(jws: str) -> dict:
|
|
40
|
+
"""Decode the payload without checking the signature (never trust alone)."""
|
|
41
|
+
try:
|
|
42
|
+
return _b64url_json(jws.split(".")[1])
|
|
43
|
+
except Exception:
|
|
44
|
+
return {}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _leaf_public_key_pem(jws: str):
|
|
48
|
+
header = _b64url_json(jws.split(".")[0])
|
|
49
|
+
x5c = header.get("x5c") or []
|
|
50
|
+
if not x5c:
|
|
51
|
+
return None
|
|
52
|
+
from cryptography import x509
|
|
53
|
+
der = base64.b64decode(x5c[0])
|
|
54
|
+
cert = x509.load_der_x509_certificate(der)
|
|
55
|
+
from cryptography.hazmat.primitives import serialization
|
|
56
|
+
return cert.public_key().public_bytes(
|
|
57
|
+
encoding=serialization.Encoding.PEM,
|
|
58
|
+
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def verify_transaction(jws: str, bundle_id: str | None = None) -> dict | None:
|
|
63
|
+
"""Return the verified transaction payload, or None if invalid.
|
|
64
|
+
|
|
65
|
+
Verifies the JWS signature with the leaf certificate embedded in the token AND
|
|
66
|
+
that the embedded chain roots to Apple's trusted root. Both must pass.
|
|
67
|
+
"""
|
|
68
|
+
import jwt # PyJWT
|
|
69
|
+
|
|
70
|
+
pem = _leaf_public_key_pem(jws)
|
|
71
|
+
if pem is None:
|
|
72
|
+
logger.warning("StoreKit transaction has no x5c leaf cert")
|
|
73
|
+
return None
|
|
74
|
+
try:
|
|
75
|
+
payload = jwt.decode(jws, pem, algorithms=["ES256"], options={"verify_aud": False})
|
|
76
|
+
except Exception as e:
|
|
77
|
+
logger.warning("StoreKit JWS signature invalid: %s", e)
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
if bundle_id and payload.get("bundleId") not in (None, bundle_id):
|
|
81
|
+
logger.warning("StoreKit bundleId mismatch: %s != %s", payload.get("bundleId"), bundle_id)
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
# MANDATORY chain check. The leaf signature above only proves the token was
|
|
85
|
+
# signed by whoever's cert is in x5c[0] — which the sender controls — so without
|
|
86
|
+
# rooting the chain to Apple, anyone can forge a valid-looking transaction.
|
|
87
|
+
if not _verify_chain(jws):
|
|
88
|
+
logger.warning("StoreKit cert chain did not verify to Apple's root; rejecting transaction")
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
return payload
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _verify_chain(jws: str) -> bool:
|
|
95
|
+
"""Verify the x5c chain (leaf -> intermediate -> Apple root) against the trusted
|
|
96
|
+
root. Returns True only when every link's signature checks out AND the top of the
|
|
97
|
+
presented chain is (or is signed by) our pinned Apple root."""
|
|
98
|
+
root_path = _root_path()
|
|
99
|
+
if not os.path.exists(root_path):
|
|
100
|
+
logger.error("Apple root cert not found at %s; refusing to verify purchases", root_path)
|
|
101
|
+
return False
|
|
102
|
+
try:
|
|
103
|
+
from cryptography import x509
|
|
104
|
+
from cryptography.hazmat.primitives import hashes
|
|
105
|
+
from cryptography.hazmat.primitives.asymmetric import ec, padding
|
|
106
|
+
|
|
107
|
+
header = _b64url_json(jws.split(".")[0])
|
|
108
|
+
x5c = header.get("x5c") or []
|
|
109
|
+
if not x5c:
|
|
110
|
+
return False
|
|
111
|
+
chain = [x509.load_der_x509_certificate(base64.b64decode(c)) for c in x5c]
|
|
112
|
+
with open(root_path, "rb") as f:
|
|
113
|
+
root = x509.load_pem_x509_certificate(f.read())
|
|
114
|
+
|
|
115
|
+
def signed_by(parent, child) -> bool:
|
|
116
|
+
pub = parent.public_key()
|
|
117
|
+
try:
|
|
118
|
+
if isinstance(pub, ec.EllipticCurvePublicKey):
|
|
119
|
+
pub.verify(child.signature, child.tbs_certificate_bytes,
|
|
120
|
+
ec.ECDSA(child.signature_hash_algorithm))
|
|
121
|
+
else:
|
|
122
|
+
pub.verify(child.signature, child.tbs_certificate_bytes,
|
|
123
|
+
padding.PKCS1v15(), child.signature_hash_algorithm)
|
|
124
|
+
return True
|
|
125
|
+
except Exception:
|
|
126
|
+
return False
|
|
127
|
+
|
|
128
|
+
# Every presented link must verify: leaf<-intermediate<-...<-top.
|
|
129
|
+
for child, parent in zip(chain, chain[1:]):
|
|
130
|
+
if not signed_by(parent, child):
|
|
131
|
+
return False
|
|
132
|
+
|
|
133
|
+
# The top of the presented chain must anchor to OUR pinned Apple root:
|
|
134
|
+
# either it IS the root (chain includes it), or the root signed it.
|
|
135
|
+
top = chain[-1]
|
|
136
|
+
if top.fingerprint(hashes.SHA256()) == root.fingerprint(hashes.SHA256()):
|
|
137
|
+
return True
|
|
138
|
+
return signed_by(root, top)
|
|
139
|
+
except Exception:
|
|
140
|
+
logger.warning("StoreKit chain verification error", exc_info=True)
|
|
141
|
+
return False
|
server/attested_store.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Store of App-Attested device keys.
|
|
2
|
+
|
|
3
|
+
JSON-backed (like UserStore/Billing). Maps a key id (hex of the Secure Enclave
|
|
4
|
+
key's id) to the account it attests, the stored P-256 public key (PEM), and the
|
|
5
|
+
last-seen signature counter (for the assertion replay guard). Once a key is
|
|
6
|
+
bound to a `d-<uuid>` account, that account is considered attested: it may mint a
|
|
7
|
+
free trial and open metered sessions when attestation is required.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import threading
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AttestedStore:
|
|
18
|
+
def __init__(self, path: str | None = None):
|
|
19
|
+
self._path = path or os.environ.get("VOXA_ATTESTED_FILE", "attested.json")
|
|
20
|
+
self._lock = threading.Lock()
|
|
21
|
+
self._data: dict = self._load()
|
|
22
|
+
|
|
23
|
+
def _load(self) -> dict:
|
|
24
|
+
try:
|
|
25
|
+
with open(self._path) as f:
|
|
26
|
+
return json.load(f)
|
|
27
|
+
except (OSError, ValueError):
|
|
28
|
+
return {}
|
|
29
|
+
|
|
30
|
+
def _save(self) -> None:
|
|
31
|
+
tmp = f"{self._path}.tmp"
|
|
32
|
+
with open(tmp, "w") as f:
|
|
33
|
+
json.dump(self._data, f)
|
|
34
|
+
os.replace(tmp, self._path)
|
|
35
|
+
|
|
36
|
+
def get(self, key_id: str) -> dict | None:
|
|
37
|
+
with self._lock:
|
|
38
|
+
rec = self._data.get(key_id)
|
|
39
|
+
return dict(rec) if rec else None
|
|
40
|
+
|
|
41
|
+
def put(self, key_id: str, account: str, public_key_pem: str, counter: int) -> None:
|
|
42
|
+
with self._lock:
|
|
43
|
+
self._data[key_id] = {
|
|
44
|
+
"account": account,
|
|
45
|
+
"public_key_pem": public_key_pem,
|
|
46
|
+
"counter": int(counter),
|
|
47
|
+
}
|
|
48
|
+
self._save()
|
|
49
|
+
|
|
50
|
+
def update_counter(self, key_id: str, counter: int) -> None:
|
|
51
|
+
with self._lock:
|
|
52
|
+
rec = self._data.get(key_id)
|
|
53
|
+
if rec is not None:
|
|
54
|
+
rec["counter"] = int(counter)
|
|
55
|
+
self._save()
|
|
56
|
+
|
|
57
|
+
def account_is_attested(self, account: str) -> bool:
|
|
58
|
+
"""True if any stored key is bound to this account."""
|
|
59
|
+
with self._lock:
|
|
60
|
+
return any(rec.get("account") == account for rec in self._data.values())
|
server/auth.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Sign in with Apple verification + auth routes for Voxa Cloud."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import jwt # PyJWT
|
|
6
|
+
from fastapi import FastAPI, Request
|
|
7
|
+
from fastapi.responses import JSONResponse
|
|
8
|
+
from jwt import PyJWKClient
|
|
9
|
+
|
|
10
|
+
from server.users import UserStore, issue_token, verify_token
|
|
11
|
+
|
|
12
|
+
APPLE_ISS = "https://appleid.apple.com"
|
|
13
|
+
APPLE_JWKS_URL = "https://appleid.apple.com/auth/keys"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def verify_apple_identity_token(identity_token: str, bundle_id: str,
|
|
17
|
+
jwks_url: str = APPLE_JWKS_URL) -> dict | None:
|
|
18
|
+
"""Verify an Apple identity token (RS256, signed by Apple). Returns the claims
|
|
19
|
+
(sub, optional email) or None. iss must be Apple; aud must be our bundle id."""
|
|
20
|
+
try:
|
|
21
|
+
key = PyJWKClient(jwks_url).get_signing_key_from_jwt(identity_token)
|
|
22
|
+
return jwt.decode(identity_token, key.key, algorithms=["RS256"],
|
|
23
|
+
audience=bundle_id, issuer=APPLE_ISS)
|
|
24
|
+
except Exception:
|
|
25
|
+
return None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def bearer_user(request: Request, secret: str) -> str | None:
|
|
29
|
+
"""Return the user_id from a verified `Authorization: Bearer <token>` header."""
|
|
30
|
+
header = request.headers.get("authorization", "")
|
|
31
|
+
if not header.lower().startswith("bearer "):
|
|
32
|
+
return None
|
|
33
|
+
return verify_token(header[7:].strip(), secret)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def add_auth_routes(app: FastAPI, users: UserStore, *, secret: str, bundle_id: str,
|
|
37
|
+
apple_verifier=verify_apple_identity_token,
|
|
38
|
+
billing=None) -> None:
|
|
39
|
+
@app.post("/auth/apple")
|
|
40
|
+
async def auth_apple(request: Request):
|
|
41
|
+
body = await request.json()
|
|
42
|
+
token = (body or {}).get("identity_token", "")
|
|
43
|
+
if not token:
|
|
44
|
+
return JSONResponse({"error": "missing identity_token"}, status_code=400)
|
|
45
|
+
claims = apple_verifier(token, bundle_id)
|
|
46
|
+
if not claims or not claims.get("sub"):
|
|
47
|
+
return JSONResponse({"error": "invalid identity token"}, status_code=401)
|
|
48
|
+
email = (body or {}).get("email") or claims.get("email")
|
|
49
|
+
uid = users.find_or_create_apple_user(claims["sub"], email)
|
|
50
|
+
return {"token": issue_token(uid, secret), "user_id": uid}
|
|
51
|
+
|
|
52
|
+
@app.get("/auth/me")
|
|
53
|
+
async def auth_me(request: Request):
|
|
54
|
+
uid = bearer_user(request, secret)
|
|
55
|
+
if not uid:
|
|
56
|
+
return JSONResponse({"error": "unauthorized"}, status_code=401)
|
|
57
|
+
user = users.get_user(uid) or {}
|
|
58
|
+
return {"user_id": uid, "email": user.get("email")}
|
|
59
|
+
|
|
60
|
+
@app.delete("/auth/account")
|
|
61
|
+
async def delete_account(request: Request):
|
|
62
|
+
"""Permanently delete the signed-in account and its data (App Review
|
|
63
|
+
Guideline 5.1.1(v)). Idempotent: succeeds even if already gone."""
|
|
64
|
+
uid = bearer_user(request, secret)
|
|
65
|
+
if not uid:
|
|
66
|
+
return JSONResponse({"error": "unauthorized"}, status_code=401)
|
|
67
|
+
users.delete_user(uid)
|
|
68
|
+
if billing is not None:
|
|
69
|
+
billing.delete_account(uid) # drop the account's minute balance/ledger
|
|
70
|
+
return {"ok": True}
|