physistool 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.
physis/attestation.py ADDED
@@ -0,0 +1,754 @@
1
+ """AWS Nitro Enclaves attestation verifier for the PhysisFold client.
2
+
3
+ This is the **centre of trust** of the open-source client (``client-and-api.md``
4
+ §3, ``testing-plan.md`` §1.5): *"verify me, don't trust me"*. Before the client
5
+ sends any input to the folding endpoint (CONTRACT.md §1) it fetches an
6
+ attestation document from ``GET /attestation`` and calls
7
+ :func:`verify_attestation` on it. Only after the AWS signature chain and the
8
+ published PCR0 both check out does it trust the ``enclave_pubkey`` and seal input
9
+ to it.
10
+
11
+ What a real attestation document is
12
+ -----------------------------------
13
+ The AWS Nitro Security Module (NSM) produces an **attestation document**: a
14
+ ``COSE_Sign1`` object (RFC 8152) whose payload is a CBOR map. The relevant
15
+ payload fields (AWS "Nitro Enclaves" attestation spec) are::
16
+
17
+ module_id text enclave id
18
+ timestamp uint ms since epoch (freshness / anti-replay)
19
+ digest text PCR hash algorithm, "SHA384"
20
+ pcrs {int: bstr} PCR index -> measurement (PCR0 = index 0)
21
+ certificate bstr DER leaf cert (public key that signed COSE_Sign1)
22
+ cabundle [bstr] DER intermediate certs, ROOT-FIRST toward leaf
23
+ public_key bstr? the enclave app public key (our X25519 pubkey)
24
+ user_data bstr? optional app data
25
+ nonce bstr? optional caller-supplied challenge
26
+
27
+ The COSE_Sign1 is signed by the leaf certificate's private key (ECDSA/P-384).
28
+ The leaf chains (via ``cabundle``) up to the **AWS Nitro Enclaves root CA**,
29
+ whose certificate is public and pinned here (:data:`AWS_NITRO_ROOT_CA_PEM`, or
30
+ a fixture path via :func:`set_trust_anchor`).
31
+
32
+ Wire shape consumed here
33
+ ------------------------
34
+ ``GET /attestation`` returns JSON (CONTRACT.md §1). Two shapes:
35
+
36
+ * ``{"stub": true, "pcr0": ..., "enclave_pubkey": ...}`` — Stage-A local dev
37
+ fixture. **Not** AWS-signed. :func:`verify_attestation` skips verification with
38
+ a warning and trusts the (dev-only) pubkey, matching today's client behaviour.
39
+ * ``{"stub": false, "document_b64": "<base64 COSE_Sign1 bytes>", ...}`` — a real
40
+ NSM document. Here the outer JSON is **untrusted**: ``pcr0`` and
41
+ ``enclave_pubkey`` are taken from the *signed COSE payload*, never from the
42
+ outer fields. (An outer ``pcr0`` / ``enclave_pubkey``, if present, is ignored
43
+ for trust — the parent could set them to anything.)
44
+
45
+ Security properties (why the parent cannot forge)
46
+ --------------------------------------------------
47
+ The value returned by :func:`verify_attestation` is trusted **only** after ALL
48
+ of the following pass, in order — any failure raises before anything is
49
+ returned:
50
+
51
+ 1. The COSE_Sign1 signature verifies against the **leaf certificate** public key.
52
+ 2. The certificate chain leaf -> ``cabundle`` links validly (each cert signed by
53
+ the next), and the top of the chain is signed by the **pinned AWS Nitro root**.
54
+ 3. ``payload.pcrs[0] == expected_pcr0`` — a single flipped byte rejects.
55
+ 4. The document is fresh: ``timestamp`` within :data:`MAX_AGE_MS` of now and not
56
+ in the future (anti-replay of an old, otherwise-valid document).
57
+
58
+ The parent EC2 instance has no AWS Nitro root private key, so it cannot mint a
59
+ leaf that chains to the root, nor re-sign a tampered payload. It also cannot
60
+ change PCR0 without changing the enclave image measurement.
61
+
62
+ Dependencies
63
+ ------------
64
+ Uses ``cryptography`` (already a client dep via ``envelope.py``) for X.509 and
65
+ ECDSA. For CBOR/COSE parsing it prefers ``cbor2`` if installed; otherwise it
66
+ falls back to a **minimal, self-contained CBOR reader** (:func:`_cbor_load`)
67
+ sufficient for NSM documents. This keeps the verifier importable even where
68
+ ``cbor2`` is not available. The client is open-source and permitted third-party
69
+ deps; only the compiled engine is dependency-restricted.
70
+ """
71
+
72
+ from __future__ import annotations
73
+
74
+ import base64
75
+ import os
76
+ import struct
77
+ import sys
78
+ import time
79
+ from typing import Any, Optional
80
+
81
+ from cryptography import x509
82
+ from cryptography.exceptions import InvalidSignature
83
+ from cryptography.hazmat.primitives import hashes
84
+ from cryptography.hazmat.primitives.asymmetric import ec, padding, rsa
85
+ from cryptography.hazmat.primitives.asymmetric.utils import (
86
+ encode_dss_signature,
87
+ )
88
+
89
+ # Optional fast CBOR path.
90
+ try: # pragma: no cover - trivial import guard
91
+ import cbor2 as _cbor2 # type: ignore
92
+ except Exception: # pragma: no cover
93
+ _cbor2 = None
94
+
95
+
96
+ # ── Trust anchor: AWS Nitro Enclaves root CA ─────────────────────────────────
97
+ #
98
+ # The AWS Nitro Enclaves root certificate is PUBLIC. AWS publishes it (and its
99
+ # SHA-256 fingerprint) at
100
+ # https://aws-nitro-enclaves.amazonaws.com/AWS_NitroEnclaves_Root-G1.zip
101
+ # It is pinned here so the verifier needs no network access. Operators can
102
+ # override it with a fixture via :func:`set_trust_anchor` (used by the test
103
+ # suite to exercise the chain logic against a *synthetic* root without shipping
104
+ # a forged AWS-looking cert).
105
+ #
106
+ # The pinned AWS Nitro Enclaves Root G1 certificate, downloaded from
107
+ # https://aws-nitro-enclaves.amazonaws.com/AWS_NitroEnclaves_Root-G1.zip and
108
+ # verified against its published DER SHA-256 fingerprint:
109
+ # 641a0321a3e244efe456463195d606317ed7cdcc3c1756e09893f3c68f79bb5b
110
+ # Self-signed, CN=aws.nitro-enclaves, valid 2019-10-28 .. 2049-10-28. This is a
111
+ # PUBLIC certificate; pinning it (not fetching at runtime) is what makes the
112
+ # verifier trustworthy offline. A deploy may still override it via
113
+ # ``set_trust_anchor(...)`` or the PHYSIS_NITRO_ROOT_PEM[_FILE] env vars.
114
+ AWS_NITRO_ROOT_CA_PEM: bytes = b"""-----BEGIN CERTIFICATE-----
115
+ MIICETCCAZagAwIBAgIRAPkxdWgbkK/hHUbMtOTn+FYwCgYIKoZIzj0EAwMwSTEL
116
+ MAkGA1UEBhMCVVMxDzANBgNVBAoMBkFtYXpvbjEMMAoGA1UECwwDQVdTMRswGQYD
117
+ VQQDDBJhd3Mubml0cm8tZW5jbGF2ZXMwHhcNMTkxMDI4MTMyODA1WhcNNDkxMDI4
118
+ MTQyODA1WjBJMQswCQYDVQQGEwJVUzEPMA0GA1UECgwGQW1hem9uMQwwCgYDVQQL
119
+ DANBV1MxGzAZBgNVBAMMEmF3cy5uaXRyby1lbmNsYXZlczB2MBAGByqGSM49AgEG
120
+ BSuBBAAiA2IABPwCVOumCMHzaHDimtqQvkY4MpJzbolL//Zy2YlES1BR5TSksfbb
121
+ 48C8WBoyt7F2Bw7eEtaaP+ohG2bnUs990d0JX28TcPQXCEPZ3BABIeTPYwEoCWZE
122
+ h8l5YoQwTcU/9KNCMEAwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUkCW1DdkF
123
+ R+eWw5b6cp3PmanfS5YwDgYDVR0PAQH/BAQDAgGGMAoGCCqGSM49BAMDA2kAMGYC
124
+ MQCjfy+Rocm9Xue4YnwWmNJVA44fA0P5W2OpYow9OYCVRaEevL8uO1XYru5xtMPW
125
+ rfMCMQCi85sWBbJwKKXdS6BptQFuZbT73o/gBh1qUxl/nNr12UO8Yfwr6wPLb+6N
126
+ IwLz3/Y=
127
+ -----END CERTIFICATE-----
128
+ """
129
+
130
+ # Freshness window for a real attestation document (anti-replay). AWS NSM
131
+ # timestamps are ms since epoch. 5 minutes is generous for clock skew + fetch.
132
+ MAX_AGE_MS: int = 5 * 60 * 1000
133
+
134
+ # Small tolerance for a document dated slightly in the future (clock skew).
135
+ FUTURE_SKEW_MS: int = 60 * 1000
136
+
137
+
138
+ class AttestationError(Exception):
139
+ """Raised when an attestation document cannot be trusted.
140
+
141
+ Deliberately raised *before* :func:`verify_attestation` returns any value,
142
+ so a caller that reaches the ``enclave_pubkey`` return is guaranteed the
143
+ document was fully verified (or was an explicitly-warned stub).
144
+ """
145
+
146
+
147
+ # A process-level trust anchor override. When ``None``, the pinned
148
+ # :data:`AWS_NITRO_ROOT_CA_PEM` is used.
149
+ _TRUST_ANCHOR_PEM: Optional[bytes] = None
150
+
151
+
152
+ def set_trust_anchor(pem_bytes: Optional[bytes]) -> None:
153
+ """Override the root CA used for chain verification (PEM bytes).
154
+
155
+ Passing ``None`` restores the pinned :data:`AWS_NITRO_ROOT_CA_PEM`. Used at
156
+ deploy time to load the published AWS root from a fixture, and by the test
157
+ suite to swap in a synthetic root (so the chain logic is exercised without
158
+ shipping a fake AWS-looking certificate).
159
+ """
160
+ global _TRUST_ANCHOR_PEM
161
+ _TRUST_ANCHOR_PEM = pem_bytes
162
+
163
+
164
+ # Deploy-time root-CA injection without editing source. Checked ONLY when neither
165
+ # an explicit :func:`set_trust_anchor` override nor the pinned
166
+ # :data:`AWS_NITRO_ROOT_CA_PEM` is set. ``PHYSIS_NITRO_ROOT_PEM`` carries the PEM
167
+ # text directly; ``PHYSIS_NITRO_ROOT_PEM_FILE`` a path to read it from.
168
+ _ENV_ROOT_PEM = "PHYSIS_NITRO_ROOT_PEM"
169
+ _ENV_ROOT_PEM_FILE = "PHYSIS_NITRO_ROOT_PEM_FILE"
170
+
171
+
172
+ def _root_pem_from_env() -> Optional[bytes]:
173
+ pem = os.environ.get(_ENV_ROOT_PEM)
174
+ if pem:
175
+ return pem.encode("utf-8") if isinstance(pem, str) else pem
176
+ path = os.environ.get(_ENV_ROOT_PEM_FILE)
177
+ if path:
178
+ try:
179
+ with open(path, "rb") as fh:
180
+ return fh.read()
181
+ except OSError as exc:
182
+ raise AttestationError(
183
+ f"{_ENV_ROOT_PEM_FILE}={path!r} could not be read: {exc}"
184
+ ) from exc
185
+ return None
186
+
187
+
188
+ def _root_ca_cert() -> x509.Certificate:
189
+ if _TRUST_ANCHOR_PEM is not None:
190
+ pem = _TRUST_ANCHOR_PEM
191
+ else:
192
+ pem = AWS_NITRO_ROOT_CA_PEM or _root_pem_from_env() or b""
193
+ if not pem:
194
+ raise AttestationError(
195
+ "no AWS Nitro root CA pinned. Set attestation.AWS_NITRO_ROOT_CA_PEM "
196
+ "to the published AWS_NitroEnclaves_Root-G1.pem, call "
197
+ "set_trust_anchor(pem_bytes=...), or set the "
198
+ f"{_ENV_ROOT_PEM}/{_ENV_ROOT_PEM_FILE} environment variable."
199
+ )
200
+ try:
201
+ return x509.load_pem_x509_certificate(pem)
202
+ except Exception as exc: # noqa: BLE001
203
+ raise AttestationError(f"invalid pinned root CA PEM: {exc}") from exc
204
+
205
+
206
+ # ── Public API ───────────────────────────────────────────────────────────────
207
+
208
+ def verify_attestation(doc: dict, expected_pcr0: str) -> str:
209
+ """Verify an attestation document and return the trusted ``enclave_pubkey``.
210
+
211
+ Parameters
212
+ ----------
213
+ doc : dict
214
+ The parsed JSON body of ``GET /attestation`` (CONTRACT.md §1). Either a
215
+ Stage-A stub (``{"stub": true, ...}``) or a real document carrying the
216
+ raw AWS COSE_Sign1 bytes in ``document_b64`` (base64) — also accepted
217
+ under the keys ``attestation_b64`` / ``cose_b64`` / ``document``.
218
+ expected_pcr0 : str
219
+ The PCR0 measurement of the *published* build, as a hex string. The
220
+ document's signed PCR0 must equal this exactly.
221
+
222
+ Returns
223
+ -------
224
+ str
225
+ The enclave X25519 public key as base64 — the value the client seals
226
+ input to (Stage C). For a stub, the (unverified, dev-only) outer
227
+ ``enclave_pubkey``.
228
+
229
+ Raises
230
+ ------
231
+ AttestationError
232
+ On any verification failure — wrong PCR0 (even one byte), broken/invalid
233
+ AWS signature, an untrusted certificate chain, a stale/future document,
234
+ or a malformed document. Raised **before** any pubkey is returned.
235
+ """
236
+ if not isinstance(doc, dict):
237
+ raise AttestationError("attestation document must be a JSON object")
238
+
239
+ # ── Stage-A stub: skip verification with a warning (matches client today).
240
+ if doc.get("stub"):
241
+ print(
242
+ "[stub attestation - AWS signature NOT verified - local dev only]",
243
+ file=sys.stderr,
244
+ )
245
+ pub = doc.get("enclave_pubkey")
246
+ if not isinstance(pub, str) or not pub:
247
+ raise AttestationError("stub attestation is missing enclave_pubkey")
248
+ return pub
249
+
250
+ # ── Real document: the outer JSON is UNTRUSTED. Everything trusted comes
251
+ # from the signed COSE payload below.
252
+ cose_b64 = (
253
+ doc.get("document_b64")
254
+ or doc.get("attestation_b64")
255
+ or doc.get("cose_b64")
256
+ or doc.get("document")
257
+ )
258
+ if not isinstance(cose_b64, str) or not cose_b64:
259
+ raise AttestationError(
260
+ "non-stub attestation is missing the COSE_Sign1 document "
261
+ "(expected base64 under 'document_b64')"
262
+ )
263
+ try:
264
+ cose_bytes = base64.b64decode(cose_b64, validate=True)
265
+ except Exception as exc: # noqa: BLE001
266
+ raise AttestationError(f"attestation document is not valid base64: {exc}") from exc
267
+
268
+ payload = _verify_cose_and_chain(cose_bytes)
269
+
270
+ # ── PCR0 check (constant-shape, exact match; one byte mismatch rejects).
271
+ pcrs = payload.get("pcrs")
272
+ if not isinstance(pcrs, dict) or 0 not in pcrs:
273
+ raise AttestationError("attestation payload has no PCR0")
274
+ pcr0_bytes = pcrs[0]
275
+ if not isinstance(pcr0_bytes, (bytes, bytearray)):
276
+ raise AttestationError("PCR0 is not a byte string")
277
+ pcr0_hex = bytes(pcr0_bytes).hex()
278
+
279
+ expected = _normalize_pcr0(expected_pcr0)
280
+ if not _consttime_eq(pcr0_hex.lower(), expected):
281
+ raise AttestationError(
282
+ "PCR0 mismatch: enclave image is not the published build "
283
+ f"(got {pcr0_hex[:16]}..., expected {expected[:16]}...)"
284
+ )
285
+
286
+ # ── Freshness (anti-replay of an old but otherwise-valid document).
287
+ _check_freshness(payload.get("timestamp"))
288
+
289
+ # ── Only now do we trust the enclave public key, taken from the SIGNED
290
+ # payload (not the outer JSON).
291
+ pub_bytes = payload.get("public_key")
292
+ if not isinstance(pub_bytes, (bytes, bytearray)) or len(pub_bytes) == 0:
293
+ raise AttestationError("attestation payload has no enclave public_key")
294
+ return base64.b64encode(bytes(pub_bytes)).decode("ascii")
295
+
296
+
297
+ # ── COSE_Sign1 + certificate chain verification ──────────────────────────────
298
+
299
+ def _verify_cose_and_chain(cose_bytes: bytes) -> dict:
300
+ """Parse a COSE_Sign1, verify its signature + cert chain, return the payload.
301
+
302
+ Returns the decoded CBOR payload map (with int-keyed PCRs). Raises
303
+ :class:`AttestationError` on any structural, signature, or chain failure.
304
+ """
305
+ protected_bstr, payload_bytes, signature = _parse_cose_sign1(cose_bytes)
306
+
307
+ # Decode the payload (CBOR map) — the attestation fields live here.
308
+ try:
309
+ payload = _cbor_load(payload_bytes)
310
+ except Exception as exc: # noqa: BLE001
311
+ raise AttestationError(f"attestation payload is not valid CBOR: {exc}") from exc
312
+ if not isinstance(payload, dict):
313
+ raise AttestationError("attestation payload is not a CBOR map")
314
+
315
+ # Leaf certificate (signs the COSE_Sign1) + CA bundle (root-first).
316
+ leaf_der = payload.get("certificate")
317
+ cabundle = payload.get("cabundle") or []
318
+ if not isinstance(leaf_der, (bytes, bytearray)):
319
+ raise AttestationError("attestation payload has no leaf certificate")
320
+ if not isinstance(cabundle, list):
321
+ raise AttestationError("attestation cabundle is not a list")
322
+ try:
323
+ leaf = x509.load_der_x509_certificate(bytes(leaf_der))
324
+ except Exception as exc: # noqa: BLE001
325
+ raise AttestationError(f"invalid leaf certificate: {exc}") from exc
326
+
327
+ # 1) COSE_Sign1 signature against the leaf public key.
328
+ _verify_cose_signature(protected_bstr, payload_bytes, signature, leaf)
329
+
330
+ # 2) Certificate chain: leaf -> cabundle -> pinned AWS Nitro root.
331
+ _verify_cert_chain(leaf, [bytes(c) for c in cabundle])
332
+
333
+ return payload
334
+
335
+
336
+ def _parse_cose_sign1(cose_bytes: bytes) -> tuple[bytes, bytes, bytes]:
337
+ """Decode a COSE_Sign1 into (protected_bstr, payload_bstr, signature).
338
+
339
+ A COSE_Sign1 is a CBOR array (optionally tag 18) of four elements:
340
+ ``[protected: bstr, unprotected: map, payload: bstr, signature: bstr]``.
341
+ We keep ``protected`` and ``payload`` as raw byte strings because the
342
+ Sig_structure that gets signed reuses their exact serialised bytes.
343
+ """
344
+ try:
345
+ obj = _cbor_load(cose_bytes)
346
+ except Exception as exc: # noqa: BLE001
347
+ raise AttestationError(f"attestation is not valid CBOR/COSE: {exc}") from exc
348
+ # cbor2 surfaces the tag as a CBORTag; the minimal reader unwraps tag 18.
349
+ if _cbor2 is not None and isinstance(obj, _cbor2.CBORTag):
350
+ obj = obj.value
351
+ if not isinstance(obj, (list, tuple)) or len(obj) != 4:
352
+ raise AttestationError("COSE_Sign1 must be a 4-element array")
353
+ protected, _unprotected, payload, signature = obj
354
+ if not isinstance(protected, (bytes, bytearray)):
355
+ raise AttestationError("COSE_Sign1 protected header must be a byte string")
356
+ if not isinstance(payload, (bytes, bytearray)):
357
+ raise AttestationError("COSE_Sign1 payload must be a byte string (detached not supported)")
358
+ if not isinstance(signature, (bytes, bytearray)):
359
+ raise AttestationError("COSE_Sign1 signature must be a byte string")
360
+ return bytes(protected), bytes(payload), bytes(signature)
361
+
362
+
363
+ def _verify_cose_signature(
364
+ protected: bytes, payload: bytes, signature: bytes, leaf: x509.Certificate
365
+ ) -> None:
366
+ """Verify the COSE_Sign1 signature over the Sig_structure with the leaf key.
367
+
368
+ Sig_structure (RFC 8152 §4.4) for COSE_Sign1::
369
+
370
+ [ "Signature1", protected, external_aad (b""), payload ]
371
+
372
+ AWS Nitro uses ECDSA over P-384 (COSE alg ES384). The COSE signature is the
373
+ raw ``r || s`` concatenation; ``cryptography`` wants a DER-encoded ECDSA
374
+ signature, so we re-encode. RSA leaves are also handled defensively.
375
+ """
376
+ sig_structure = _cbor_dump(["Signature1", protected, b"", payload])
377
+
378
+ pub = leaf.public_key()
379
+ try:
380
+ if isinstance(pub, ec.EllipticCurvePublicKey):
381
+ der_sig = _raw_ecdsa_to_der(signature)
382
+ pub.verify(der_sig, sig_structure, ec.ECDSA(_curve_hash(pub.curve)))
383
+ elif isinstance(pub, rsa.RSAPublicKey):
384
+ pub.verify(signature, sig_structure, padding.PKCS1v15(), hashes.SHA384())
385
+ else: # pragma: no cover - AWS never uses these
386
+ raise AttestationError(
387
+ f"unsupported leaf key type for COSE verify: {type(pub).__name__}"
388
+ )
389
+ except InvalidSignature as exc:
390
+ raise AttestationError("COSE_Sign1 signature is invalid (bad AWS signature)") from exc
391
+ except AttestationError:
392
+ raise
393
+ except Exception as exc: # noqa: BLE001
394
+ raise AttestationError(f"COSE_Sign1 signature verification failed: {exc}") from exc
395
+
396
+
397
+ def _verify_cert_chain(leaf: x509.Certificate, cabundle_der: list[bytes]) -> None:
398
+ """Verify leaf -> cabundle -> pinned AWS Nitro root.
399
+
400
+ ``cabundle`` is ordered **root-first** in the AWS document. We build the
401
+ ordered chain leaf..root and check every link (child signed by the next
402
+ cert's public key). The top of the presented chain must be signed by the
403
+ pinned root, and we anchor trust in that pinned root (identity check against
404
+ the top intermediate's issuer / self-signed root).
405
+
406
+ Each certificate's validity window (notBefore/notAfter) is also enforced.
407
+ """
408
+ root = _root_ca_cert()
409
+
410
+ # Parse intermediates. AWS orders cabundle root-first; reverse to leaf->root.
411
+ intermediates: list[x509.Certificate] = []
412
+ for der in cabundle_der:
413
+ try:
414
+ intermediates.append(x509.load_der_x509_certificate(der))
415
+ except Exception as exc: # noqa: BLE001
416
+ raise AttestationError(f"invalid certificate in cabundle: {exc}") from exc
417
+ intermediates_leaf_first = list(reversed(intermediates))
418
+
419
+ # Ordered chain from leaf up to (but not including) the pinned root.
420
+ chain = [leaf] + intermediates_leaf_first
421
+
422
+ now = _utcnow()
423
+ for cert in chain + [root]:
424
+ _check_validity_window(cert, now)
425
+
426
+ # Each cert in the chain must be signed by the next one's public key; the
427
+ # last one in the chain must be signed by the pinned root.
428
+ for i, cert in enumerate(chain):
429
+ issuer_cert = chain[i + 1] if i + 1 < len(chain) else root
430
+ _check_signed_by(cert, issuer_cert)
431
+
432
+ # Anchor: the top of the presented chain must actually chain to OUR pinned
433
+ # root (issuer name match), and the root must be self-signed (trust anchor).
434
+ top = chain[-1]
435
+ if top.issuer != root.subject:
436
+ raise AttestationError(
437
+ "certificate chain does not terminate at the pinned AWS Nitro root"
438
+ )
439
+ _check_signed_by(root, root) # root is self-signed; verifies our anchor
440
+
441
+
442
+ def _check_signed_by(cert: x509.Certificate, issuer: x509.Certificate) -> None:
443
+ """Assert ``cert`` was signed by ``issuer``'s private key."""
444
+ issuer_pub = issuer.public_key()
445
+ try:
446
+ if isinstance(issuer_pub, ec.EllipticCurvePublicKey):
447
+ issuer_pub.verify(
448
+ cert.signature,
449
+ cert.tbs_certificate_bytes,
450
+ ec.ECDSA(cert.signature_hash_algorithm),
451
+ )
452
+ elif isinstance(issuer_pub, rsa.RSAPublicKey):
453
+ issuer_pub.verify(
454
+ cert.signature,
455
+ cert.tbs_certificate_bytes,
456
+ padding.PKCS1v15(),
457
+ cert.signature_hash_algorithm,
458
+ )
459
+ else: # pragma: no cover
460
+ raise AttestationError(
461
+ f"unsupported issuer key type: {type(issuer_pub).__name__}"
462
+ )
463
+ except InvalidSignature as exc:
464
+ raise AttestationError(
465
+ "broken certificate chain: a certificate is not signed by its issuer"
466
+ ) from exc
467
+ except AttestationError:
468
+ raise
469
+ except Exception as exc: # noqa: BLE001
470
+ raise AttestationError(f"certificate chain verification failed: {exc}") from exc
471
+
472
+
473
+ def _check_validity_window(cert: x509.Certificate, now: float) -> None:
474
+ """Reject a certificate that is not currently valid (expired / not yet valid)."""
475
+ try:
476
+ not_before = cert.not_valid_before_utc.timestamp()
477
+ not_after = cert.not_valid_after_utc.timestamp()
478
+ except AttributeError: # cryptography < 42 fallback
479
+ not_before = cert.not_valid_before.timestamp()
480
+ not_after = cert.not_valid_after.timestamp()
481
+ if now < not_before:
482
+ raise AttestationError("a certificate in the chain is not yet valid")
483
+ if now > not_after:
484
+ raise AttestationError("a certificate in the chain has expired")
485
+
486
+
487
+ # ── Freshness / anti-replay ──────────────────────────────────────────────────
488
+
489
+ def _check_freshness(timestamp_ms: Any) -> None:
490
+ """Reject a document whose timestamp is stale or in the future."""
491
+ if timestamp_ms is None:
492
+ raise AttestationError("attestation payload has no timestamp (cannot check freshness)")
493
+ if not isinstance(timestamp_ms, (int, float)):
494
+ raise AttestationError("attestation timestamp is not a number")
495
+ now_ms = time.time() * 1000.0
496
+ age = now_ms - float(timestamp_ms)
497
+ if age > MAX_AGE_MS:
498
+ raise AttestationError(
499
+ f"attestation document is stale (age {int(age)} ms > {MAX_AGE_MS} ms) - "
500
+ "possible replay"
501
+ )
502
+ if age < -FUTURE_SKEW_MS:
503
+ raise AttestationError("attestation document timestamp is in the future")
504
+
505
+
506
+ # ── Small helpers ────────────────────────────────────────────────────────────
507
+
508
+ def _normalize_pcr0(pcr0: str) -> str:
509
+ if not isinstance(pcr0, str) or not pcr0:
510
+ raise AttestationError("expected_pcr0 must be a non-empty hex string")
511
+ s = pcr0.strip().lower()
512
+ if s.startswith("0x"):
513
+ s = s[2:]
514
+ try:
515
+ bytes.fromhex(s)
516
+ except ValueError as exc:
517
+ raise AttestationError(f"expected_pcr0 is not valid hex: {exc}") from exc
518
+ return s
519
+
520
+
521
+ def _consttime_eq(a: str, b: str) -> bool:
522
+ """Constant-time-ish string compare (avoid early-exit timing on PCR0)."""
523
+ if len(a) != len(b):
524
+ return False
525
+ result = 0
526
+ for x, y in zip(a, b):
527
+ result |= ord(x) ^ ord(y)
528
+ return result == 0
529
+
530
+
531
+ def _curve_hash(curve: ec.EllipticCurve) -> hashes.HashAlgorithm:
532
+ """Pick the COSE hash for an EC curve (P-384 -> SHA-384, AWS default)."""
533
+ name = curve.name
534
+ if name == "secp256r1":
535
+ return hashes.SHA256()
536
+ if name == "secp521r1":
537
+ return hashes.SHA512()
538
+ return hashes.SHA384() # secp384r1 (AWS Nitro) and default
539
+
540
+
541
+ def _raw_ecdsa_to_der(raw: bytes) -> bytes:
542
+ """Convert a COSE raw ``r || s`` ECDSA signature to DER."""
543
+ if len(raw) % 2 != 0:
544
+ raise AttestationError("raw ECDSA signature has odd length")
545
+ half = len(raw) // 2
546
+ r = int.from_bytes(raw[:half], "big")
547
+ s = int.from_bytes(raw[half:], "big")
548
+ return encode_dss_signature(r, s)
549
+
550
+
551
+ def _utcnow() -> float:
552
+ return time.time()
553
+
554
+
555
+ # ── CBOR: prefer cbor2, else a minimal self-contained reader/writer ──────────
556
+ #
557
+ # The minimal reader/writer supports exactly what an NSM attestation document
558
+ # needs: unsigned/negative ints, byte strings, text strings, arrays, maps, the
559
+ # COSE tag (18), and simple values. It is intentionally small; ``cbor2`` is used
560
+ # when present.
561
+
562
+ def _cbor_load(data: bytes) -> Any:
563
+ if _cbor2 is not None:
564
+ return _cbor2.loads(data)
565
+ value, off = _MinimalCBOR.decode(data, 0)
566
+ return value
567
+
568
+
569
+ def _cbor_dump(obj: Any) -> bytes:
570
+ if _cbor2 is not None:
571
+ return _cbor2.dumps(obj)
572
+ return _MinimalCBOR.encode(obj)
573
+
574
+
575
+ class _MinimalCBOR:
576
+ """A tiny, dependency-free CBOR codec (subset for COSE/NSM).
577
+
578
+ Only the constructs used by NSM attestation + the COSE Sig_structure are
579
+ implemented. Not a general-purpose CBOR library.
580
+ """
581
+
582
+ # ---- decode ----
583
+ _BREAK = 0xFF # major 7, minor 31 — the "break" stop code for indefinite items
584
+
585
+ @staticmethod
586
+ def decode(data: bytes, off: int) -> tuple[Any, int]:
587
+ if off >= len(data):
588
+ raise ValueError("unexpected end of CBOR data")
589
+ ib = data[off]
590
+ major = ib >> 5
591
+ minor = ib & 0x1F
592
+ off += 1
593
+
594
+ # Indefinite-length items (minor 31): byte/text strings, arrays, maps run
595
+ # until a 0xFF break. AWS NSM attestation documents encode the payload map
596
+ # (and its ``pcrs`` sub-map) this way, so the verifier MUST handle it.
597
+ if minor == 31 and major in (2, 3, 4, 5):
598
+ return _MinimalCBOR._decode_indefinite(data, off, major)
599
+
600
+ val, off = _MinimalCBOR._read_len(data, off, minor)
601
+
602
+ if major == 0: # unsigned int
603
+ return val, off
604
+ if major == 1: # negative int
605
+ return -1 - val, off
606
+ if major == 2: # byte string
607
+ end = off + val
608
+ if end > len(data):
609
+ raise ValueError("truncated byte string")
610
+ return data[off:end], end
611
+ if major == 3: # text string
612
+ end = off + val
613
+ if end > len(data):
614
+ raise ValueError("truncated text string")
615
+ return data[off:end].decode("utf-8"), end
616
+ if major == 4: # array
617
+ arr = []
618
+ for _ in range(val):
619
+ item, off = _MinimalCBOR.decode(data, off)
620
+ arr.append(item)
621
+ return arr, off
622
+ if major == 5: # map
623
+ d = {}
624
+ for _ in range(val):
625
+ k, off = _MinimalCBOR.decode(data, off)
626
+ v, off = _MinimalCBOR.decode(data, off)
627
+ d[k] = v
628
+ return d, off
629
+ if major == 6: # tag
630
+ inner, off = _MinimalCBOR.decode(data, off)
631
+ return inner, off # unwrap tag (COSE tag 18) transparently
632
+ if major == 7: # simple / float
633
+ if minor == 20:
634
+ return False, off
635
+ if minor == 21:
636
+ return True, off
637
+ if minor == 22:
638
+ return None, off
639
+ if minor == 23:
640
+ return None, off # undefined
641
+ if minor == 25:
642
+ return struct.unpack(">e", data[off - 2:off])[0], off
643
+ if minor == 26:
644
+ return struct.unpack(">f", data[off - 4:off])[0], off
645
+ if minor == 27:
646
+ return struct.unpack(">d", data[off - 8:off])[0], off
647
+ return val, off
648
+ raise ValueError(f"unsupported CBOR major type {major}")
649
+
650
+ @staticmethod
651
+ def _decode_indefinite(data: bytes, off: int, major: int) -> tuple[Any, int]:
652
+ """Decode an indefinite-length item (major 2/3/4/5), consuming its break.
653
+
654
+ Items run until the 0xFF break byte. Strings concatenate their definite
655
+ chunks; arrays/maps collect elements until the break.
656
+ """
657
+ if major == 2: # byte string: concat definite chunks
658
+ out = b""
659
+ while True:
660
+ if off >= len(data):
661
+ raise ValueError("unterminated indefinite byte string")
662
+ if data[off] == _MinimalCBOR._BREAK:
663
+ return out, off + 1
664
+ chunk, off = _MinimalCBOR.decode(data, off)
665
+ if not isinstance(chunk, (bytes, bytearray)):
666
+ raise ValueError("bad chunk in indefinite byte string")
667
+ out += bytes(chunk)
668
+ if major == 3: # text string: concat definite chunks
669
+ out_s = ""
670
+ while True:
671
+ if off >= len(data):
672
+ raise ValueError("unterminated indefinite text string")
673
+ if data[off] == _MinimalCBOR._BREAK:
674
+ return out_s, off + 1
675
+ chunk, off = _MinimalCBOR.decode(data, off)
676
+ if not isinstance(chunk, str):
677
+ raise ValueError("bad chunk in indefinite text string")
678
+ out_s += chunk
679
+ if major == 4: # array
680
+ arr = []
681
+ while True:
682
+ if off >= len(data):
683
+ raise ValueError("unterminated indefinite array")
684
+ if data[off] == _MinimalCBOR._BREAK:
685
+ return arr, off + 1
686
+ item, off = _MinimalCBOR.decode(data, off)
687
+ arr.append(item)
688
+ # major == 5: map
689
+ d = {}
690
+ while True:
691
+ if off >= len(data):
692
+ raise ValueError("unterminated indefinite map")
693
+ if data[off] == _MinimalCBOR._BREAK:
694
+ return d, off + 1
695
+ k, off = _MinimalCBOR.decode(data, off)
696
+ v, off = _MinimalCBOR.decode(data, off)
697
+ d[k] = v
698
+
699
+ @staticmethod
700
+ def _read_len(data: bytes, off: int, minor: int) -> tuple[int, int]:
701
+ if minor < 24:
702
+ return minor, off
703
+ if minor == 24:
704
+ return data[off], off + 1
705
+ if minor == 25:
706
+ return struct.unpack(">H", data[off:off + 2])[0], off + 2
707
+ if minor == 26:
708
+ return struct.unpack(">I", data[off:off + 4])[0], off + 4
709
+ if minor == 27:
710
+ return struct.unpack(">Q", data[off:off + 8])[0], off + 8
711
+ raise ValueError(f"unsupported CBOR length encoding {minor}")
712
+
713
+ # ---- encode (only what _cbor_dump needs: str, bytes, list) ----
714
+ @staticmethod
715
+ def encode(obj: Any) -> bytes:
716
+ if isinstance(obj, str):
717
+ b = obj.encode("utf-8")
718
+ return _MinimalCBOR._head(3, len(b)) + b
719
+ if isinstance(obj, (bytes, bytearray)):
720
+ b = bytes(obj)
721
+ return _MinimalCBOR._head(2, len(b)) + b
722
+ if isinstance(obj, bool): # before int (bool is int subclass)
723
+ return b"\xf5" if obj else b"\xf4"
724
+ if isinstance(obj, int):
725
+ if obj < 0:
726
+ return _MinimalCBOR._head(1, -1 - obj)
727
+ return _MinimalCBOR._head(0, obj)
728
+ if obj is None:
729
+ return b"\xf6"
730
+ if isinstance(obj, (list, tuple)):
731
+ out = _MinimalCBOR._head(4, len(obj))
732
+ for item in obj:
733
+ out += _MinimalCBOR.encode(item)
734
+ return out
735
+ if isinstance(obj, dict):
736
+ out = _MinimalCBOR._head(5, len(obj))
737
+ for k, v in obj.items():
738
+ out += _MinimalCBOR.encode(k)
739
+ out += _MinimalCBOR.encode(v)
740
+ return out
741
+ raise ValueError(f"cannot CBOR-encode {type(obj).__name__}")
742
+
743
+ @staticmethod
744
+ def _head(major: int, length: int) -> bytes:
745
+ mt = major << 5
746
+ if length < 24:
747
+ return bytes([mt | length])
748
+ if length < 0x100:
749
+ return bytes([mt | 24, length])
750
+ if length < 0x10000:
751
+ return bytes([mt | 25]) + struct.pack(">H", length)
752
+ if length < 0x100000000:
753
+ return bytes([mt | 26]) + struct.pack(">I", length)
754
+ return bytes([mt | 27]) + struct.pack(">Q", length)