feat(lighthouse): define domain manifest signing envelope
This commit is contained in:
parent
6e26d1f354
commit
ca25dab2a3
3 changed files with 297 additions and 0 deletions
|
|
@ -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 已在线签发、会话能力已签发、节点已连接或域运行体已加载。
|
||||
|
|
|
|||
131
server-tools/enterprise-lighthouse/domain_manifest_signing.py
Normal file
131
server-tools/enterprise-lighthouse/domain_manifest_signing.py
Normal 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,
|
||||
}
|
||||
|
|
@ -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()
|
||||
Loading…
Reference in a new issue