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 +6 -0
- iab/access.py +66 -0
- iab/admin.py +99 -0
- iab/attest.py +454 -0
- iab/attest_test.py +245 -0
- iab/authz.py +250 -0
- iab/cli.py +601 -0
- iab/dialect.py +374 -0
- iab/hub.py +756 -0
- iab/hub_test.py +907 -0
- iab/join.py +341 -0
- iab/pairing.py +315 -0
- iab/pairwords.py +46 -0
- iab/pki.py +332 -0
- iab/registry.py +689 -0
- iab/remote.py +357 -0
- iab/review.py +195 -0
- iab/server.py +248 -0
- iab/softtpm.py +149 -0
- iab/store.py +1679 -0
- iab/store_test.py +1413 -0
- iab/testdb.py +25 -0
- iab/tpm.py +207 -0
- iab/tpm_roots.pem +820 -0
- iab/tpm_test.py +199 -0
- iab/tpmtls.py +92 -0
- iab/wake.py +353 -0
- iab/worker.py +144 -0
- inter_agent_bus-0.15.0.dist-info/METADATA +414 -0
- inter_agent_bus-0.15.0.dist-info/RECORD +35 -0
- inter_agent_bus-0.15.0.dist-info/WHEEL +5 -0
- inter_agent_bus-0.15.0.dist-info/entry_points.txt +3 -0
- inter_agent_bus-0.15.0.dist-info/licenses/LICENSE-APACHE +176 -0
- inter_agent_bus-0.15.0.dist-info/licenses/LICENSE-MIT +21 -0
- inter_agent_bus-0.15.0.dist-info/top_level.txt +1 -0
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")
|