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_gate_client.py` 负责按固定顺序读取地图、按当前主体签收、恢复外显意图胶囊、申请
|
||||||
单 Agent/单动作解锁,并把一次性解锁交给固定连接器。管理员凭据只从进程环境读取,不
|
单 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