Files
PlantAI-rag-new/tooling/crypto/client/plantai_core.py
T
jerryandClaude Fable 5 27946edc2b P0/M1: 收编 encryption-test 工具链入 tooling/
- legacy_plantai_server_package/: phase_a–f + compile_* + bin/plantai_admin.py
  + tools/(audit/elf_visibility/smoke/build_delivery_manifest) 完整基线
- legacy_docker_packager/: protected 形态参考
- crypto/client/: plantai_core.py(AES-GCM 原语) + install_plantai.sh 参考
- secrets/provision 严格排除(gitignore),入 git 前已脱敏清理
- LEGACY_MAPPING.md 记录源与改造方向

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 12:59:29 +08:00

1019 lines
43 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
PlantAI CLIENT-SIDE core library.
Splitting logic out of plantai_client.py makes the two workflow phases
independently testable:
Phase A (client): fingerprint collection + TPM provisioning
(collect_fingerprint / tpm2_setup_key / tpm2_certify_wrapkey)
Phase E (server): K_master wrapping + license issuance using a fingerprint
(handled by plantai_admin.py, which only needs a JSON file
produced by this library — no client import required)
By design this module contains NO private key handling and NO random key
generation for licenses or assets. It can only:
- collect this machine's hardware fingerprint (to send to the vendor)
- verify a license signature using the supplier PUBLIC key
- unwrap an already-issued asset key envelope and decrypt asset packages
Signing and master-key handling only exist in plantai_admin.py, which lives
on the vendor's server and is never shipped.
"""
from __future__ import annotations
import argparse
import base64
import hashlib
import json
import os
import shutil
import socket
import subprocess
import tarfile
import tempfile
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
# ---------------------------------------------------------------------------
# generic helpers
# ---------------------------------------------------------------------------
def now_iso() -> str:
return datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds")
def read_text(path: Path) -> str:
try:
return path.read_text(encoding="utf-8").strip()
except Exception:
return ""
def run(cmd: list[str]) -> str:
try:
return subprocess.check_output(cmd, text=True, stderr=subprocess.DEVNULL).strip()
except Exception:
return ""
def canonical_bytes(data: Any) -> bytes:
return json.dumps(data, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
def sha256_hex(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
def load_json(path: Path) -> Any:
return json.loads(path.read_text(encoding="utf-8"))
def write_json(path: Path, data: Any) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(data, ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8")
def log_audit(root: Path, event: dict[str, Any]) -> None:
event = dict(event)
event.setdefault("at", now_iso())
path = root / "var" / "audit" / "launcher-audit.jsonl"
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("a", encoding="utf-8") as f:
f.write(json.dumps(event, ensure_ascii=False) + "\n")
# ===========================================================================
# TPM 2.0 support — hardware-bound K_master unwrapping
# ===========================================================================
# Problem this solves:
# The legacy (v2) wrapping uses HKDF(core_id_hash, salt) where BOTH
# parameters are stored in asset.key.enc. Anyone with the client package
# can recompute the wrap key and decrypt K_master — this is NOT hardware
# binding, it's just obfuscation.
#
# TPM-based (v3) wrapping provides REAL hardware binding:
# 1. Client runs "tpm2-setup" ONCE:
# → A non-exportable RSA-2048 key is created INSIDE the TPM chip.
# → The private key NEVER exists in plaintext outside the TPM.
# → The public key is exported and sent to the vendor.
# 2. Vendor wraps K_master with RSA-OAEP using the client's TPM public key.
# → Only the TPM holding the corresponding private key can unwrap it.
# 3. At runtime, the client asks the TPM to decrypt:
# → tpm2_rsadecrypt returns K_master; the private key never leaves the TPM.
#
# Requirements:
# - Linux kernel >= 4.12 with TPM 2.0 (/dev/tpm0 or /dev/tpmrm0)
# - tpm2-tools >= 5.x (apt install tpm2-tools / dnf install tpm2-tools)
# - Read access to TPM device (user in tss group, or run as root)
#
# Without a TPM, the system falls back to legacy HKDF wrapping and prints
# a clear warning that this is weak protection.
# ===========================================================================
_TPM_HANDLE_BASE = 0x81008000 # Well-known persistent-handle range
_TPM_STATE_NAME = "tpm_wrapkey_state.json"
class TpmBackend:
"""Abstraction over the tpm2-tools command layer.
The real backend shells out to tpm2-tools. Tests substitute a fake
backend (or monkeypatch the module-level ``_TPM`` instance) so the
provisioning logic can run without a physical TPM / swtpm.
"""
def run(self, *args: str) -> str:
"""Run a tpm2-tools command. Returns stdout. Raises SystemExit on failure."""
cmd = list(args)
try:
output = subprocess.check_output(cmd, stderr=subprocess.PIPE)
# Some tpm2-tools commands emit binary status/signature bytes on
# stdout even when their useful artifacts are directed to files.
# Text-producing commands remain UTF-8; ignored binary stdout must
# never crash the workflow with UnicodeDecodeError.
return output.decode("utf-8", errors="replace").strip()
except subprocess.CalledProcessError as e:
stderr_text = (e.stderr or "").strip()
raise SystemExit(
f"TPM command failed: {' '.join(cmd)}\n"
f" exit={e.returncode} stderr={stderr_text}"
)
except FileNotFoundError:
raise SystemExit(
"tpm2-tools is not installed.\n"
" Debian/Ubuntu: sudo apt install tpm2-tools\n"
" RHEL/CentOS: sudo dnf install tpm2-tools\n"
" Arch: sudo pacman -S tpm2-tools"
)
def is_available(self) -> bool:
"""Check whether TPM 2.0 userspace + kernel device are functional."""
try:
subprocess.run(
["tpm2_getrandom", "4"],
check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
timeout=10,
)
return True
except Exception:
return False
def flush_transients(self) -> None:
"""Free libtpms' three transient slots between CLI context loads.
tpm2-tools may leave a loaded copy behind when a saved context file
is passed to the next command. Only do this for the explicit swtpm
test backend; flushing all transient objects on a real/shared TPM is
unsafe.
"""
if os.environ.get("TPM2TOOLS_TCTI", "").startswith("swtpm:"):
subprocess.run(
["tpm2_flushcontext", "-t"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
)
# Default backend used by the production CLI. Tests replace this instance.
_TPM = TpmBackend()
def tpm2_is_available() -> bool:
"""Check whether TPM 2.0 userspace + kernel device are functional."""
return _TPM.is_available()
def read_public_key_pem(handle: int, tpm: TpmBackend | None = None) -> str:
"""Export the public key (PEM) of a persistent TPM-resident key.
NOTE: tpm2-readtools 5.x NEVER prints the PEM on stdout — stdout always
carries the human-readable TPMT_PUBLIC dump regardless of -f. The PEM
only lands in the file given via -o, so we write it to a temp file and
read it back. (Capturing stdout instead produced the text dump and made
the server-side load_pem_public_key() fail with MalformedFraming.)
"""
tpm = tpm or _TPM
tpm.flush_transients()
with tempfile.TemporaryDirectory(prefix="plantai-pem-") as tmp:
pem_path = Path(tmp) / "wrapkey_pub.pem"
tpm.run("tpm2_readpublic", "-c", f"0x{handle:08X}", "-f", "pem",
"-o", str(pem_path))
return pem_path.read_text(encoding="utf-8").strip()
def tpm2_try_load_state(root: Path) -> dict[str, Any] | None:
"""If a TPM wrapping key state file exists, return it. Otherwise None."""
state_path = root / "config" / "tpm" / _TPM_STATE_NAME
if state_path.exists():
return load_json(state_path)
return None
def tpm2_setup_key(root: Path, tpm: TpmBackend | None = None) -> dict[str, Any]:
"""One-time TPM provisioning: create a non-exportable RSA-2048 key inside
the TPM, persist it across reboots, and return its public metadata.
Idempotent — if a key already exists, just re-export the public key
instead of creating a new one (which would invalidate existing licenses).
"""
tpm = tpm or _TPM
if not tpm.is_available():
raise SystemExit(
"TPM 2.0 is not available on this machine.\n\n"
"Check with:\n"
" ls /dev/tpm* # should show /dev/tpm0 or /dev/tpmrm0\n"
" sudo dmesg | grep -i tpm # kernel detection log\n"
" groups # should include 'tss'\n\n"
"If this machine has no TPM (common on cloud VMs without vTPM),\n"
"use the legacy path — it provides weaker (file-level) protection."
)
tpm_dir = root / "config" / "tpm"
tpm_dir.mkdir(parents=True, exist_ok=True)
state_path = tpm_dir / _TPM_STATE_NAME
# ---- Already set up? ----
if state_path.exists():
state = load_json(state_path)
handle = state["persistent_handle"]
try:
pubkey_pem = read_public_key_pem(handle, tpm)
except SystemExit:
raise SystemExit(
f"TPM persistent key at handle 0x{handle:08X} is missing.\n"
"The TPM may have been cleared or the key evicted.\n"
f"Remove {state_path} and re-run tpm2-setup."
)
# Repair a previous partial setup without rotating the wrapping key.
# This is especially useful for swtpm, where transient-slot exhaustion
# may have allowed persistence but interrupted attestation generation.
if not state.get("attestation"):
tpm.flush_transients()
print("Existing TPM key has no attestation — generating it now …")
state["attestation"] = tpm2_certify_wrapkey(root, tpm)
write_json(state_path, state)
print(f"TPM key already exists at persistent handle 0x{handle:08X} — reusing it.")
result = {
"tpm_version": "2.0",
"persistent_handle": handle,
"algorithm": "RSA-2048-OAEP-SHA256",
"public_key_pem": pubkey_pem,
"public_key_sha256": sha256_hex(pubkey_pem.encode()),
}
if state.get("attestation"):
result["attestation"] = state["attestation"]
return result
# ---- Create a new non-exportable key ----
primary_ctx = tpm_dir / "primary.ctx"
child_pub = tpm_dir / "wrapkey.pub"
child_priv = tpm_dir / "wrapkey.priv"
child_ctx = tpm_dir / "wrapkey.ctx"
# 1. Create a primary key under the storage (owner) hierarchy.
# This "primary key" is a TPM-internal root that never leaves the chip.
print("Creating TPM primary key (storage hierarchy) …")
tpm.run("tpm2_createprimary", "-C", "o",
"-g", "sha256", "-G", "rsa",
"-c", str(primary_ctx))
tpm.flush_transients()
# 2. Create a child RSA-2048 key for K_master wrapping.
# Key attributes (each one is critical):
# fixedtpm — cannot be duplicated to another TPM chip
# fixedparent — bound to this specific primary key
# sensitivedataorigin — private key material generated by TPM's RNG
# userwithauth — allows setting an auth value (we leave it empty)
# decrypt — key can perform RSA decryption (OAEP)
print("Creating non-exportable TPM wrapping key (RSA-2048) …")
tpm.run("tpm2_create", "-C", str(primary_ctx),
# This is an unrestricted RSA decryption child used with OAEP.
# Its symmetric definition must be NULL. aes128cfb is appropriate
# for a restricted storage parent, but is rejected for this child by
# conforming TPMs (including swtpm/libtpms) with TPM_RC_SYMMETRIC.
"-g", "sha256", "-G", "rsa2048:null",
"-a", "fixedtpm|fixedparent|sensitivedataorigin|userwithauth|decrypt",
"-u", str(child_pub),
"-r", str(child_priv))
tpm.flush_transients()
# 3. Load the key into the TPM (it's still transient — gone after reboot)
tpm.run("tpm2_load", "-C", str(primary_ctx),
"-u", str(child_pub), "-r", str(child_priv),
"-c", str(child_ctx))
tpm.flush_transients()
# 4. Persist the key so it survives reboots.
# Persistent handles live in the range 0x81000000–0x81FFFFFF.
persistent_handle = None
for offset in range(3):
handle = _TPM_HANDLE_BASE + offset
try:
tpm.run("tpm2_evictcontrol", "-C", "o",
"-c", str(child_ctx),
f"0x{handle:08X}")
persistent_handle = handle
break
except SystemExit:
continue
if persistent_handle is None:
raise SystemExit(
"Could not persist TPM key — all candidate handles are occupied.\n"
"List persistent handles: tpm2_getcap handles-persistent\n"
"Free one with: tpm2_evictcontrol -C o -c <handle>"
)
# 5. Export the public key (PEM) — this is what we send to the vendor.
# The private key stays inside the TPM; it CANNOT be exported.
pubkey_pem = read_public_key_pem(persistent_handle, tpm)
(tpm_dir / "wrapkey_pub.pem").write_text(pubkey_pem)
# 6. Save state so the unwrap path knows which handle to use.
state: dict[str, Any] = {
"persistent_handle": persistent_handle,
"algorithm": "RSA-2048-OAEP-SHA256",
"public_key_sha256": sha256_hex(pubkey_pem.encode()),
"created_at": now_iso(),
}
write_json(state_path, state)
# 7. Generate a TPM attestation that cryptographically proves the
# wrapping key is TPM-resident with the claimed attributes.
# The server uses this to verify the key is not a software fake.
try:
attestation = tpm2_certify_wrapkey(root, tpm)
state["attestation"] = attestation
write_json(state_path, state)
except SystemExit as e:
print(f"WARNING: Could not create TPM attestation: {e}")
print("The wrapping key is functional, but the server will not be")
print("able to cryptographically verify it is TPM-resident.")
print("Re-run tpm2-setup after resolving the issue to enable attestation.")
state["attestation"] = None
write_json(state_path, state)
# Clean up intermediate files — the real key lives in TPM silicon now
for p in [primary_ctx, child_pub, child_priv, child_ctx]:
p.unlink(missing_ok=True)
print(f"TPM wrapping key created and persisted at handle 0x{persistent_handle:08X}")
return {
"tpm_version": "2.0",
"persistent_handle": persistent_handle,
"algorithm": "RSA-2048-OAEP-SHA256",
"public_key_pem": pubkey_pem,
"public_key_sha256": state["public_key_sha256"],
}
def tpm2_certify_wrapkey(root: Path, tpm: TpmBackend | None = None) -> dict[str, Any]:
"""Use TPM2_Certify to cryptographically prove the wrapping key's attributes.
Creates a temporary signing key inside the TPM, then asks the TPM to sign
a TPMS_ATTEST structure that certifies:
- The wrapping key is TPM-resident (not a software-generated key)
- The wrapping key was created with the claimed attributes
(fixedtpm | sensitivedataorigin | decrypt | …)
The server can verify this attestation signature to confirm the key
really lives inside a TPM — something a software-only key cannot fake.
LIMITATION: Without a full TPM remote-attestation chain (EK → AK → certify),
a software TPM emulator could still forge this. For production, combine
this with a trusted provisioning environment or TPM EK certificate
verification, or pre-provision TPM keys at the factory.
"""
tpm = tpm or _TPM
state = tpm2_try_load_state(root)
if state is None:
raise SystemExit("No TPM state found. Run tpm2-setup first.")
tpm_dir = root / "config" / "tpm"
handle = state["persistent_handle"]
# We create a fresh primary key + signing key each time because the
# certifying key is temporary — only its public key is exported for
# the server to verify the attestation signature.
cert_primary_ctx = tpm_dir / "_cert_primary.ctx"
sign_pub = tpm_dir / "_cert_sign.pub"
sign_priv = tpm_dir / "_cert_sign.priv"
sign_ctx = tpm_dir / "_cert_sign.ctx"
attest_path = tpm_dir / "wrapkey.attest"
sig_path = tpm_dir / "wrapkey.attest.sig"
print("Creating TPM attestation for wrapping key …")
tpm.run("tpm2_createprimary", "-C", "o",
"-g", "sha256", "-G", "rsa",
"-c", str(cert_primary_ctx))
tpm.flush_transients()
# Create a signing-only RSA key to act as the certifying key.
# Attributes mirror the wrapping key: fixedtpm | fixedparent |
# sensitivedataorigin | userwithauth | sign.
tpm.run("tpm2_create", "-C", str(cert_primary_ctx),
"-g", "sha256", "-G", "rsa2048:rsassa:null",
"-a", "fixedtpm|fixedparent|sensitivedataorigin|userwithauth|sign",
"-u", str(sign_pub),
"-r", str(sign_priv))
tpm.flush_transients()
tpm.run("tpm2_load", "-C", str(cert_primary_ctx),
"-u", str(sign_pub), "-r", str(sign_priv),
"-c", str(sign_ctx))
tpm.flush_transients()
# Export the certifying key's public key so the server can verify
# the attestation signature. Same temp-file requirement as the
# wrapping key PEM (see read_public_key_pem).
sign_pub_pem_path = tpm_dir / "_cert_sign_pub.pem"
tpm.run("tpm2_readpublic", "-c", str(sign_ctx), "-f", "pem",
"-o", str(sign_pub_pem_path))
sign_pub_pem = sign_pub_pem_path.read_text(encoding="utf-8").strip()
sign_pub_pem_path.unlink(missing_ok=True)
# TPM2_Certify: the TPM signs a TPMS_ATTEST structure that binds the
# wrapping key's public area (including its attributes) to this TPM.
# Use long option names to be unambiguous — short flag -C vs -c
# conventions have changed across tpm2-tools versions.
# --certifiedkey-context = the object whose properties are being
# certified (the wrapping key)
# --signingkey-context = the key that signs the attestation
# (the temporary certifying key)
tpm.run("tpm2_certify",
"--certifiedkey-context", f"0x{handle:08X}",
"--signingkey-context", str(sign_ctx),
"-g", "sha256",
"--attestation", str(attest_path),
"--signature", str(sig_path),
"--format", "tss")
# Export the wrapping key's raw public area (TPMT_PUBLIC) so the server
# can compute its TPM Name and verify it matches the Name embedded in
# the attestation. This proves the attestation is about *this* key,
# not some unrelated key.
#
# tpm2_readpublic -o writes TPM2B_PUBLIC (UINT16 size + TPMT_PUBLIC).
# The TPM Name = SHA-256(marshaled TPMT_PUBLIC), so we strip the 2-byte
# size prefix before sending.
pub_raw_path = tpm_dir / "_wrapkey_tpmt_public.bin"
tpm.run("tpm2_readpublic", "-c", f"0x{handle:08X}",
"-o", str(pub_raw_path))
tpm2b_public = pub_raw_path.read_bytes()
# Strip the 2-byte UINT16 size prefix to get TPMT_PUBLIC
tpmt_public = tpm2b_public[2:]
pub_raw_path.unlink(missing_ok=True)
# Try to parse the attestation blob to JSON for human-readable
# verification on the server side.
try:
attest_json_str = tpm.run("tpm2_print", "-t", "TPMS_ATTEST", str(attest_path))
attest_data = json.loads(attest_json_str)
except (SystemExit, json.JSONDecodeError):
attest_data = None
attestation_b64 = base64.b64encode(attest_path.read_bytes()).decode("ascii")
# tpm2_certify -o outputs the signature in TPMT_SIGNATURE format, not
# as a bare RSA signature. We must strip the TPMT_SIGNATURE header so
# the server can verify it with standard cryptography APIs.
#
# TPMT_SIGNATURE (TPM_ALG_RSASSA = 0x0014):
# UINT16 sigAlg (2 bytes) — 0x0014
# UINT16 hashAlg (2 bytes) — 0x000B = SHA-256
# UINT16 sigSize (2 bytes) — e.g. 256 for RSA-2048
# BYTE[sigSize] — the actual RSASSA-PKCS1-v1_5 signature
_sig_raw = sig_path.read_bytes()
if len(_sig_raw) < 6:
raise SystemExit(
f"tpm2_certify signature is too short ({len(_sig_raw)} bytes) — "
"expected TPMT_SIGNATURE format with at least a 6-byte header"
)
_sig_alg = int.from_bytes(_sig_raw[0:2], "big")
if _sig_alg != 0x0014:
raise SystemExit(
f"tpm2_certify signature has unexpected algorithm 0x{_sig_alg:04X} — "
"expected TPM_ALG_RSASSA (0x0014)"
)
_hash_alg = int.from_bytes(_sig_raw[2:4], "big")
if _hash_alg != 0x000B:
raise SystemExit(
f"tpm2_certify signature has unexpected hash algorithm 0x{_hash_alg:04X} — "
"expected TPM_ALG_SHA256 (0x000B)"
)
_sig_size = int.from_bytes(_sig_raw[4:6], "big")
if len(_sig_raw) != 6 + _sig_size:
raise SystemExit(
f"tpm2_certify signature size mismatch: header says {_sig_size} bytes, "
f"but file is {len(_sig_raw)} bytes (expected {6 + _sig_size})"
)
_rsa_sig = _sig_raw[6:]
signature_b64 = base64.b64encode(_rsa_sig).decode("ascii")
# Clean up temporary TPM objects — the attestation + signature files
# are plaintext blobs we keep on disk.
for p in [cert_primary_ctx, sign_pub, sign_priv, sign_ctx]:
p.unlink(missing_ok=True)
result: dict[str, Any] = {
"attestation_blob_b64": attestation_b64,
"attestation_signature_b64": signature_b64,
"certifying_key_pem": sign_pub_pem,
"certifying_key_sha256": sha256_hex(sign_pub_pem.encode()),
# Raw TPMT_PUBLIC of the wrapping key — server computes
# SHA-256 of this to get the TPM Name and cross-checks it
# against the Name inside the attestation blob.
"wrapkey_tpmt_public_b64": base64.b64encode(tpmt_public).decode("ascii"),
}
if attest_data is not None:
result["attestation_json"] = attest_data
print("TPM attestation created — wrapping key attributes are now cryptographically provable.")
return result
def tpm2_unwrap_master_key(root: Path, wrapped_key_b64: str,
tpm: TpmBackend | None = None) -> bytes:
"""Ask the TPM to RSA-OAEP-decrypt the wrapped K_master.
The TPM performs the decryption internally using the non-exportable
private key. The plaintext K_master is returned to us — the private
key itself never leaves the TPM chip.
"""
tpm = tpm or _TPM
state = tpm2_try_load_state(root)
if state is None:
raise SystemExit(
"TPM-wrapped asset key found (v3 envelope) but no TPM state "
"exists on this machine. Run 'plantai_client.py tpm2-setup' first."
)
handle = state["persistent_handle"]
# Use /dev/shm (tmpfs) so the plaintext K_master never touches a
# persistent disk. Even if the finally block is skipped, a reboot
# wipes /dev/shm completely. PID in the filename prevents collisions.
ct_path = Path(f"/dev/shm/_unwrap_ct_{os.getpid()}.bin")
pt_path = Path(f"/dev/shm/_unwrap_pt_{os.getpid()}.bin")
ct_path.write_bytes(base64.b64decode(wrapped_key_b64))
try:
tpm.run("tpm2_rsadecrypt",
"-c", f"0x{handle:08X}",
"-s", "oaep",
"-o", str(pt_path),
str(ct_path))
return pt_path.read_bytes()
finally:
ct_path.unlink(missing_ok=True)
pt_path.unlink(missing_ok=True)
# ---------------------------------------------------------------------------
# fingerprint collection, hardened against volatile/virtual components
# ---------------------------------------------------------------------------
def _is_physical_nic(iface: Path) -> bool:
# Physical NICs have a real PCI/USB device behind them; virtual
# interfaces created by docker/libvirt/veth/tun/bridges do not.
return (iface / "device").exists()
def _collect_network() -> list[dict[str, str]]:
rows = []
for iface in sorted(Path("/sys/class/net").glob("*")):
name = iface.name
if name == "lo":
continue
if not _is_physical_nic(iface):
continue
mac = read_text(iface / "address")
if mac and mac != "00:00:00:00:00:00":
rows.append({"iface": name, "mac": mac.lower()})
return rows
def _is_removable_disk(name: str) -> bool:
flag = read_text(Path(f"/sys/block/{name}/removable"))
return flag == "1"
def _collect_disks() -> list[dict[str, str]]:
rows = []
lsblk = run(["lsblk", "-ndo", "NAME,SERIAL,MODEL,TYPE"])
for line in lsblk.splitlines():
cols = line.split()
if len(cols) >= 2 and cols[-1] == "disk":
name = cols[0]
if _is_removable_disk(name):
continue
rows.append({
"name": name,
"serial": cols[1] if len(cols) >= 3 else "",
"model": " ".join(cols[2:-1]) if len(cols) > 3 else "",
})
return rows
def collect_fingerprint(device_id: str, root: Path | None = None,
tpm: TpmBackend | None = None) -> dict[str, Any]:
tpm = tpm or _TPM
dmi_root = Path("/sys/class/dmi/id")
cpu_model = ""
for line in Path("/proc/cpuinfo").read_text(errors="ignore").splitlines():
if line.lower().startswith("model name"):
cpu_model = line.split(":", 1)[-1].strip()
break
gpu_rows = []
gpu_out = run(["nvidia-smi", "--query-gpu=name,uuid", "--format=csv,noheader"])
for line in gpu_out.splitlines():
parts = [p.strip() for p in line.split(",", 1)]
if len(parts) == 2:
gpu_rows.append({"name": parts[0], "uuid": parts[1]})
disk_rows = _collect_disks()
network_rows = _collect_network()
product_uuid = read_text(dmi_root / "product_uuid")
board_serial = read_text(dmi_root / "board_serial")
fp = {
"device_id": device_id,
"collected_at": now_iso(),
"hostname": socket.gethostname(),
"dmi": {
"product_uuid": product_uuid,
"board_serial": board_serial,
"bios_vendor": read_text(dmi_root / "bios_vendor"),
"bios_version": read_text(dmi_root / "bios_version"),
},
"cpu": {"model": cpu_model},
"gpu": gpu_rows,
"disk": disk_rows,
"network": network_rows,
}
# Stable "core identity" anchor used ONLY for cryptographic key wrapping.
# Picked from the most stable identifier actually available on this box,
# in order of preference. This deliberately excludes anything that can
# change without the physical machine changing (NICs, USB disks, hostname).
bad_uuid_markers = ("", "00000000-0000-0000-0000-000000000000", "Not Specified")
if product_uuid and product_uuid not in bad_uuid_markers:
anchor_type, anchor_value = "dmi_product_uuid", product_uuid
elif board_serial and board_serial not in bad_uuid_markers:
anchor_type, anchor_value = "dmi_board_serial", board_serial
elif disk_rows and any(d["serial"] for d in disk_rows):
anchor_type = "disk_serial_set"
anchor_value = "|".join(sorted(d["serial"] for d in disk_rows if d["serial"]))
else:
# Weak fallback: still deterministic, but flagged so the server can
# decide whether to accept it (e.g. bare-metal-less VM with no DMI).
anchor_type, anchor_value = "hostname_weak", socket.gethostname()
fp["core_id_anchor"] = anchor_type
fp["core_id_hash"] = sha256_hex(canonical_bytes({"type": anchor_type, "value": anchor_value}))
fp["fingerprint_material"] = {
"hostname": fp["hostname"],
"dmi": fp["dmi"],
"cpu_model": cpu_model,
"gpu_uuid": sorted(g.get("uuid", "") for g in gpu_rows if g.get("uuid")),
"disk_serial": sorted(d.get("serial", "") for d in disk_rows if d.get("serial")),
"network_mac": sorted(n.get("mac", "") for n in network_rows if n.get("mac")),
}
# Attach TPM public key if available. This tells the vendor "wrap K_master
# with this public key so only my TPM can unwrap it."
if root is not None:
tpm_state = tpm2_try_load_state(root)
if tpm_state is not None:
try:
pubkey_pem = read_public_key_pem(tpm_state["persistent_handle"], tpm)
fp["tpm_public_key"] = {
"tpm_version": "2.0",
"persistent_handle": tpm_state["persistent_handle"],
"algorithm": "RSA-2048-OAEP-SHA256",
"public_key_pem": pubkey_pem,
"public_key_sha256": sha256_hex(pubkey_pem.encode()),
}
# Include attestation data so the server can cryptographically
# verify the key is truly TPM-resident, not a software fake.
tpm_attestation = tpm_state.get("attestation")
if tpm_attestation:
fp["tpm_public_key"]["attestation"] = tpm_attestation
else:
print("WARNING: No TPM attestation found. The server will not")
print("be able to verify this key is truly hardware-backed.")
print("Re-run 'plantai_client.py tpm2-setup --root <ROOT>' to create one.")
except SystemExit:
pass # TPM unavailable at fingerprint time — just omit
return fp
def match_score(expected: dict[str, Any], current: dict[str, Any]) -> int:
score = 0
if (expected.get("dmi") or {}).get("product_uuid") and (expected.get("dmi") or {}).get("product_uuid") == (current.get("dmi") or {}).get("product_uuid"):
score += 30
expected_gpu = set(expected.get("gpu_uuid", []))
current_gpu = set(current.get("gpu_uuid", []))
if expected_gpu and expected_gpu & current_gpu:
score += 25
expected_disk = set(expected.get("disk_serial", []))
current_disk = set(current.get("disk_serial", []))
if expected_disk and expected_disk & current_disk:
score += 20
if expected.get("dmi") and expected.get("dmi") == current.get("dmi"):
score += 10
expected_mac = set(expected.get("network_mac", []))
current_mac = set(current.get("network_mac", []))
if expected_mac and expected_mac & current_mac:
score += 10
if expected.get("hostname") and expected.get("hostname") == current.get("hostname"):
score += 5
return min(score, 100)
# ---------------------------------------------------------------------------
# verification / key unwrap / decrypt (public-key + already-issued files only)
# ---------------------------------------------------------------------------
def load_public(path: Path) -> Ed25519PublicKey:
return serialization.load_pem_public_key(path.read_bytes())
def verify_payload(public_key_path: Path, payload: dict[str, Any]) -> None:
signature = base64.b64decode(payload.get("signature", ""))
unsigned = dict(payload)
unsigned.pop("signature", None)
load_public(public_key_path).verify(signature, canonical_bytes(unsigned))
def derive_wrap_key(anchor_hash: str, salt: bytes) -> bytes:
return HKDF(
algorithm=hashes.SHA256(),
length=32,
salt=salt,
info=b"PlantAI K_master wrap v2",
).derive(anchor_hash.encode("ascii"))
def unwrap_master_key(asset_key_path: Path, current_fp: dict[str, Any],
root: Path) -> bytes:
"""Unwrap K_master. Supports two envelope versions:
v3 — TPM-based (hardware-bound):
K_master is RSA-OAEP-encrypted to a non-exportable key inside the
TPM. Only this specific TPM chip can decrypt it.
v2 — Legacy HKDF (WEAK, no hardware binding):
wrap_key = HKDF(core_id_hash, salt) where both parameters are
stored in this file. Anyone with the file can recompute the key.
"""
envelope = load_json(asset_key_path)
version = envelope.get("version", 1)
# ---- v3: TPM-based unwrapping (REAL hardware binding) ----
if version == 3:
return tpm2_unwrap_master_key(
root,
envelope["wrapped_key_b64"],
)
# ---- v2: Legacy HKDF-based unwrapping (WEAK) ----
print("WARNING: This license uses legacy HKDF key wrapping — this provides")
print("NO hardware binding. Anyone with the asset.key.enc file can derive")
print("the unwrap key. If this machine has a TPM 2.0, run:")
print(" plantai_client.py tpm2-setup --root <ROOT>")
print("and request a new asset.key.enc from the vendor.")
print()
if envelope["core_id_hash"] != current_fp["core_id_hash"]:
raise SystemExit(
"Hardware core identity has changed since this license was issued "
f"(anchor={envelope.get('core_id_anchor')}). This machine cannot unwrap "
"the asset key. Contact vendor support for a license migration."
)
salt = base64.b64decode(envelope["salt"])
nonce = base64.b64decode(envelope["nonce"])
ciphertext = base64.b64decode(envelope["ciphertext"])
wrap_key = derive_wrap_key(envelope["core_id_hash"], salt)
return AESGCM(wrap_key).decrypt(nonce, ciphertext, envelope["core_id_hash"].encode("ascii"))
def _safe_extract(tf: tarfile.TarFile, output_dir: Path) -> None:
"""Extract tar members safely: reject absolute paths, parent-dir
traversal, and symlinks / hardlinks / device nodes."""
resolved_base = output_dir.resolve()
for member in tf.getmembers():
# Reject absolute paths and .. traversal
if member.name.startswith("/") or ".." in Path(member.name).parts:
raise SystemExit(
f"tar member '{member.name}' has an unsafe path"
)
target = (resolved_base / member.name).resolve()
if not str(target).startswith(str(resolved_base) + "/") and target != resolved_base:
raise SystemExit(
f"tar member '{member.name}' escapes output directory"
)
# Reject anything that isn't a regular file or directory
if not (member.isreg() or member.isdir()):
raise SystemExit(
f"tar member '{member.name}' is not a regular file or directory"
)
if member.isdir():
target.mkdir(parents=True, exist_ok=True)
else:
target.parent.mkdir(parents=True, exist_ok=True)
with tf.extractfile(member) as src, open(target, "wb") as dst:
shutil.copyfileobj(src, dst)
def decrypt_asset(root: Path, master_key: bytes, enc_path: Path, output_dir: Path,
keep_compressed_tmp: bool = False,
allow_missing_manifest: bool = False) -> None:
name = enc_path.name.removesuffix(".enc")
# --- verify asset manifest before touching the ciphertext ---
pubkey_path = root / "config" / "supplier_public_key.pem"
license_data = load_json(root / "config" / "license.json")
manifest_path = enc_path.parent / f"{name}.manifest.json"
if manifest_path.exists():
manifest = load_json(manifest_path)
verify_payload(pubkey_path, manifest)
# release_version binding
lic_release = license_data.get("asset_policy", {}).get("release_version", "")
mf_release = manifest.get("release_version", "")
if lic_release and mf_release != lic_release:
raise SystemExit(
f"manifest release_version '{mf_release}' does not match "
f"license asset_policy.release_version '{lic_release}' for asset '{name}'"
)
# Asset package names ("models", "prompts") are NOT the same as
# business modules ("identify", "rnaseq_qc", …). Use asset_policy.
# allowed_assets to control which encrypted packages a license permits.
allowed_assets: list[str] = license_data.get("asset_policy", {}).get("allowed_assets", [])
if allowed_assets and name not in allowed_assets:
raise SystemExit(
f"asset '{name}' is not in license asset_policy.allowed_assets"
)
else:
if allow_missing_manifest:
print(f"WARNING: no manifest found for '{name}', skipping integrity check "
f"(--allow-missing-manifest is set)")
else:
raise SystemExit(
f"No manifest found for '{name}' — every asset must have a signed "
f"manifest. If this is a test/dev environment, pass "
f"--allow-missing-manifest to decrypt-all."
)
raw = enc_path.read_bytes()
nonce_b64, ciphertext = raw.split(b"\n", 1)
# verify cipher_hash before decrypt (fast, detects corruption / tampering)
if manifest_path.exists():
expected_cipher_hash = manifest.get("cipher_hash", "")
if expected_cipher_hash and sha256_hex(ciphertext) != expected_cipher_hash:
raise SystemExit(
f"cipher_hash mismatch for '{name}' — the .enc file may be "
"corrupted or tampered with"
)
plaintext = AESGCM(master_key).decrypt(base64.b64decode(nonce_b64), ciphertext, name.encode("utf-8"))
# verify content_hash after decrypt (guards against key misuse / corruption)
if manifest_path.exists():
expected_content_hash = manifest.get("content_hash", "")
if expected_content_hash and sha256_hex(plaintext) != expected_content_hash:
raise SystemExit(
f"content_hash mismatch for '{name}' — the decrypted asset does "
"not match what the vendor signed"
)
output_dir.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory() as tmp:
archive_path = Path(tmp) / f"{name}.tar.zst"
archive_path.write_bytes(plaintext)
if shutil.which("zstd"):
tar_path = Path(tmp) / f"{name}.tar"
subprocess.check_call(["zstd", "-q", "-d", "-f", str(archive_path), "-o", str(tar_path)])
else:
tar_path = archive_path
with tarfile.open(tar_path, "r") as tf:
_safe_extract(tf, output_dir)
def verify_install(args: argparse.Namespace) -> dict[str, Any]:
root = Path(args.root)
license_data = load_json(root / "config" / "license.json")
try:
verify_payload(root / "config" / "supplier_public_key.pem", license_data)
except InvalidSignature as exc:
raise SystemExit("license signature verification failed") from exc
current_fp = collect_fingerprint(license_data["device_id"], root)
hw = license_data["hardware_policy"]
score = match_score(hw["fingerprint_snapshot"], current_fp["fingerprint_material"])
min_score = int(hw["min_match_score"])
warn_score = int(hw.get("allow_warning_score", min_score))
if score < min_score:
raise SystemExit(f"hardware match score {score} is below required {min_score}")
# Verify that asset.key.enc matches the hash baked into the license.
# This prevents an attacker from swapping in a different envelope.
# For license_version >= 2.0, asset_key_sha256 is REQUIRED — if it's
# missing the license was not properly bound (e.g. operator forgot
# --asset-key-envelope when running issue-license).
asset_key_path = root / "config" / "asset.key.enc"
expected_ak_hash = license_data.get("asset_policy", {}).get("asset_key_sha256", "")
if not expected_ak_hash:
raise SystemExit(
"license is missing asset_policy.asset_key_sha256 — the license "
"was not cryptographically bound to an asset key envelope.\n"
"The vendor must re-issue the license with --asset-key-envelope "
"pointing to the same asset.key.enc that ships with this license."
)
actual_ak_hash = sha256_hex(asset_key_path.read_bytes())
if actual_ak_hash != expected_ak_hash:
raise SystemExit(
f"asset.key.enc hash does not match license asset_key_sha256.\n"
f" Expected: {expected_ak_hash}\n"
f" Actual: {actual_ak_hash}\n"
f"The license and asset key may have been tampered with or are "
f"from different issuance runs."
)
# Verify that the TPM key used for wrapping matches the one in the license.
# This prevents a TPM-key downgrade between license issuance and runtime.
expected_tpm_hash = license_data.get("asset_policy", {}).get("tpm_public_key_sha256", "")
if expected_tpm_hash:
current_tpm = current_fp.get("tpm_public_key")
if not current_tpm:
raise SystemExit(
"License requires TPM-based protection (tpm_public_key_sha256 is set) "
"but this machine has no TPM key configured. Run tpm2-setup."
)
if current_tpm["public_key_sha256"] != expected_tpm_hash:
raise SystemExit(
f"TPM public key has changed since license issuance.\n"
f" License: {expected_tpm_hash}\n"
f" Current: {current_tpm['public_key_sha256']}\n"
f"Was tpm2-setup re-run after the license was issued?"
)
result = {"ok": True, "hardware_match_score": score, "root": str(root)}
if score < warn_score:
result["warning"] = f"hardware match score {score} is below the comfortable threshold {warn_score}; license still valid but drifting"
# Enforce min_wrap_version before unwrap. If the license says v3 is
# required (TPM-based), reject a v2 (HKDF) envelope even if a v2
# unwrap would technically succeed. This prevents downgrade attacks
# where an attacker replaces asset.key.enc with a v2 envelope.
min_wrap = license_data.get("asset_policy", {}).get("min_wrap_version", 0)
envelope = load_json(asset_key_path)
env_version = envelope.get("version", 1)
if env_version < min_wrap:
raise SystemExit(
f"asset.key.enc wrap version ({env_version}) is below the "
f"minimum required by this license ({min_wrap}).\n"
f"The license requires v{min_wrap} (TPM hardware-bound) but "
f"the asset key envelope is v{env_version}.\n"
f"Contact the vendor for a properly wrapped asset key."
)
master_key = unwrap_master_key(asset_key_path, current_fp, root)
result["asset_key_ready"] = True
log_audit(root, {"event": "verify-install", **result})
print(json.dumps(result, ensure_ascii=False))
return {"license_data": license_data, "master_key": master_key, "result": result}
def decrypt_all(root: Path, output: Path,
allow_missing_manifest: bool = False) -> None:
"""Decrypt every *.enc under <root>/assets into output. Pure logic — the
CLI wrapper and the launcher both call this."""
state = verify_install(argparse.Namespace(root=str(root)))
master_key = state["master_key"]
output_dir = Path(output)
for enc in sorted(Path(root, "assets").glob("*.enc")):
decrypt_asset(root, master_key, enc, output_dir,
allow_missing_manifest=allow_missing_manifest)
print(f"decrypted {enc.name} -> {output_dir}")