inter-agent-bus 0.15.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.
iab/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """inter-agent-bus package — pyproject.toml maps `iab` to this directory.
2
+
3
+ `iab.store` is the storage core (stdlib only), `iab.cli` the console
4
+ entry point; `server.py` (the MCP wrapper) additionally needs the MCP
5
+ SDK and is launched as a script by agent clients, not imported.
6
+ """
iab/access.py ADDED
@@ -0,0 +1,66 @@
1
+ """Access layer of the inter-agent bus — which backend serves the bus
2
+ operations.
3
+
4
+ The wrappers (server.py, cli.py and the drivers) call the operations
5
+ through `bus`, never through store.py directly, so that the same code
6
+ runs against two backends:
7
+
8
+ - LocalBackend: store.py itself, over the local database — the
9
+ default, unchanged behaviour (stdio, no daemon);
10
+ - RemoteBackend: a client of the hub (`iab hub serve`) — selected by
11
+ IAB_HUB.
12
+
13
+ The backend is resolved at every call from the environment: a process
14
+ never caches a choice its environment no longer makes. Pure helpers that
15
+ need no database (identity resolution, name splitting) stay in store.py
16
+ and are called there directly.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import os
21
+
22
+ try: # installed package: `iab` maps to this directory (pyproject.toml)
23
+ from . import store
24
+ except ImportError: # bare script usage
25
+ import store
26
+
27
+ # The store functions a client may call through a backend — the contract
28
+ # a hub exposes, one route per operation. Anything else is local.
29
+ OPERATIONS = frozenset({
30
+ "register_agent", "rename_agent",
31
+ "push_task", "wait_for_task", "claim_task", "publish_result",
32
+ "cancel_task", "requeue_task", "extend_lease", "read_result",
33
+ "heartbeat", "touch_presence", "touch_identity", "list_presence",
34
+ "announce", "read_channel", "notify", "broadcast", "replies", "poll",
35
+ "pending_signals", "wait_signal",
36
+ "acquire_wake_lock", "renew_wake_lock", "release_wake_lock",
37
+ "record_wake", "wake_count",
38
+ "get_system_state", "get_events", "db_info",
39
+ })
40
+
41
+
42
+ def backend():
43
+ """The backend serving this call: a hub client when IAB_HUB names a
44
+ hub, else store.py (LocalBackend)."""
45
+ hub = os.environ.get("IAB_HUB", "").strip()
46
+ if not hub:
47
+ return store
48
+ try: # imported only in hub mode
49
+ from . import remote
50
+ except ImportError:
51
+ import remote
52
+ return remote.backend_for(hub)
53
+
54
+
55
+ class _Bus:
56
+ """`bus.<operation>(…)` forwards to the backend of the moment; an
57
+ unknown name is refused (a local helper reached through `bus` would
58
+ silently bypass a hub)."""
59
+
60
+ def __getattr__(self, name: str):
61
+ if name not in OPERATIONS:
62
+ raise AttributeError(f"'{name}' is not a bus operation")
63
+ return getattr(backend(), name)
64
+
65
+
66
+ bus = _Bus()
iab/admin.py ADDED
@@ -0,0 +1,99 @@
1
+ """Admin driver of the multi-machine bus — what an admin machine asks the
2
+ hub: approve or deny a pairing, invite a machine, list and revoke
3
+ machines, trust a TPM's endorsement key, renew this machine's certificate.
4
+
5
+ Thin over the hub client (remote.py), through the bus listener, so the
6
+ hub checks the admin role on the authenticated machine certificate. The
7
+ only logic here is the approval dialogue: a person must read the
8
+ request and confirm the check words — there is deliberately no
9
+ non-interactive approval (machines deployed by Ansible use invitations).
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import os
15
+ import sys
16
+
17
+ try: # installed package (`iab` maps to this directory)
18
+ from . import registry, remote
19
+ except ImportError: # bare script usage
20
+ import registry
21
+ import remote
22
+
23
+
24
+ def _backend() -> remote.RemoteBackend | str:
25
+ url = os.environ.get("IAB_HUB") or remote.machine_info().get("hub")
26
+ if not url:
27
+ return "ERROR: no hub — set IAB_HUB (this machine must be paired as admin)."
28
+ return remote.RemoteBackend(url)
29
+
30
+
31
+ def call(action: str, body: dict | None = None):
32
+ """One admin action. Returns the result (dict/list/str) or an ERROR line."""
33
+ backend = _backend()
34
+ if isinstance(backend, str):
35
+ return backend
36
+ try:
37
+ status, answer = backend.request("POST", f"/v1/admin/{action}", body or {})
38
+ except remote.HubError as exc:
39
+ return str(exc)
40
+ if status != 200:
41
+ return str(answer.get("error") or f"ERROR: hub answered HTTP {status}.")
42
+ return answer["result"]
43
+
44
+
45
+ def approve(code: str | None, name: str | None, role: str,
46
+ ask=input, say=print) -> int:
47
+ if not sys.stdin.isatty() and ask is input:
48
+ say("ERROR: approval is interactive on purpose — run `iab approve` in a terminal "
49
+ "(use an invitation for unattended machines).")
50
+ return 1
51
+ code = code or ask("Code: ")
52
+ found = call("pairing/lookup", {"user_code": code})
53
+ if isinstance(found, str):
54
+ say(found)
55
+ return 1
56
+ shown = {k: registry.printable(v) for k, v in found.items() if k != "inherits"} # never raw to a terminal
57
+ say(f"Request from '{shown['name']}' — {shown['platform'] or 'unknown system'} — "
58
+ f"{shown['source']} — key: {shown['key_kind']}")
59
+ if found.get("attestation"):
60
+ say(f"TPM key attested: {registry.printable(found['attestation'], 200)}")
61
+ if found.get("inherits"):
62
+ inherited = ", ".join(registry.printable(a) for a in found["inherits"])
63
+ say(f"This name was paired before: the machine would inherit {inherited}")
64
+ say(f"Check words: {shown['words']}")
65
+ final = (name or found["name"]).strip().lower()
66
+ answer = ask(f"Are the four words identical to those shown on {shown['name']}? "
67
+ f"Pair it as '{final}' ({role}) [y/N] ").strip().lower()
68
+ if answer not in ("y", "yes", "o", "oui"):
69
+ denied = call("pairing/deny", {"user_code": code})
70
+ say("Not approved — request denied." if not str(denied).startswith("ERROR")
71
+ else str(denied))
72
+ return 1
73
+ done = call("pairing/approve", {"user_code": code, "name": final, "role": role})
74
+ if isinstance(done, str):
75
+ say(done)
76
+ return 1
77
+ say(f"Machine '{done['name']}' paired (certificate until {done['expires_at'][:10]}).")
78
+ return 0
79
+
80
+
81
+ def show(result) -> int:
82
+ if isinstance(result, str):
83
+ print(result)
84
+ return 1 if result.startswith("ERROR") else 0
85
+ print(json.dumps(result, indent=2, ensure_ascii=False))
86
+ return 0
87
+
88
+
89
+ def renew(force: bool) -> int:
90
+ backend = _backend()
91
+ if isinstance(backend, str):
92
+ print(backend)
93
+ return 1
94
+ try:
95
+ out = backend.renew(force=force)
96
+ except remote.HubError as exc:
97
+ out = str(exc)
98
+ print(out)
99
+ return 1 if out.startswith("ERROR") else 0
iab/attest.py ADDED
@@ -0,0 +1,454 @@
1
+ """TPM attestation, hub side — proof that a machine key was born in a TPM
2
+ chip and can never leave it, by credential activation against the chip's
3
+ endorsement key (EK).
4
+
5
+ Owner's decision of 2026-10-17 (phase 9e): a TPM key is attested, never
6
+ taken on the machine's word — a machine that merely declared `key: tpm`
7
+ could keep its key in a file and still become admin.
8
+
9
+ The exchange (pairing.py drives it, the client side is tpm.py):
10
+ 1. the joining machine sends, next to its certificate request, the
11
+ public area of its key and of its EK (TPM2B_PUBLIC), the EK
12
+ certificate when the chip carries one, and the intermediate
13
+ certificates it found;
14
+ 2. the hub checks the key's attributes — fixedTPM, fixedParent,
15
+ sensitiveDataOrigin: generated inside the chip, impossible to export
16
+ or duplicate — that it is the key of the request, and that the EK is
17
+ genuine: certified by a manufacturer CA shipped with the hub (or added
18
+ by the operator), or trusted by an admin through its fingerprint (a
19
+ virtual TPM has no manufacturer certificate);
20
+ 3. the hub encrypts a random secret to the EK, bound to the key's name
21
+ (TPM2_MakeCredential, computed here in software): only the chip that
22
+ holds that EK, with that very key loaded, gives the secret back
23
+ (TPM2_ActivateCredential) — a public area loaded alone cannot be
24
+ activated, so a software key dressed up with the right attributes
25
+ gets nothing;
26
+ 4. the machine returns the secret with its request.
27
+
28
+ What it proves: the key is non-exportable, in a chip whose EK the hub
29
+ trusts. What it does not prove: which person may pair — a human approval
30
+ (or an invitation) still decides that.
31
+
32
+ EK certificates are checked by signature only, never by date: the
33
+ manufacturer's signature states that the EK was born in its chip, a fact
34
+ an expiry date does not change (TPMs outlive their certificates).
35
+
36
+ Needs only `cryptography` (extra `[hub]`), like pki.py.
37
+ """
38
+ from __future__ import annotations
39
+
40
+ import base64
41
+ import binascii
42
+ import hashlib
43
+ import hmac
44
+ import os
45
+ from dataclasses import dataclass
46
+ from pathlib import Path
47
+ from typing import Callable
48
+
49
+ try: # installed package (`iab` maps to this directory)
50
+ from . import pki
51
+ except ImportError: # bare script usage
52
+ import pki
53
+
54
+ from cryptography import x509
55
+ from cryptography.exceptions import InvalidSignature, UnsupportedAlgorithm
56
+ from cryptography.hazmat.primitives import hashes
57
+ from cryptography.hazmat.primitives.asymmetric import ec, padding, rsa
58
+ from cryptography.hazmat.primitives.ciphers import Cipher, algorithms
59
+ from cryptography.x509.oid import NameOID
60
+
61
+ try: # recent cryptography: CFB lives with the legacy modes (the TPM mandates it)
62
+ from cryptography.hazmat.decrepit.ciphers.modes import CFB
63
+ except ImportError: # pragma: no cover — older cryptography
64
+ from cryptography.hazmat.primitives.ciphers.modes import CFB
65
+
66
+ # TPM 2.0 algorithm and curve identifiers (TCG Algorithm Registry).
67
+ ALG_RSA = 0x0001
68
+ ALG_AES = 0x0006
69
+ ALG_SHA256 = 0x000B
70
+ ALG_SHA384 = 0x000C
71
+ ALG_NULL = 0x0010
72
+ ALG_ECC = 0x0023
73
+ ALG_CFB = 0x0043
74
+ ECC_NIST_P256 = 0x0003
75
+ ECC_NIST_P384 = 0x0004
76
+
77
+ # TPMA_OBJECT bits.
78
+ FIXED_TPM = 1 << 1
79
+ FIXED_PARENT = 1 << 4
80
+ SENSITIVE_DATA_ORIGIN = 1 << 5
81
+ USER_WITH_AUTH = 1 << 6
82
+ ADMIN_WITH_POLICY = 1 << 7
83
+ RESTRICTED = 1 << 16
84
+ DECRYPT = 1 << 17
85
+ SIGN = 1 << 18
86
+ # Born in the chip, bound to it and to its parent: never exported.
87
+ BOUND = FIXED_TPM | FIXED_PARENT | SENSITIVE_DATA_ORIGIN
88
+
89
+ _HASHES = {ALG_SHA256: ("sha256", hashes.SHA256), ALG_SHA384: ("sha384", hashes.SHA384)}
90
+ _CURVES = {ECC_NIST_P256: ec.SECP256R1, ECC_NIST_P384: ec.SECP384R1}
91
+
92
+ ROOTS = Path(__file__).with_name("tpm_roots.pem")
93
+ MAX_PUBLIC = 1024 # a TPM2B_PUBLIC is a few hundred bytes
94
+ MAX_CERTIFICATE = 8192
95
+ MAX_CHAIN = 4
96
+ SECRET_BYTES = 32 # the credential: one SHA-256 digest
97
+
98
+
99
+ class AttestationError(ValueError):
100
+ pass
101
+
102
+
103
+ # -- TPM structures ----------------------------------------------------------
104
+
105
+ class _Reader:
106
+ def __init__(self, data: bytes) -> None:
107
+ self.data, self.pos = data, 0
108
+
109
+ def take(self, n: int) -> bytes:
110
+ if self.pos + n > len(self.data):
111
+ raise AttestationError("truncated TPM structure")
112
+ chunk = self.data[self.pos:self.pos + n]
113
+ self.pos += n
114
+ return chunk
115
+
116
+ def u16(self) -> int:
117
+ return int.from_bytes(self.take(2), "big")
118
+
119
+ def u32(self) -> int:
120
+ return int.from_bytes(self.take(4), "big")
121
+
122
+ def sized(self) -> bytes:
123
+ return self.take(self.u16())
124
+
125
+ def scheme(self) -> int:
126
+ # TPMT_*_SCHEME / TPMT_KDF_SCHEME: an algorithm, then its hash
127
+ # unless NULL (multi-part schemes such as ECDAA are refused).
128
+ alg = self.u16()
129
+ if alg != ALG_NULL:
130
+ self.u16()
131
+ return alg
132
+
133
+
134
+ def _sized(data: bytes) -> bytes:
135
+ return len(data).to_bytes(2, "big") + data
136
+
137
+
138
+ @dataclass(frozen=True)
139
+ class Public:
140
+ """A parsed TPMT_PUBLIC (the part of an object its name hashes)."""
141
+ area: bytes
142
+ type: int
143
+ name_alg: int
144
+ attributes: int
145
+ auth_policy: bytes
146
+ symmetric: tuple[int, int, int] # (algorithm, key bits, mode); NULL → (NULL, 0, 0)
147
+ key: object # the public key, as a cryptography object
148
+
149
+ @property
150
+ def name(self) -> bytes:
151
+ """The object's TPM name: its name algorithm, then the digest of its
152
+ public area — what a credential is bound to."""
153
+ algorithm, _ = _HASHES[self.name_alg]
154
+ return self.name_alg.to_bytes(2, "big") + hashlib.new(algorithm, self.area).digest()
155
+
156
+
157
+ def parse_public(blob: bytes) -> Public:
158
+ """Parse a marshaled TPM2B_PUBLIC (RSA or ECC object)."""
159
+ outer = _Reader(blob)
160
+ area = outer.sized()
161
+ if outer.pos != len(blob):
162
+ raise AttestationError("trailing bytes after the TPM public area")
163
+ r = _Reader(area)
164
+ kind, name_alg, attributes = r.u16(), r.u16(), r.u32()
165
+ if name_alg not in _HASHES:
166
+ raise AttestationError(f"unsupported TPM name algorithm 0x{name_alg:04x}")
167
+ auth_policy = r.sized()
168
+ sym_alg = r.u16()
169
+ symmetric = (sym_alg, r.u16(), r.u16()) if sym_alg != ALG_NULL else (ALG_NULL, 0, 0)
170
+ if kind == ALG_RSA:
171
+ r.scheme()
172
+ bits, exponent = r.u16(), r.u32() or 65537
173
+ modulus = r.sized()
174
+ if len(modulus) * 8 != bits:
175
+ raise AttestationError("RSA modulus does not match its declared size")
176
+ try:
177
+ key = rsa.RSAPublicNumbers(exponent, int.from_bytes(modulus, "big")).public_key()
178
+ except ValueError as exc:
179
+ raise AttestationError(f"invalid RSA key in the TPM public area: {exc}") from None
180
+ elif kind == ALG_ECC:
181
+ r.scheme()
182
+ curve_id = r.u16()
183
+ r.scheme() # kdf
184
+ x, y = r.sized(), r.sized()
185
+ if curve_id not in _CURVES:
186
+ raise AttestationError(f"unsupported TPM curve 0x{curve_id:04x}")
187
+ try:
188
+ key = ec.EllipticCurvePublicNumbers(
189
+ int.from_bytes(x, "big"), int.from_bytes(y, "big"), _CURVES[curve_id]()
190
+ ).public_key()
191
+ except ValueError as exc:
192
+ raise AttestationError(f"invalid ECC point in the TPM public area: {exc}") from None
193
+ else:
194
+ raise AttestationError(f"unsupported TPM object type 0x{kind:04x}")
195
+ if r.pos != len(area):
196
+ raise AttestationError("trailing bytes inside the TPM public area")
197
+ return Public(area, kind, name_alg, attributes, auth_policy, symmetric, key)
198
+
199
+
200
+ # -- TPM2_MakeCredential, in software ----------------------------------------
201
+
202
+ def kdfa(name_alg: int, key: bytes, label: bytes, context_u: bytes, context_v: bytes,
203
+ bits: int) -> bytes:
204
+ """TPM 2.0 KDFa (SP 800-108 counter mode, HMAC)."""
205
+ algorithm, _ = _HASHES[name_alg]
206
+ out, counter = b"", 1
207
+ while len(out) * 8 < bits:
208
+ out += hmac.new(key, counter.to_bytes(4, "big") + label + b"\0" + context_u
209
+ + context_v + bits.to_bytes(4, "big"), algorithm).digest()
210
+ counter += 1
211
+ return out[:bits // 8]
212
+
213
+
214
+ def kdfe(name_alg: int, z: bytes, label: bytes, party_u: bytes, party_v: bytes,
215
+ bits: int) -> bytes:
216
+ """TPM 2.0 KDFe (SP 800-56A concatenation KDF), for ECC secrets."""
217
+ algorithm, _ = _HASHES[name_alg]
218
+ out, counter = b"", 1
219
+ while len(out) * 8 < bits:
220
+ out += hashlib.new(algorithm, counter.to_bytes(4, "big") + z + label + b"\0"
221
+ + party_u + party_v).digest()
222
+ counter += 1
223
+ return out[:bits // 8]
224
+
225
+
226
+ def _seed(ek: Public) -> tuple[bytes, bytes]:
227
+ """A fresh seed and its encryption to the EK (TPM2B_ENCRYPTED_SECRET)."""
228
+ algorithm, hash_class = _HASHES[ek.name_alg]
229
+ size = hashlib.new(algorithm).digest_size
230
+ if ek.type == ALG_RSA:
231
+ seed = os.urandom(size)
232
+ encrypted = ek.key.encrypt(seed, padding.OAEP(mgf=padding.MGF1(hash_class()),
233
+ algorithm=hash_class(),
234
+ label=b"IDENTITY\0"))
235
+ return seed, _sized(encrypted)
236
+ curve = ek.key.curve
237
+ width = (curve.key_size + 7) // 8
238
+ ephemeral = ec.generate_private_key(type(curve)())
239
+ z = ephemeral.exchange(ec.ECDH(), ek.key)
240
+ point = ephemeral.public_key().public_numbers()
241
+ qx, qy = point.x.to_bytes(width, "big"), point.y.to_bytes(width, "big")
242
+ ek_x = ek.key.public_numbers().x.to_bytes(width, "big")
243
+ seed = kdfe(ek.name_alg, z, b"IDENTITY", qx, ek_x, size * 8)
244
+ return seed, _sized(_sized(qx) + _sized(qy))
245
+
246
+
247
+ def make_credential(ek: Public, credential: bytes, name: bytes) -> tuple[bytes, bytes]:
248
+ """TPM2_MakeCredential: `credential` sealed for the chip holding `ek`,
249
+ releasable only for the object called `name`. Returns the marshaled
250
+ TPM2B_ID_OBJECT and TPM2B_ENCRYPTED_SECRET."""
251
+ algorithm, _ = _HASHES[ek.name_alg]
252
+ size = hashlib.new(algorithm).digest_size
253
+ if len(credential) > size:
254
+ raise AttestationError("credential longer than the EK's name digest")
255
+ seed, secret = _seed(ek)
256
+ sym_key = kdfa(ek.name_alg, seed, b"STORAGE", name, b"", ek.symmetric[1])
257
+ encryptor = Cipher(algorithms.AES(sym_key), CFB(b"\0" * 16)).encryptor()
258
+ identity = encryptor.update(_sized(credential)) + encryptor.finalize()
259
+ hmac_key = kdfa(ek.name_alg, seed, b"INTEGRITY", b"", b"", size * 8)
260
+ integrity = hmac.new(hmac_key, identity + name, algorithm).digest()
261
+ return _sized(_sized(integrity) + identity), secret
262
+
263
+
264
+ # -- what the hub accepts ----------------------------------------------------
265
+
266
+ def _check_key(key: Public) -> None:
267
+ if key.attributes & BOUND != BOUND:
268
+ raise AttestationError("the key was not generated inside the TPM or can leave it "
269
+ "(fixedTPM, fixedParent and sensitiveDataOrigin required)")
270
+ if not key.attributes & SIGN or key.attributes & (RESTRICTED | DECRYPT):
271
+ raise AttestationError("the key must be an unrestricted signing key")
272
+ # Authorized by its secret value only: a public area loaded alone in a
273
+ # chip has none, so it cannot be activated. Chips also refuse a policy
274
+ # on such an object (checked on hardware, tpm_test.py); the hub does
275
+ # not rely on it.
276
+ if key.auth_policy or key.attributes & ADMIN_WITH_POLICY \
277
+ or not key.attributes & USER_WITH_AUTH:
278
+ raise AttestationError("the key must be authorized by its own secret, not by a "
279
+ "policy (empty authPolicy, userWithAuth, no adminWithPolicy)")
280
+ if key.type != ALG_ECC or not isinstance(key.key.curve, ec.SECP256R1) \
281
+ or key.name_alg != ALG_SHA256:
282
+ raise AttestationError("the key must be an ECC P-256 key named with SHA-256")
283
+
284
+
285
+ def _check_ek(ek: Public) -> None:
286
+ if ek.attributes & BOUND != BOUND or ek.attributes & (RESTRICTED | DECRYPT) \
287
+ != RESTRICTED | DECRYPT or ek.attributes & SIGN:
288
+ raise AttestationError("the endorsement key is not a restricted decryption key "
289
+ "bound to its chip")
290
+ alg, bits, mode = ek.symmetric
291
+ if alg != ALG_AES or mode != ALG_CFB or bits not in (128, 256):
292
+ raise AttestationError("the endorsement key does not use AES-CFB")
293
+ if ek.type == ALG_RSA and ek.key.key_size < 2048:
294
+ raise AttestationError("the endorsement key is an RSA key under 2048 bits")
295
+
296
+
297
+ def _cn(name: x509.Name) -> str:
298
+ attrs = name.get_attributes_for_oid(NameOID.COMMON_NAME)
299
+ return str(attrs[0].value) if attrs else name.rfc4514_string()
300
+
301
+
302
+ def issuer_name(cert: x509.Certificate) -> str:
303
+ return _cn(cert.issuer)
304
+
305
+
306
+ def _signed(cert: x509.Certificate, issuer: x509.Certificate) -> bool:
307
+ # Not verify_directly_issued_by: it compares the names' DER bytes, and
308
+ # manufacturers encode the same name differently in the EK certificate
309
+ # and in their CA's (PrintableString / UTF8String). The callers match
310
+ # names by value, as OpenSSL does; this checks the signature.
311
+ key = issuer.public_key()
312
+ try:
313
+ if isinstance(key, rsa.RSAPublicKey):
314
+ key.verify(cert.signature, cert.tbs_certificate_bytes,
315
+ cert.signature_algorithm_parameters, cert.signature_hash_algorithm)
316
+ elif isinstance(key, ec.EllipticCurvePublicKey):
317
+ key.verify(cert.signature, cert.tbs_certificate_bytes,
318
+ cert.signature_algorithm_parameters)
319
+ else:
320
+ return False
321
+ except (InvalidSignature, ValueError, TypeError, UnsupportedAlgorithm):
322
+ return False
323
+ return True
324
+
325
+
326
+ def _is_ca(cert: x509.Certificate) -> bool:
327
+ try:
328
+ return cert.extensions.get_extension_for_class(x509.BasicConstraints).value.ca
329
+ except x509.ExtensionNotFound:
330
+ return False
331
+
332
+
333
+ def load_certificates(data: bytes) -> list[x509.Certificate]:
334
+ """Every certificate of a PEM bundle, or the one of a DER file."""
335
+ if b"-----BEGIN CERTIFICATE-----" in data:
336
+ return x509.load_pem_x509_certificates(data)
337
+ return [x509.load_der_x509_certificate(data)]
338
+
339
+
340
+ class Trust:
341
+ """The CAs a hub accepts for EK certificates: the manufacturers' roots
342
+ shipped with the hub (tpm_roots.pem), plus whatever the operator drops
343
+ in <hub home>/tpm-ca (PEM or DER) — a missing intermediate, a
344
+ manufacturer the bundle lacks, the CA of a virtual TPM's host."""
345
+
346
+ def __init__(self, extra: Path | None = None) -> None:
347
+ self.anchors: list[x509.Certificate] = []
348
+ self.anchors += load_certificates(ROOTS.read_bytes())
349
+ if extra is not None and extra.is_dir():
350
+ for path in sorted(extra.iterdir()):
351
+ if path.suffix.lower() in (".pem", ".crt", ".cer", ".der"):
352
+ try:
353
+ self.anchors += load_certificates(path.read_bytes())
354
+ except ValueError as exc:
355
+ raise AttestationError(f"unreadable CA file {path}: {exc}") from None
356
+
357
+ def certified_by(self, cert: x509.Certificate, chain: list[x509.Certificate]) -> str:
358
+ """The name of the CA that issued `cert`, once its chain reaches a
359
+ trusted CA; AttestationError otherwise."""
360
+ current, pool = cert, list(chain)
361
+ for _ in range(MAX_CHAIN + 2):
362
+ for anchor in self.anchors:
363
+ if anchor.subject == current.issuer and _signed(current, anchor):
364
+ return _cn(cert.issuer)
365
+ for candidate in pool:
366
+ if candidate.subject == current.issuer and _is_ca(candidate) \
367
+ and _signed(current, candidate):
368
+ current = candidate
369
+ pool.remove(candidate)
370
+ break
371
+ else:
372
+ raise AttestationError(f"the EK certificate does not chain to a TPM "
373
+ f"manufacturer CA this hub trusts (issuer: "
374
+ f"'{_cn(current.issuer)}')")
375
+ raise AttestationError("the EK certificate chain is too long")
376
+
377
+
378
+ @dataclass(frozen=True)
379
+ class Checked:
380
+ ek: Public
381
+ name: bytes # the machine key's TPM name
382
+ ek_fingerprint: str
383
+ description: str # how the EK was trusted, for the registry and the approver
384
+
385
+
386
+ def _decode(value, limit: int, what: str) -> bytes:
387
+ if not isinstance(value, str):
388
+ raise AttestationError(f"missing {what}")
389
+ try:
390
+ data = base64.b64decode(value, validate=True)
391
+ except (binascii.Error, ValueError):
392
+ raise AttestationError(f"{what} is not base64") from None
393
+ if not data or len(data) > limit:
394
+ raise AttestationError(f"{what} has an unexpected size")
395
+ return data
396
+
397
+
398
+ def check(material, csr_key, trust: Trust,
399
+ pinned: Callable[[str], dict | None]) -> Checked | str:
400
+ """Check what a joining machine sends about its TPM (see the module
401
+ docstring, step 2). `pinned(fingerprint)` answers whether an admin
402
+ trusts that EK. Returns what the challenge needs, or an ERROR line."""
403
+ try:
404
+ if not isinstance(material, dict):
405
+ raise AttestationError("a TPM key needs its attestation material")
406
+ key = parse_public(_decode(material.get("public"), MAX_PUBLIC, "the key's public area"))
407
+ _check_key(key)
408
+ if not pki.same_key(key.key, csr_key):
409
+ raise AttestationError("the TPM key is not the key of the certificate request")
410
+ ek = parse_public(_decode(material.get("ek_public"), MAX_PUBLIC,
411
+ "the endorsement key's public area"))
412
+ _check_ek(ek)
413
+ fingerprint = pki.spki_sha256(ek.key)
414
+ pin = pinned(fingerprint)
415
+ if pin is not None:
416
+ return Checked(ek, key.name, fingerprint,
417
+ f"EK sha256:{fingerprint}, trusted by {pin['added_by']}")
418
+ hint = (f" — an admin may trust it once, after reading its fingerprint on the "
419
+ f"machine itself: iab trust-ek {fingerprint}")
420
+ if material.get("ek_certificate") is None:
421
+ raise AttestationError("no manufacturer certificate for this TPM's endorsement "
422
+ "key (a virtual TPM?)" + hint)
423
+ try:
424
+ cert = x509.load_der_x509_certificate(
425
+ _decode(material["ek_certificate"], MAX_CERTIFICATE, "the EK certificate"))
426
+ raw_chain = material.get("chain") or []
427
+ if not isinstance(raw_chain, list) or len(raw_chain) > MAX_CHAIN:
428
+ raise AttestationError("unexpected EK certificate chain")
429
+ chain = [x509.load_der_x509_certificate(
430
+ _decode(c, MAX_CERTIFICATE, "an intermediate certificate")) for c in raw_chain]
431
+ except AttestationError:
432
+ raise
433
+ except ValueError as exc:
434
+ raise AttestationError(f"unreadable EK certificate ({exc})" + hint) from None
435
+ if not pki.same_key(cert.public_key(), ek.key):
436
+ raise AttestationError("the EK certificate is not for this endorsement key")
437
+ try:
438
+ issuer = trust.certified_by(cert, chain)
439
+ except AttestationError as exc:
440
+ raise AttestationError(f"{exc}{hint}") from None
441
+ return Checked(ek, key.name, fingerprint,
442
+ f"EK sha256:{fingerprint}, certified by {issuer}")
443
+ except AttestationError as exc:
444
+ return f"ERROR: TPM attestation refused: {exc}."
445
+
446
+
447
+ def normalize_fingerprint(text: str) -> str | None:
448
+ """An EK fingerprint as typed by a person (colons, spaces, any case)."""
449
+ value = "".join(c for c in str(text).lower() if c not in ": ")
450
+ return value if len(value) == 64 and all(c in "0123456789abcdef" for c in value) else None
451
+
452
+
453
+ def encode(data: bytes) -> str:
454
+ return base64.b64encode(data).decode("ascii")