feat(lighthouse): define domain manifest signing envelope

This commit is contained in:
冰朔 2026-08-10 02:19:20 +08:00
commit ca25dab2a3
3 changed files with 297 additions and 0 deletions

View file

@ -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 已在线签发、会话能力已签发、节点已连接或域运行体已加载。

View file

@ -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,
}

View file

@ -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()