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/__init__.py +32 -0
- physis/attestation.py +754 -0
- physis/dispatcher.py +124 -0
- physis/envelope.py +253 -0
- physis/licensing.py +190 -0
- physis/metering.py +129 -0
- physis/physis_client.py +1921 -0
- physistool-0.1.0.dist-info/METADATA +68 -0
- physistool-0.1.0.dist-info/RECORD +13 -0
- physistool-0.1.0.dist-info/WHEEL +5 -0
- physistool-0.1.0.dist-info/entry_points.txt +2 -0
- physistool-0.1.0.dist-info/licenses/LICENSE +21 -0
- physistool-0.1.0.dist-info/top_level.txt +1 -0
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)
|