- 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>
1019 lines
43 KiB
Python
1019 lines
43 KiB
Python
#!/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}")
|