Refresh agent signing keys from repository (Refs #64)

This commit is contained in:
2026-10-09 21:39:03 -04:00
parent 6f71822d41
commit a93ddb5ef9
14 changed files with 420 additions and 214 deletions
+52 -38
View File
@@ -27,7 +27,8 @@ import uuid
UPDATER_PROTOCOL_VERSION = 2
AGENT_CONFIG = Path("/etc/electric-cherry/agent.json")
RELEASE_KEYRING = Path("/etc/electric-cherry/release-signing.gpg")
# This repository is the trust authority, independent of controller configuration.
RELEASE_KEY_URL = "https://gitea.thewrightserver.net/josh/electric-cherry/raw/branch/main/agent/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")
@@ -39,6 +40,7 @@ AGENT_HEALTH = Path("/etc/electric-cherry/agent-health.json")
AGENT_SERVICE = "electric-cherry-agent.service"
MAX_MANIFEST = 16 * 1024
MAX_SIGNATURE = 16 * 1024
MAX_KEYRING = 256 * 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})$")
@@ -153,13 +155,51 @@ 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.
def fetch_url(url, limit, source):
request = urllib.request.Request(url, headers={"User-Agent": "electric-cherry-updater/2", "Cache-Control": "no-cache"})
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
raise ContactError("Agent release redirects are not allowed")
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
with urllib.request.build_opener(NoRedirect()).open(request, timeout=25) as response:
if response.status != 200:
raise ContactError(f"The {source} did not return a complete download")
length = response.headers.get("Content-Length")
if length is not None and (not length.isdecimal() or int(length) > limit):
raise IntegrityError(f"The {source} exceeds its download size limit")
chunks, total = [], 0
deadline = time.monotonic() + 120
while True:
if time.monotonic() >= deadline:
raise ContactError(f"The {source} download timed out")
# HTTPResponse.read1 performs one socket read. read(n) can
# wait indefinitely for n bytes from a trickling server.
block = response.read1(min(64 * 1024, limit + 1 - total))
if not block:
break
total += len(block)
if total > limit:
raise IntegrityError(f"The {source} exceeds its download size limit")
chunks.append(block)
if length is not None and total != int(length):
raise ContactError(f"The {source} download was interrupted")
return b"".join(chunks)
except (OSError, ValueError, http.client.HTTPException):
raise ContactError(f"The {source} is unavailable; the running agent was left unchanged") from None
def fetch_release_key():
# Fetch afresh before every release check, including checks with no new
# version. There is deliberately no persistent keyring or offline fallback.
key = fetch_url(RELEASE_KEY_URL, MAX_KEYRING, "release-signing repository")
if not key:
raise IntegrityError("The release-signing repository returned an empty keyring")
return key
def verify_release_signature(manifest, signature, public_key):
if not public_key or len(public_key) > MAX_KEYRING:
raise IntegrityError("The repository release-signing keyring is empty or too large")
if not signature:
raise IntegrityError("The controller release requires a detached manifest signature")
trusted(UPDATE_DIR, directory=True)
@@ -167,6 +207,8 @@ def verify_release_signature(manifest, signature):
# The exact downloaded bytes are verified before manifest JSON is parsed.
with tempfile.TemporaryDirectory(prefix="verify-", dir=UPDATE_DIR) as temporary:
home = Path(temporary)
keyring = home / "release-signing.gpg"
keyring.write_bytes(public_key)
manifest_path = home / "manifest.json"
signature_path = home / "manifest.json.sig"
manifest_path.write_bytes(manifest)
@@ -271,45 +313,17 @@ class Updater:
return state
def fetch(self, path, limit):
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")
try:
with urllib.request.build_opener(NoRedirect()).open(request, timeout=25) as response:
if response.status != 200:
raise ContactError("The controller did not return a complete agent release")
length = response.headers.get("Content-Length")
if length is not None and (not length.isdecimal() or int(length) > limit):
raise IntegrityError("The controller release exceeds its download size limit")
chunks, total = [], 0
deadline = time.monotonic() + 120
while True:
if time.monotonic() >= deadline:
raise ContactError("The controller release download timed out")
# HTTPResponse.read1 performs one socket read. read(n) can
# wait indefinitely for n bytes from a trickling server.
block = response.read1(min(64 * 1024, limit + 1 - total))
if not block:
break
total += len(block)
if total > limit:
raise IntegrityError("The controller release exceeds its download size limit")
chunks.append(block)
if length is not None and total != int(length):
raise ContactError("The controller release download was interrupted")
return b"".join(chunks)
except (OSError, ValueError, http.client.HTTPException):
raise ContactError("The controller release is unavailable; the running agent was left unchanged") from None
return fetch_url(self.installation["controller"] + path, limit, "controller release")
def manifest(self):
public_key = fetch_release_key()
manifest = self.fetch("/api/agent/release/manifest.json", MAX_MANIFEST)
try:
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)
verify_release_signature(manifest, signature, public_key)
try:
value = json.loads(manifest)
except (ValueError, UnicodeError):
+19 -8
View File
@@ -96,11 +96,6 @@ 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():
@@ -147,7 +142,7 @@ PY
if [ -f /etc/electric-cherry/agent.json ]; then existing_enrollment=true; fi
if [ -d "$data_dir" ]; then existing_data_root=true; fi
installation_preflight
temporary_dir=$(mktemp -d)
temporary_dir=$(mktemp -d /tmp/electric-cherry-install.XXXXXXXXXX)
updater_timer_was_active=false
update_lock_held=false
activity_lock_held=false
@@ -172,23 +167,39 @@ installation_cleanup() {
exit "$ec_install_status"
}
trap installation_cleanup EXIT
# INSTALLATION_TRUST: always fetch directly from the repository, never the controller.
key_status=$(curl --proto '=https' --tlsv1.2 --fail --silent --show-error \
--connect-timeout 25 --max-time 120 --max-filesize 262144 \
--header 'Cache-Control: no-cache' --write-out '%{http_code}' \
'https://gitea.thewrightserver.net/josh/electric-cherry/raw/branch/main/agent/release-signing.gpg' \
-o "$temporary_dir/release-signing.gpg")
[ "$key_status" = 200 ] && [ -s "$temporary_dir/release-signing.gpg" ] || {
echo 'The release-signing repository did not return a public key. Installation stopped.' >&2
exit 1
}
# INSTALLATION_TRUST_END
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" /etc/electric-cherry/release-signing.gpg <<'PY'
python3 - "$temporary_dir/manifest.json" "$temporary_dir/ec-agent.py" "$temporary_dir/ec-agent-update.py" "$temporary_dir/release-signing.gpg" <<'PY'
# INSTALLATION_RELEASE: check both downloads before installing or executing either.
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):
# The key lives in our private root-owned mktemp directory under sticky /tmp.
# /tmp itself is intentionally writable; require protection of the directory
# and file we created, rather than accepting a pre-existing host keyring.
for path in (keyring.parent, 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 not 0 < keyring.stat().st_size <= 256 * 1024:
raise SystemExit('Repository public key is empty or exceeds its size limit.')
if len(manifest_bytes) > 16 * 1024 or signature.stat().st_size > 16 * 1024:
raise SystemExit('Release signing metadata exceeds its size limit.')
try:
+2 -2
View File
@@ -4,6 +4,6 @@
"platform": "debian-amd64",
"sha256": "e773c907141c97eab2055b354306973b248a67109a51337f1f1d76d80aaa8701",
"updaterProtocolVersion": 2,
"updaterSha256": "e4ea1857dcadd9b3e7861bb44cf20d06216047ffa81053ce3ccd7278470013fe",
"installerSha256": "b7f680577c8e0ee0f892a2c43211bd0f1ca64b6c92ba51389d2abca9cfb2c26c"
"updaterSha256": "a9d7f96b87d0ec74d8a0f476dff6e3d2a7a0bc454a93a51d7b57de00c9ce23cb",
"installerSha256": "384d5e397a51d19cb9fc9ed8793b4314353f726e217030246050859c0bc18231"
}