Refresh agent signing keys from repository (Refs #64)
This commit is contained in:
+52
-38
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user