From ca25dab2a326a55ae64e81e5e944d4f8b4c3cb94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=86=B0=E6=9C=94?= <565183519@qq.com> Date: Mon, 10 Aug 2026 02:19:20 +0800 Subject: [PATCH] feat(lighthouse): define domain manifest signing envelope --- server-tools/enterprise-lighthouse/README.md | 18 +++ .../domain_manifest_signing.py | 131 ++++++++++++++++ .../test_domain_manifest_signing.py | 148 ++++++++++++++++++ 3 files changed, 297 insertions(+) create mode 100644 server-tools/enterprise-lighthouse/domain_manifest_signing.py create mode 100644 server-tools/enterprise-lighthouse/test_domain_manifest_signing.py diff --git a/server-tools/enterprise-lighthouse/README.md b/server-tools/enterprise-lighthouse/README.md index 283f49e..9af5e36 100644 --- a/server-tools/enterprise-lighthouse/README.md +++ b/server-tools/enterprise-lighthouse/README.md @@ -70,3 +70,21 @@ root 可以在物理层停止或移除本服务,但绕过本门直接执行的 `agent_gate_client.py` 负责按固定顺序读取地图、按当前主体签收、恢复外显意图胶囊、申请 单 Agent/单动作解锁,并把一次性解锁交给固定连接器。管理员凭据只从进程环境读取,不 写入参数、意图胶囊、仓库或回执。 + +## 域 manifest 签名信封(源码能力,默认关闭) + +`domain_manifest_signing.py` 定义了与 HoloLake 客户端一致的 +`gh-aios.domain-manifest/v1` 规范字节、SHA-256 摘要和 Ed25519 签名信封。签名输入固定为: + +```text +domainId + repositoryId + schema + signerId + sourceCommit +``` + +生产私钥不属于仓库。只有同时提供 +`LIGHTHOUSE_DOMAIN_SIGNING_KEY_PATH`、`LIGHTHOUSE_DOMAIN_SIGNER_ID` 和 +`LIGHTHOUSE_DOMAIN_SIGNING_REPOSITORY_ID`,且私钥路径为绝对路径、文件权限仅所有者可读 +写时,签名配置才可加载。缺少任一项均失败关闭。 + +当前源码没有登记生产签发者、没有生成生产密钥、没有公开签发 HTTP 路由,也没有把该模块 +接入正在运行的企业灯塔。因此,本模块通过测试只证明“源端可以生成客户端能够验证的签名 +信封”,不证明域 manifest 已在线签发、会话能力已签发、节点已连接或域运行体已加载。 diff --git a/server-tools/enterprise-lighthouse/domain_manifest_signing.py b/server-tools/enterprise-lighthouse/domain_manifest_signing.py new file mode 100644 index 0000000..c6d736f --- /dev/null +++ b/server-tools/enterprise-lighthouse/domain_manifest_signing.py @@ -0,0 +1,131 @@ +#!/usr/bin/env python3 +"""Disabled-by-default Ed25519 envelope signer for HoloLake domain manifests. + +This module defines source bytes shared with the HoloLake client. It does not +register a production signer, generate a key, expose an HTTP route, or claim +that a domain runtime is online. +""" + +import base64 +import hashlib +import json +import os +import re +import subprocess +import tempfile +from dataclasses import dataclass +from pathlib import Path +from typing import Mapping, Optional + + +MANIFEST_SCHEMA = "gh-aios.domain-manifest/v1" +IDENTIFIER = re.compile(r"^[A-Z0-9][A-Z0-9._:-]{0,159}$") +SOURCE_COMMIT = re.compile(r"^[a-f0-9]{40}(?:[a-f0-9]{24})?$") + + +class SignerConfigurationError(ValueError): + pass + + +@dataclass(frozen=True) +class SignerConfig: + private_key_path: Path + repository_id: str + signer_id: str + + +def _identifier(value: str, field: str) -> str: + if not isinstance(value, str) or not IDENTIFIER.fullmatch(value): + raise SignerConfigurationError(f"{field} is not a bounded identifier") + return value + + +def canonical_manifest_payload(*, domain_id: str, repository_id: str, signer_id: str, source_commit: str) -> bytes: + _identifier(domain_id, "domain_id") + _identifier(repository_id, "repository_id") + _identifier(signer_id, "signer_id") + if not isinstance(source_commit, str) or not SOURCE_COMMIT.fullmatch(source_commit): + raise SignerConfigurationError("source_commit must be a full Git object id") + payload = { + "domainId": domain_id, + "repositoryId": repository_id, + "schema": MANIFEST_SCHEMA, + "signerId": signer_id, + "sourceCommit": source_commit, + } + return json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8") + + +def _private_key(path: Path) -> Path: + if not isinstance(path, Path) or not path.is_absolute(): + raise SignerConfigurationError("private key path must be absolute") + try: + status = path.stat() + except OSError as error: + raise SignerConfigurationError("private key file is unavailable") from error + if not path.is_file() or status.st_mode & 0o077: + raise SignerConfigurationError("private key file must be regular and owner-only") + return path + + +def load_signer_config_from_environment(environment: Mapping[str, str] = os.environ) -> Optional[SignerConfig]: + key_path = environment.get("LIGHTHOUSE_DOMAIN_SIGNING_KEY_PATH", "").strip() + signer_id = environment.get("LIGHTHOUSE_DOMAIN_SIGNER_ID", "").strip() + repository_id = environment.get("LIGHTHOUSE_DOMAIN_SIGNING_REPOSITORY_ID", "").strip() + if not key_path and not signer_id and not repository_id: + return None + if not key_path or not signer_id or not repository_id: + raise SignerConfigurationError("domain signing configuration is incomplete") + return SignerConfig( + private_key_path=_private_key(Path(key_path)), + repository_id=_identifier(repository_id, "repository_id"), + signer_id=_identifier(signer_id, "signer_id"), + ) + + +def sign_domain_manifest( + *, + private_key_path: Path, + domain_id: str, + repository_id: str, + signer_id: str, + source_commit: str, +) -> dict: + key_path = _private_key(private_key_path) + payload = canonical_manifest_payload( + domain_id=domain_id, + repository_id=repository_id, + signer_id=signer_id, + source_commit=source_commit, + ) + payload_path = None + try: + with tempfile.NamedTemporaryFile(prefix="guanghu-domain-manifest-", delete=False) as handle: + handle.write(payload) + payload_path = Path(handle.name) + payload_path.chmod(0o600) + completed = subprocess.run( + [ + "openssl", "pkeyutl", "-sign", "-rawin", "-inkey", str(key_path), + "-in", str(payload_path), + ], + capture_output=True, + check=True, + timeout=5, + ) + except (OSError, subprocess.SubprocessError) as error: + raise SignerConfigurationError("Ed25519 manifest signing failed") from error + finally: + if payload_path is not None: + payload_path.unlink(missing_ok=True) + if len(completed.stdout) != 64: + raise SignerConfigurationError("signer returned an invalid Ed25519 signature") + return { + "digest": hashlib.sha256(payload).hexdigest(), + "domainId": domain_id, + "repositoryId": repository_id, + "schema": MANIFEST_SCHEMA, + "signature": base64.b64encode(completed.stdout).decode("ascii"), + "signerId": signer_id, + "sourceCommit": source_commit, + } diff --git a/server-tools/enterprise-lighthouse/test_domain_manifest_signing.py b/server-tools/enterprise-lighthouse/test_domain_manifest_signing.py new file mode 100644 index 0000000..567ba3e --- /dev/null +++ b/server-tools/enterprise-lighthouse/test_domain_manifest_signing.py @@ -0,0 +1,148 @@ +#!/usr/bin/env python3 +import base64 +import subprocess +import tempfile +import unittest +from pathlib import Path + +from domain_manifest_signing import ( + SignerConfigurationError, + canonical_manifest_payload, + load_signer_config_from_environment, + sign_domain_manifest, +) + + +class DomainManifestSigningTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.root = Path(self.temp.name) + self.private_key = self.root / "signer.pem" + self.public_key = self.root / "signer-public.pem" + subprocess.run( + ["openssl", "genpkey", "-algorithm", "ED25519", "-out", self.private_key], + check=True, + capture_output=True, + ) + self.private_key.chmod(0o600) + subprocess.run( + ["openssl", "pkey", "-in", self.private_key, "-pubout", "-out", self.public_key], + check=True, + capture_output=True, + ) + + def tearDown(self): + self.temp.cleanup() + + def test_canonical_payload_matches_the_hololake_client_contract(self): + encoded = canonical_manifest_payload( + domain_id="DOM-FIFTH-0001", + repository_id="REPO-012", + signer_id="GH-LIGHTHOUSE-001", + source_commit="b" * 40, + ) + self.assertEqual( + encoded, + b'{"domainId":"DOM-FIFTH-0001","repositoryId":"REPO-012",' + b'"schema":"gh-aios.domain-manifest/v1","signerId":"GH-LIGHTHOUSE-001",' + b'"sourceCommit":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}', + ) + + def test_signs_a_digest_bound_ed25519_envelope(self): + envelope = sign_domain_manifest( + private_key_path=self.private_key, + domain_id="DOM-FIFTH-0001", + repository_id="REPO-012", + signer_id="GH-LIGHTHOUSE-001", + source_commit="b" * 40, + ) + payload = canonical_manifest_payload( + domain_id=envelope["domainId"], + repository_id=envelope["repositoryId"], + signer_id=envelope["signerId"], + source_commit=envelope["sourceCommit"], + ) + signature_path = self.root / "signature.bin" + payload_path = self.root / "payload.json" + payload_path.write_bytes(payload) + signature_path.write_bytes(base64.b64decode(envelope["signature"], validate=True)) + verified = subprocess.run( + [ + "openssl", "pkeyutl", "-verify", "-rawin", "-pubin", + "-inkey", self.public_key, "-sigfile", signature_path, "-in", payload_path, + ], + capture_output=True, + ) + self.assertEqual(verified.returncode, 0, verified.stderr.decode()) + self.assertEqual(len(envelope["digest"]), 64) + self.assertEqual(set(envelope), { + "digest", "domainId", "repositoryId", "schema", "signature", "signerId", "sourceCommit", + }) + + def test_payload_tampering_does_not_verify(self): + envelope = sign_domain_manifest( + private_key_path=self.private_key, + domain_id="DOM-FIFTH-0001", + repository_id="REPO-012", + signer_id="GH-LIGHTHOUSE-001", + source_commit="b" * 40, + ) + signature_path = self.root / "signature.bin" + tampered_path = self.root / "tampered.json" + signature_path.write_bytes(base64.b64decode(envelope["signature"], validate=True)) + tampered = canonical_manifest_payload( + domain_id="DOM-FIFTH-0001", + repository_id="REPO-012", + signer_id="GH-LIGHTHOUSE-001", + source_commit="c" * 40, + ) + tampered_path.write_bytes(tampered) + verified = subprocess.run( + [ + "openssl", "pkeyutl", "-verify", "-rawin", "-pubin", + "-inkey", self.public_key, "-sigfile", signature_path, "-in", tampered_path, + ], + capture_output=True, + ) + self.assertNotEqual(verified.returncode, 0) + + def test_signing_is_unavailable_without_complete_environment_configuration(self): + self.assertIsNone(load_signer_config_from_environment({})) + with self.assertRaises(SignerConfigurationError): + load_signer_config_from_environment({"LIGHTHOUSE_DOMAIN_SIGNING_KEY_PATH": str(self.private_key)}) + + def test_rejects_relative_or_permissive_private_key_files(self): + with self.assertRaises(SignerConfigurationError): + sign_domain_manifest( + private_key_path=Path("relative.pem"), + domain_id="DOM-FIFTH-0001", + repository_id="REPO-012", + signer_id="GH-LIGHTHOUSE-001", + source_commit="b" * 40, + ) + self.private_key.chmod(0o644) + with self.assertRaises(SignerConfigurationError): + sign_domain_manifest( + private_key_path=self.private_key, + domain_id="DOM-FIFTH-0001", + repository_id="REPO-012", + signer_id="GH-LIGHTHOUSE-001", + source_commit="b" * 40, + ) + + def test_rejects_unbounded_identifiers_and_invalid_source_commits(self): + for field, value in (("domain_id", "../../escape"), ("source_commit", "not-a-commit")): + arguments = { + "private_key_path": self.private_key, + "domain_id": "DOM-FIFTH-0001", + "repository_id": "REPO-012", + "signer_id": "GH-LIGHTHOUSE-001", + "source_commit": "b" * 40, + } + arguments[field] = value + with self.assertRaises(SignerConfigurationError): + sign_domain_manifest(**arguments) + + +if __name__ == "__main__": + unittest.main()