Require signed agent releases (Refs #64)
This commit is contained in:
@@ -19,13 +19,15 @@ import re
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import uuid
|
||||
|
||||
UPDATER_PROTOCOL_VERSION = 1
|
||||
UPDATER_PROTOCOL_VERSION = 2
|
||||
AGENT_CONFIG = Path("/etc/electric-cherry/agent.json")
|
||||
RELEASE_KEYRING = Path("/etc/electric-cherry/release-signing.gpg")
|
||||
AGENT_EXECUTABLE = Path("/usr/local/libexec/electric-cherry-agent.py")
|
||||
UNINSTALL_EXECUTABLE = Path("/usr/local/libexec/electric-cherry-uninstall.py")
|
||||
UNINSTALL_REQUEST = Path("/etc/electric-cherry/decommission.json")
|
||||
@@ -36,6 +38,7 @@ UPDATE_STATUS = Path("/etc/electric-cherry/update-status.json")
|
||||
AGENT_HEALTH = Path("/etc/electric-cherry/agent-health.json")
|
||||
AGENT_SERVICE = "electric-cherry-agent.service"
|
||||
MAX_MANIFEST = 16 * 1024
|
||||
MAX_SIGNATURE = 16 * 1024
|
||||
MAX_SOURCE = 8 * 1024 * 1024
|
||||
HEALTH_TIMEOUT = 90
|
||||
VERSION_RE = re.compile(r"^(0|[1-9][0-9]{0,8})\.(0|[1-9][0-9]{0,8})\.(0|[1-9][0-9]{0,8})$")
|
||||
@@ -150,6 +153,32 @@ def source_version(source):
|
||||
return declarations[0]
|
||||
|
||||
|
||||
def verify_release_signature(manifest, signature):
|
||||
# Trust is provisioned by the host operator, never fetched from the
|
||||
# controller. Check every path component before exposing it to gpgv.
|
||||
try:
|
||||
keyring = trusted(RELEASE_KEYRING)
|
||||
except (UpdateError, OSError):
|
||||
raise IntegrityError("The host release-signing keyring is missing or unsafe; provision a trusted root-owned keyring") from None
|
||||
if not signature:
|
||||
raise IntegrityError("The controller release requires a detached manifest signature")
|
||||
trusted(UPDATE_DIR, directory=True)
|
||||
# An isolated home excludes any ambient GnuPG keyring or configuration.
|
||||
# The exact downloaded bytes are verified before manifest JSON is parsed.
|
||||
with tempfile.TemporaryDirectory(prefix="verify-", dir=UPDATE_DIR) as temporary:
|
||||
home = Path(temporary)
|
||||
manifest_path = home / "manifest.json"
|
||||
signature_path = home / "manifest.json.sig"
|
||||
manifest_path.write_bytes(manifest)
|
||||
signature_path.write_bytes(signature)
|
||||
try:
|
||||
result = subprocess.run(["gpgv", "--homedir", str(home), "--keyring", str(keyring), str(signature_path), str(manifest_path)], stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=30, check=False)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
raise IntegrityError("Release signature verification is unavailable; install or repair gpgv on this host") from None
|
||||
if result.returncode:
|
||||
raise IntegrityError("The release manifest signature is invalid or is not signed by a trusted release key")
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def exclusive_lock(path):
|
||||
trusted(path, missing=True)
|
||||
@@ -242,7 +271,7 @@ class Updater:
|
||||
return state
|
||||
|
||||
def fetch(self, path, limit):
|
||||
request = urllib.request.Request(self.installation["controller"] + path, headers={"User-Agent": "electric-cherry-updater/1"})
|
||||
request = urllib.request.Request(self.installation["controller"] + path, headers={"User-Agent": "electric-cherry-updater/2"})
|
||||
class NoRedirect(urllib.request.HTTPRedirectHandler):
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
||||
raise ContactError("Agent release redirects are not allowed")
|
||||
@@ -274,8 +303,15 @@ class Updater:
|
||||
raise ContactError("The controller release is unavailable; the running agent was left unchanged") from None
|
||||
|
||||
def manifest(self):
|
||||
manifest = self.fetch("/api/agent/release/manifest.json", MAX_MANIFEST)
|
||||
try:
|
||||
value = json.loads(self.fetch("/api/agent/release/manifest.json", MAX_MANIFEST))
|
||||
signature = self.fetch("/api/agent/release/manifest.json.sig", MAX_SIGNATURE)
|
||||
except ContactError:
|
||||
raise IntegrityError("The controller release signature is unavailable; a signed release is required") from None
|
||||
self.ensure_directory()
|
||||
verify_release_signature(manifest, signature)
|
||||
try:
|
||||
value = json.loads(manifest)
|
||||
except (ValueError, UnicodeError):
|
||||
raise IntegrityError("The controller release manifest is invalid") from None
|
||||
if not isinstance(value, dict) or type(value.get("protocolVersion")) is not int or value["protocolVersion"] != 1 or value.get("platform") != "debian-amd64":
|
||||
|
||||
+34
-5
@@ -33,7 +33,7 @@ command -v systemctl >/dev/null || { echo 'systemd is required.' >&2; exit 1; }
|
||||
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
apt-get update -qq
|
||||
apt-get install -y --no-install-recommends ca-certificates curl python3 util-linux
|
||||
apt-get install -y --no-install-recommends ca-certificates curl python3 util-linux gpgv
|
||||
installation_preflight() {
|
||||
if [ "$existing_enrollment" = true ] && [ ! -f /etc/electric-cherry/agent.json ]; then
|
||||
echo 'The existing enrollment disappeared during installation. Stop and inspect host deletion before trying again.' >&2
|
||||
@@ -96,6 +96,11 @@ def trusted_json(path):
|
||||
|
||||
trusted_directories(root)
|
||||
trusted_directories(config.parent)
|
||||
keyring = config.with_name('release-signing.gpg')
|
||||
try:
|
||||
trusted_file(keyring)
|
||||
except FileNotFoundError:
|
||||
raise SystemExit('Provision the publisher public key at /etc/electric-cherry/release-signing.gpg through a trusted channel before installing. Never obtain the trust key from the controller.') from None
|
||||
mountinfo = pathlib.Path('/proc/1/mountinfo')
|
||||
if mountinfo.exists():
|
||||
for line in mountinfo.read_text().splitlines():
|
||||
@@ -169,19 +174,43 @@ installation_cleanup() {
|
||||
trap installation_cleanup EXIT
|
||||
controller="${controller%/}"
|
||||
curl --proto '=https' --tlsv1.2 --fail --silent --show-error "$controller/api/agent/release/manifest.json" -o "$temporary_dir/manifest.json"
|
||||
curl --proto '=https' --tlsv1.2 --fail --silent --show-error "$controller/api/agent/release/manifest.json.sig" -o "$temporary_dir/manifest.json.sig"
|
||||
curl --proto '=https' --tlsv1.2 --fail --silent --show-error "$controller/api/agent/release/ec-agent.py" -o "$temporary_dir/ec-agent.py"
|
||||
curl --proto '=https' --tlsv1.2 --fail --silent --show-error "$controller/api/agent/release/ec-agent-update.py" -o "$temporary_dir/ec-agent-update.py"
|
||||
python3 - "$temporary_dir/manifest.json" "$temporary_dir/ec-agent.py" "$temporary_dir/ec-agent-update.py" <<'PY'
|
||||
python3 - "$temporary_dir/manifest.json" "$temporary_dir/ec-agent.py" "$temporary_dir/ec-agent-update.py" /etc/electric-cherry/release-signing.gpg <<'PY'
|
||||
# INSTALLATION_RELEASE: check both downloads before installing or executing either.
|
||||
import ast, hashlib, json, pathlib, re, sys
|
||||
manifest = json.loads(pathlib.Path(sys.argv[1]).read_text())
|
||||
import ast, hashlib, json, pathlib, re, stat, subprocess, sys, tempfile
|
||||
manifest_path = pathlib.Path(sys.argv[1])
|
||||
manifest_bytes = manifest_path.read_bytes()
|
||||
signature = manifest_path.with_name('manifest.json.sig')
|
||||
keyring = pathlib.Path(sys.argv[4])
|
||||
for path in (*reversed(keyring.parents), keyring):
|
||||
info = path.lstat()
|
||||
expected_type = stat.S_ISREG if path == keyring else stat.S_ISDIR
|
||||
if not expected_type(info.st_mode) or info.st_uid != 0 or info.st_mode & 0o022:
|
||||
raise SystemExit('Release signing key must be a regular root-owned file in protected directories.')
|
||||
if len(manifest_bytes) > 16 * 1024 or signature.stat().st_size > 16 * 1024:
|
||||
raise SystemExit('Release signing metadata exceeds its size limit.')
|
||||
try:
|
||||
with tempfile.TemporaryDirectory(prefix='gpgv-', dir=manifest_path.parent) as home:
|
||||
verified = subprocess.run(['gpgv', '--homedir', home, '--keyring', str(keyring),
|
||||
str(signature), '-'], input=manifest_bytes,
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
timeout=30, check=False)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
raise SystemExit('Release signature verification requires a working gpgv installation.') from None
|
||||
if verified.returncode:
|
||||
raise SystemExit('Release signature verification failed. The installed agent was left unchanged.')
|
||||
manifest = json.loads(manifest_bytes)
|
||||
if not isinstance(manifest, dict):
|
||||
raise SystemExit('Unsupported agent or updater release manifest.')
|
||||
agent = pathlib.Path(sys.argv[2]).read_bytes()
|
||||
updater = pathlib.Path(sys.argv[3]).read_bytes()
|
||||
if hashlib.sha256(agent).hexdigest() != manifest.get('sha256'):
|
||||
raise SystemExit('Agent release checksum verification failed.')
|
||||
if hashlib.sha256(updater).hexdigest() != manifest.get('updaterSha256'):
|
||||
raise SystemExit('Updater release checksum verification failed.')
|
||||
if manifest.get('platform') != 'debian-amd64' or manifest.get('protocolVersion') != 1 or manifest.get('updaterProtocolVersion') != 1:
|
||||
if manifest.get('platform') != 'debian-amd64' or type(manifest.get('protocolVersion')) is not int or manifest['protocolVersion'] != 1 or type(manifest.get('updaterProtocolVersion')) is not int or manifest['updaterProtocolVersion'] != 2:
|
||||
raise SystemExit('Unsupported agent or updater release manifest.')
|
||||
versions = [node.value.value for node in ast.parse(agent).body
|
||||
if isinstance(node, ast.Assign) and any(isinstance(target, ast.Name) and target.id == 'VERSION' for target in node.targets)
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"protocolVersion": 1,
|
||||
"platform": "debian-amd64",
|
||||
"sha256": "e773c907141c97eab2055b354306973b248a67109a51337f1f1d76d80aaa8701",
|
||||
"updaterProtocolVersion": 1,
|
||||
"updaterSha256": "066bb896726600ece9ce72697ce523c9a707cc9a621505931ba0892dec59d3c8"
|
||||
"updaterProtocolVersion": 2,
|
||||
"updaterSha256": "e4ea1857dcadd9b3e7861bb44cf20d06216047ffa81053ce3ccd7278470013fe",
|
||||
"installerSha256": "b7f680577c8e0ee0f892a2c43211bd0f1ca64b6c92ba51389d2abca9cfb2c26c"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user