From 1e98b37e45e51d04ab97abc27787906e91a0bc70 Mon Sep 17 00:00:00 2001 From: ScottW514 Date: Sun, 20 Sep 2026 21:41:21 -0400 Subject: [PATCH] forgeext on the image, and the tests of the host and of the consent The image installs forgeext beside forgefirm-sandbox. The recipe installs the init script from forgeext's own tree (start 91, after forgectrl at 90, whose read-only routes the host takes the machine's state from; stop 9, down before it). The host runs nothing while ext_enabled is 0, the default. setup.extensions-consent (takeover): the Extensions advisory is served and is no first-run document; ext_enabled=1 is refused without the advisory's hash, with a stale one, without the phrase, with the phrase in another case, and whole beside a write the daemon refuses, each leaving the setting and the record on disk untouched; with the hash and the phrase it is accepted, recorded under on_demand.extensions, and the data directory gains the search bit and nothing else. The setting, the directory's mode, and the record are put back, the record under a forgectrl restart. exthost.service (takeover): a reference package built on the board, signed with a key made there and added as an owner key. It is unverified before the key is the owner's and community after; the install is refused without the consent. Turned on over the advisory, the service is looked at from outside (account, no_new_privs, seccomp, group, limits, chain) and from inside (what it can read, write, dial, and open), its first line is looked for in the forgeext logger's file, safe mode stops it, a host killed with the service in its quiet loop takes it along within 2 s and comes back, and ext_enabled=0 leaves no group and no chain. The package, the key, the setting, the mode, and the record are put back, and the extension root is compared with how it was found. The reference package exists only while the test runs: no image carries it, and no image trusts its key. Proven. forgetest's unit suite: 422 tests pass, 0 undefined names. On the bench reference, image 20260920211625 with the cross-built forgectrl and host from /tmp: setup.extensions-consent PASS and, against the image's own daemon, FAIL at its first request; exthost.service PASS and, with the init wrapper's kill taken out, FAIL at the killed-host check. The first runs of exthost.service found what the host tests could not: /data/forgefirm is 0700 on the bench reference, and the service ended with EACCES on its own entry point until forgectrl opened the directory for search with the consent. Acceptance. Both tests are new. exthost.service covers forgeext whole and forgectrl's src/main.c and src/logs.*; setup.extensions-consent covers forgectrl's consent path. The recipe, the image line, and the init script's install are layer content, in the platform identity of every fingerprint. forgeext joins scripts/manifest-from-tree.py with its first pin. --- forgetest/forgetest/suite/exthost.py | 378 ++++++++++++++++++ forgetest/forgetest/suite/setup.py | 140 +++++++ .../recipes-forgefirm/forgeext/forgeext.bb | 12 +- .../images/forgefirm-image.bb | 6 +- 4 files changed, 533 insertions(+), 3 deletions(-) diff --git a/forgetest/forgetest/suite/exthost.py b/forgetest/forgetest/suite/exthost.py index 0a33c12..5e99e2e 100644 --- a/forgetest/forgetest/suite/exthost.py +++ b/forgetest/forgetest/suite/exthost.py @@ -15,6 +15,7 @@ import time from ..catalog import test from .forgectrl import lan_ip from .image import kernel_config +from .setup import SAFETY_PHRASE, read_file, record_path, request, write_file CG = "/sys/fs/cgroup" POOL_GROUP = CG + "/ffx" @@ -464,3 +465,380 @@ def platform(ctx): ctx.check(not os.path.isdir(PROBE_GROUP), "the probe group stayed behind") ctx.log("PASS: the kernel, the cgroup tree, the account pool, and the deny rules are in place, and each " "held a probe process the way it will hold a package") + + +# ------------------------------------------------------------ the host + +FORGEEXT = "/usr/bin/forgeext" +FWUP = "/usr/bin/fwup" +EXT_ROOT = "/data/forgefirm/ext" +HOST_STATUS = "/run/forgefirm/ext/status.json" +SAFE_FILE = "/run/forgefirm/ext-safe" +HOST_LOG = "/data/log/forgefirm/forgeext/forgeext.log" +REF_ID = "org.forgetest.reference" +REF_KEY = "forgetest-reference" +REF_DEST = "192.0.2.1" # TEST-NET-1: declared so that port 443 is, and never dialed + +# The reference package's service. It looks at its own confinement from the +# inside, leaves what it found in its data directory, and stays up. +REF_SERVICE = r''' +import errno, json, os, socket, sys, time +lan = sys.argv[1] +data, pkg = os.environ["FFX_DATA"], os.environ["FFX_PKG"] + + +def word(e): + return errno.errorcode.get(e.errno, str(e.errno)) + + +def opened(path, mode="r"): + try: + with open(path, mode) as f: + if "r" in mode: + f.read(1) + else: + f.write("x") + return "ok" + except OSError as e: + return word(e) + + +def dial(addr, port): + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.settimeout(4.0) + try: + s.connect((addr, port)) + return "connected" + except socket.timeout: + return "timeout" + except OSError as e: + return word(e) + finally: + s.close() + + +def family(af): + try: + socket.socket(af, socket.SOCK_RAW if af == 16 else socket.SOCK_DGRAM).close() + return "ok" + except OSError as e: + return word(e) + + +report = { + "uid": os.getuid(), "gid": os.getgid(), "groups": os.getgroups(), "cwd": os.getcwd(), + "env": sorted(os.environ), + "read_settings": opened("/data/forgefirm/forgefirm.conf"), + "read_setup": opened("/data/forgefirm/setup.json"), + "read_etc": opened("/etc/hostname"), + "read_pkg": opened(os.path.join(pkg, "manifest.json")), + "write_pkg": opened(os.path.join(pkg, "x"), "w"), + "write_data": opened(os.path.join(data, "scratch"), "w"), + "write_tmp": opened("/tmp/forgetest-reference", "w"), + "pulse_device": opened("/dev/glowforge"), + "loopback_declared_port": dial("127.0.0.1", 443), + "lan_declared_port": dial(lan, 443) if lan != "-" else "skipped", + "loopback_undeclared_port": dial("127.0.0.1", 80), + "netlink_socket": family(16), + "unix_socket": family(1), +} +with open(os.path.join(data, "report.json.new"), "w") as f: + json.dump(report, f) +os.rename(os.path.join(data, "report.json.new"), os.path.join(data, "report.json")) +print("reference service up", flush=True) +n = 0 +while True: + n += 1 + with open(os.path.join(data, "beat"), "w") as f: + f.write(str(n)) + time.sleep(0.5) +''' + + +def _host_pids(): + """The extension host: /usr/bin/forgeext run, and not an install or a + check someone has under way.""" + out = [] + for d in os.listdir("/proc"): + if not d.isdigit(): + continue + try: + if os.readlink("/proc/%s/exe" % d) != FORGEEXT: + continue + with open("/proc/%s/cmdline" % d, "rb") as f: + argv = f.read().split(b"\0") + except OSError: + continue + if argv[:2] == [FORGEEXT.encode(), b"run"]: + out.append(int(d)) + return out + + +def _host_status(): + try: + with open(HOST_STATUS) as f: + return json.load(f) + except (OSError, ValueError): + return {} + + +def _svc(id_): + return next((x for x in _host_status().get("services", []) if x.get("id") == id_), {}) + + +def _forgeext(*args, wait=120): + p = subprocess.run([FORGEEXT] + list(args), capture_output=True, text=True, timeout=wait) + try: + return json.loads(p.stdout) + except ValueError: + return {"ok": False, "error": "no JSON answer (exit %s): %s %s" % (p.returncode, p.stdout[-200:], p.stderr[-200:])} + + +def _until(ctx, cond, seconds, poll=0.25): + end = time.time() + seconds + while time.time() < end: + v = cond() + if v: + return v + ctx.checkpoint() + time.sleep(poll) + return cond() + + +def _tree(root): + """Every path under the extension root but the host's own bookkeeping.""" + out = [] + for base, dirs, files in os.walk(root): + for n in dirs + files: + rel = os.path.relpath(os.path.join(base, n), root) + if rel not in ("state.json", "lock"): + out.append(rel) + return sorted(out) + + +def _pack_reference(work, lan): + """The reference package, signed with a key made here: (archive, public key).""" + import io + import tarfile + manifest = {"manifest": 1, "id": REF_ID, "name": "forgetest reference", "version": "1.0.0", + "author": "forgetest", "license": "MIT", "api": "0.1", "runtime": "python", + "service": {"exec": "bin/reference.py", "args": [lan or "-"]}, + "capabilities": ["net.outbound:%s:443" % REF_DEST, "storage:1"]} + payload = os.path.join(work, "payload.tar.gz") + with tarfile.open(payload, "w:gz") as t: + for name, text, mode in (("manifest.json", json.dumps(manifest), 0o644), + ("bin/reference.py", REF_SERVICE, 0o755)): + info = tarfile.TarInfo(name) + data = text.encode() + info.size, info.mode = len(data), mode + t.addfile(info, io.BytesIO(data)) + conf = os.path.join(work, "fwup.conf") + _write(conf, 'meta-product = "ForgeFIRM extension"\nmeta-description = "%s"\nmeta-version = "1.0.0"\n' + 'meta-platform = "forgefirm-ext"\nfile-resource payload.tar.gz {\n host-path = "%s"\n}\n' + % (REF_ID, payload)) + key = os.path.join(work, REF_KEY) + raw, signed = os.path.join(work, "raw.ffx"), os.path.join(work, "reference.ffx") + for cmd in ([FWUP, "-g", "-o", key], [FWUP, "-c", "-f", conf, "-o", raw], + [FWUP, "-S", "-s", key + ".priv", "-i", raw, "-o", signed]): + subprocess.run(cmd, check=True, capture_output=True, timeout=60, cwd=work) + return signed, key + ".pub" + + +@test("exthost.service", title="A package's service runs confined under the extension host", + subsystem="exthost", kind="auto", hardware="takeover", est_min=5, + covers=[("forgeext", "**"), ("forgectrl", "src/main.c"), ("forgectrl", "src/logs.*")], + requires=["exthost.platform", "setup.extensions-consent"], + description="The extension host is one running process (/usr/bin/forgeext run) and the status " + "file is its word, and with extensions off it runs nothing and says why. The test builds a reference " + "package on the board, signs it with a key it makes there, and adds that key as an owner " + "key: an install without the community consent is refused, with it the package is " + "installed at the community tier. It turns extensions on over the advisory. The service " + "then runs as its pool account with no_new_privs and a seccomp filter, in its own group " + "under /sys/fs/cgroup/ffx with the limits of a service (25 percent of the core, 48 MiB, 32 " + "processes), with its chain in the rule table. From the inside it reads /etc and its " + "package, writes its data directory and nothing else, cannot read the settings file, the " + "setup record, or the pulse device, is refused by the machine on loopback and on its LAN " + "address even on the port it declared, cannot connect on a port it did not declare, and " + "cannot open a netlink socket; its output is in the forgeext log under its id. Safe mode " + "stops it and its end starts it again. A host killed outright takes its services with it " + "at once (the init wrapper), comes back, and starts the service again. ext_enabled=0 " + "stops it and leaves no group and no chain. The package, the key, the setting, and the " + "setup record are put back as found, the record under a forgectrl restart. The layer " + "content (the recipe, the init script's install, the image list) is in the platform " + "identity of every fingerprint.") +def service(ctx): + import shutil + import tempfile + fc = ctx.forgectrl + ev = ctx.evidence + ctx.check(os.path.isfile(FORGEEXT) and os.access("/etc/init.d/forgeext", os.X_OK), + "the image has no forgeext, or no init script for it") + hosts = _host_pids() + ev["host_pids"] = hosts + ctx.check(len(hosts) == 1, "the extension host is not one running process: %s", hosts) + ctx.check(_until(ctx, lambda: _host_status().get("pid") == hosts[0], 10), + "the status file %s is not the running host's: %s", HOST_STATUS, _host_status().get("pid")) + ctx.check(fc.wait_idle(timeout=30, abort=ctx.aborted), "machine not idle: settings are locked") + prior = fc.settings().get("ext_enabled") or "" + raw = read_file(record_path()) + ctx.check(raw, "no setup record at %s", record_path()) + ctx.check(not os.path.exists(SAFE_FILE), "%s exists: the machine is in safe mode", SAFE_FILE) + ctx.check(REF_ID not in [x.get("id") for x in _forgeext("list").get("packages", [])], + "%s is already installed", REF_ID) + found_tree = _tree(EXT_ROOT) + data_dir = os.path.dirname(EXT_ROOT) + dir_mode = os.stat(data_dir).st_mode & 0o7777 + ev["data_dir_mode_found"] = "%04o" % dir_mode + if prior != "1": + st = _host_status() + ev["off"] = {k: st.get(k) for k in ("enabled", "off_reason")} + ctx.check(st.get("enabled") is False and "ext_enabled" in (st.get("off_reason") or "") + and not [x for x in st.get("services", []) if x.get("state") == "running"], + "with extensions off the host does not say so: %s", ev["off"]) + + st, body, hdrs = request(fc.base, "GET", "/advisories/extensions", headers={"Host": fc.host_header()}) + etag = hdrs.get("etag") + ctx.check(st == 200 and etag, "GET /advisories/extensions -> %s", st) + work = tempfile.mkdtemp(prefix="forgetest-ffx.") + owner_key = os.path.join(EXT_ROOT, "keys", REF_KEY + ".pub") + uid = None + + def cg(name): + return _read("%s/%s/%s" % (POOL_GROUP, REF_ID, name)).strip() + + def chains(): + return subprocess.run([NFT, "list", "table", "inet", "ffx"], capture_output=True, text=True, timeout=30).stdout + + def running(other_than=0): + # The status file outlives a killed host: a pid that is the old one is no news. + x = _svc(REF_ID) + return x if x.get("state") == "running" and x.get("pid") and x["pid"] != other_than and os.path.exists("/proc/%d" % x["pid"]) else None + + try: + lan = lan_ip() + archive, pub = _pack_reference(work, lan) + r = _forgeext("inspect", archive) + ev["inspect_without_the_key"] = r.get("tier") + ctx.check(r.get("ok") and r.get("tier") == "unverified", "before its key is the owner's the package reads %s", r) + shutil.copy(pub, owner_key) + os.chmod(owner_key, 0o644) + r = _forgeext("inspect", archive) + ctx.check(r.get("ok") and r.get("tier") == "community", "with the owner's key the package reads %s", r) + r = _forgeext("install", archive) + ev["install_without_consent"] = r.get("error") + ctx.log("install without the consent -> %s", r.get("error")) + ctx.check(r.get("ok") is False, "a community package was installed without the consent") + r = _forgeext("install", archive, "--consent-community") + ctx.check(r.get("ok") is True, "the install -> %s", r.get("error")) + ctx.check(_forgeext("check", REF_ID).get("ok") is True, "the installed tree fails its integrity check") + + st, reply = fc.post("/settings", data={"ext_enabled": "1", "advisory": etag, "phrase": SAFETY_PHRASE}) + ctx.check(st == 200, "ext_enabled=1 over the advisory -> %s %r", st, reply) + x = _until(ctx, running, 90, poll=0.5) + ev["status_at_start"] = _host_status() + ctx.check(x, "the service is not running: %s", {k: ev["status_at_start"].get(k) for k in ("enabled", "off_reason", "not_ready")} + if not _svc(REF_ID) else _svc(REF_ID)) + pid = x["pid"] + uid = POOL_FIRST + int(x["account"][3:]) + proc = _read("/proc/%d/status" % pid) + ev["service"] = {"pid": pid, "account": x["account"], "cgroup": _read("/proc/%d/cgroup" % pid).strip(), + "cpu.max": cg("cpu.max"), "memory.max": cg("memory.max"), "pids.max": cg("pids.max"), + "memory.current": cg("memory.current")} + ctx.log("the service: %s", ev["service"]) + ctx.check(("Uid:\t%d\t%d\t%d\t%d" % ((uid,) * 4)) in proc and ("Gid:\t%d\t%d\t%d\t%d" % ((uid,) * 4)) in proc, + "the service does not run as %s alone", x["account"]) + ctx.check("NoNewPrivs:\t1" in proc and "Seccomp:\t2" in proc, "no no_new_privs or no seccomp filter on the service") + ctx.check(ev["service"]["cgroup"] == "0::/ffx/" + REF_ID, "the service's group is %s", ev["service"]["cgroup"]) + ctx.check(cg("cpu.max").split() == ["25000", "100000"] and cg("memory.max") == str(48 << 20) and cg("pids.max") == "32", + "the group's limits are not a service's: %s", ev["service"]) + table = chains() + ctx.check(("chain u%d " % uid) in table and REF_DEST in table, "the service's chain is not in the rule table") + + report_path = os.path.join(EXT_ROOT, "data", REF_ID, "report.json") + ctx.check(_until(ctx, lambda: os.path.isfile(report_path), 60, poll=0.5), "the service left no report") + rep = json.loads(_read(report_path)) + ev["from_the_inside"] = rep + ctx.log("from the inside: %s", rep) + want = {"uid": uid, "gid": uid, "groups": [], "read_etc": "ok", "read_pkg": "ok", "write_data": "ok", + "read_settings": "EACCES", "read_setup": "EACCES", "write_pkg": "EACCES", "write_tmp": "EACCES", + "pulse_device": "EACCES", "loopback_declared_port": "ECONNREFUSED", + "loopback_undeclared_port": "EACCES", "unix_socket": "ok", "netlink_socket": "EPERM"} + if lan: + want["lan_declared_port"] = "ECONNREFUSED" + for k, v in want.items(): + ctx.check(rep.get(k) == v, "from the inside, %s is %r, expected %r", k, rep.get(k), v) + # LC_CTYPE is the interpreter's own doing (it coerces the C locale at its start) + fixed = {"PATH", "LANG", "HOME", "TMPDIR", "FFX_ID", "FFX_PKG", "FFX_DATA", "PYTHONDONTWRITEBYTECODE", + "PYTHONUNBUFFERED"} + ctx.check(rep.get("cwd") == os.path.join(EXT_ROOT, "data", REF_ID) and set(rep.get("env") or []) - {"LC_CTYPE"} == fixed, + "its working directory or its environment is not the fixed one: %s %s", rep.get("cwd"), rep.get("env")) + ctx.check(_until(ctx, lambda: ("ext %s: reference service up" % REF_ID) in _read(HOST_LOG), 20, poll=1), + "the service's output is not in %s under its id", HOST_LOG) + + _write(SAFE_FILE, "") + ctx.check(_until(ctx, lambda: not running() and "safe mode" in (_host_status().get("off_reason") or ""), 15), + "safe mode did not stop the service: %s", _host_status()) + ctx.check(not os.path.exists("/proc/%d" % pid) and not os.path.isdir("%s/%s" % (POOL_GROUP, REF_ID)), + "safe mode left the process or its group") + os.remove(SAFE_FILE) + x = _until(ctx, lambda: running(pid), 60, poll=0.5) + ctx.check(x, "out of safe mode the service did not start again: %s", _svc(REF_ID)) + pid = x["pid"] + ctx.log("safe mode stopped it and its end started it again (pid %d)", pid) + + host = _host_pids() + ctx.check(len(host) == 1, "the extension host is not one process: %s", host) + # Only once the service is in its quiet loop: a service still on its way up ends by + # itself when its first line meets the dead host's pipe, and that would prove nothing. + beat_path = os.path.join(EXT_ROOT, "data", REF_ID, "beat") + seen = set() + ctx.check(_until(ctx, lambda: seen.add(_read(beat_path)) or len(seen) >= 3, 30, poll=0.2), + "the service's heartbeat does not advance") + t0 = time.time() + os.kill(host[0], signal.SIGKILL) + ctx.check(_until(ctx, lambda: not os.path.exists("/proc/%d" % pid), 2, poll=0.05), + "a killed host left its service running for 2 s: nobody would freeze it in an armed window") + ev["service_outlived_a_killed_host_s"] = round(time.time() - t0, 2) + x = _until(ctx, lambda: running(pid) if _host_status().get("pid") in _host_pids() else None, 60, poll=0.5) + ev["host_back_s"] = round(time.time() - t0, 1) + ctx.check(x, "the host did not come back and start the service again: %s", _host_status()) + pid = x["pid"] + ctx.log("a killed host: its service gone in %.2f s, the host back and the service running after %.1f s", + ev["service_outlived_a_killed_host_s"], ev["host_back_s"]) + + st, reply = fc.post("/settings", data={"ext_enabled": "0"}) + ctx.check(st == 200, "ext_enabled=0 -> %s %r", st, reply) + ctx.check(_until(ctx, lambda: not running() and not os.path.exists("/proc/%d" % pid), 15), + "ext_enabled=0 did not stop the service: %s", _svc(REF_ID)) + ctx.check(not os.path.isdir("%s/%s" % (POOL_GROUP, REF_ID)) and ("chain u%d " % uid) not in chains(), + "ext_enabled=0 left the service's group or its chain") + ctx.check("ext_enabled" in (_host_status().get("off_reason") or ""), "the host does not say why nothing runs: %s", + _host_status().get("off_reason")) + finally: + if os.path.exists(SAFE_FILE): + os.remove(SAFE_FILE) + st, reply = fc.post("/settings", data={"ext_enabled": "0"}) + r = _forgeext("remove", REF_ID) + ctx.log("remove %s -> %s", REF_ID, "ok" if r.get("ok") else r.get("error")) + if os.path.exists(owner_key): + os.remove(owner_key) + shutil.rmtree(work, ignore_errors=True) + if prior == "1": + st, reply = fc.post("/settings", data={"ext_enabled": "1", "advisory": etag, "phrase": SAFETY_PHRASE}) + elif prior == "": + st, reply = fc.post("/settings", params={"ext_enabled": ""}) + ctx.log("restore ext_enabled=%r -> %s", prior, st) + if prior != "1": + os.chmod(data_dir, dir_mode) + with ctx.takeover(): + write_file(record_path(), raw) + ctx.log("the previous record is back under a restart") + + ctx.check((fc.settings().get("ext_enabled") or "") == prior, "ext_enabled not restored: %r, was %r", + fc.settings().get("ext_enabled"), prior) + ctx.check(read_file(record_path()) == raw, "the setup record on disk is not the one found") + ctx.check(prior == "1" or os.stat(data_dir).st_mode & 0o7777 == dir_mode, "the data directory's mode is not the one found") + left = _tree(EXT_ROOT) + ctx.check(left == found_tree, "the extension root is not as found: %s", sorted(set(left) ^ set(found_tree))) + ctx.check(len(_host_pids()) == 1, "the extension host is not running at the end") + diff --git a/forgetest/forgetest/suite/setup.py b/forgetest/forgetest/suite/setup.py index d4055b9..475252c 100644 --- a/forgetest/forgetest/suite/setup.py +++ b/forgetest/forgetest/suite/setup.py @@ -1011,6 +1011,146 @@ def cloud_disabled_surface(ctx): "the list did not come back as found: %s, was %s", back, e) +# ----------------------------------------------------------- extensions + +@test("setup.extensions-consent", title="Extensions are turned on over their own advisory", + subsystem="setup", kind="auto", hardware="takeover", est_min=3, + covers=[("forgectrl", "src/main.c"), ("forgectrl", "src/advisories.*"), ("forgectrl", "src/setup.*"), + ("forgectrl", "src/wiz.c"), ("forgectrl", "src/settings.*"), + ("forgectrl", "docs/advisories/extensions.md"), ("forgectrl", "src/ui/embed_docs.cmake")], + requires=["forgectrl.settings-bounds", "setup.advisories-rehash"], + description="The Extensions advisory is on demand: GET /wiz does not list it among the " + "first-run documents, GET /advisories/extensions serves it as markdown with an " + "ETag equal to the SHA-256 of the body, and the first-run accept refuses it (400) " + "and leaves the press. The test turns ext_enabled off itself and puts it back as " + "found. From off, POST /settings ext_enabled=1 is refused without the advisory's " + "hash and with a stale one (409), and without the typed phrase and with the phrase " + "in another case (400); each refusal leaves the setting at 0 and the record on " + "disk untouched. A request that carries the hash and the phrase beside a write the " + "daemon refuses (controller_mode=cloud with cloud_enabled=0, 409) is refused whole " + "and records nothing. With the " + "hash and the phrase it is accepted (200): the setting reads 1, the record on disk " + "holds on_demand.extensions with that hash and the typed method, the first-run " + "documents and their press are as they were, and the data directory has gained " + "the search bit for group and others and nothing else (a package's account walks " + "through it to its own files). Re-sending 1 while it stands and " + "sending 0 ask for nothing. The previous record is put back under a forgectrl " + "restart.") +def extensions_consent(ctx): + fc = ctx.forgectrl + ev = ctx.evidence + ctx.check(fc.wait_idle(timeout=30, abort=ctx.aborted), "machine not idle: settings are locked") + raw = read_file(record_path()) + ctx.check(raw, "no setup record at %s", record_path()) + before = wiz(fc) + ev["before"] = wiz_summary(before) + prior = fc.settings().get("ext_enabled") or "" + dir_mode = os.stat(data_dir()).st_mode & 0o7777 + ev["found"] = {"ext_enabled": prior, "data_dir_mode": "%04o" % dir_mode} + + def on_disk(): + try: + return json.loads((read_file(record_path()) or b"{}").decode("utf-8")) + except ValueError: + return {} + + ctx.check("extensions" not in [d.get("id") for d in before.get("documents") or []], + "GET /wiz lists the Extensions advisory among the first-run documents") + st, body, hdrs = request(fc.base, "GET", "/advisories/extensions", headers={"Host": fc.host_header()}) + etag = hdrs.get("etag") + ev["etag"] = etag + ctx.log("GET /advisories/extensions -> %s, %d bytes, ETag %s", st, len(body), etag) + ctx.check(st == 200 and body, "GET /advisories/extensions -> %s", st) + ctx.check((hdrs.get("content-type") or "").startswith("text/markdown"), + "the document is not served as markdown: %r", hdrs.get("content-type")) + ctx.check(etag == hashlib.sha256(body).hexdigest(), "the ETag is not the SHA-256 of the body") + + try: + st, reply = fc.post("/wiz/advisories/accept", + data={"doc": "extensions", "hash": etag, "phrase": SAFETY_PHRASE}) + ev["first_run_accept"] = st + ctx.log("the first-run accept of the on-demand document -> %s %s", st, reply) + ctx.check(st == 400, "the first-run accept of the on-demand document -> %s, expected 400", st) + ctx.check(wiz(fc).get("acceptance_done") == before.get("acceptance_done"), + "the refused accept changed the press record") + + if prior == "1": + st, reply = fc.post("/settings", data={"ext_enabled": "0"}) + ctx.check(st == 200, "ext_enabled=0 -> %s %s", st, reply) + ctx.log("extensions turned off for the test (found %r)", prior) + ctx.check((fc.settings().get("ext_enabled") or "0") == "0", "ext_enabled does not read off") + start = on_disk() + + for name, form, want, words in ( + ("no hash", {"ext_enabled": "1"}, 409, "read the Extensions advisory first"), + ("stale hash", {"ext_enabled": "1", "advisory": "0" * 64, "phrase": SAFETY_PHRASE}, 409, + "read the Extensions advisory first"), + ("no phrase", {"ext_enabled": "1", "advisory": etag}, 400, "type I UNDERSTAND"), + ("phrase in another case", {"ext_enabled": "1", "advisory": etag, + "phrase": SAFETY_PHRASE.lower()}, 400, "type I UNDERSTAND"), + ("good consent beside a refused write", {"ext_enabled": "1", "advisory": etag, + "phrase": SAFETY_PHRASE, "cloud_enabled": "0", + "controller_mode": "cloud"}, + 409, "cloud mode is not enabled on this machine")): + st, reply = fc.post("/settings", data=form) + ev[name] = st + ctx.log("POST /settings ext_enabled=1, %s -> %s %s", name, st, reply if isinstance(reply, str) else "") + ctx.check(st == want, "%s -> %s, expected %s", name, st, want) + ctx.check(isinstance(reply, str) and words in reply, "%s was refused in other words: %r", name, reply) + ctx.check((fc.settings().get("ext_enabled") or "0") == "0", "%s: a refused write turned extensions on", name) + ctx.check(on_disk() == start, "%s: a refused write changed the record on disk", name) + + st, reply = fc.post("/settings", data={"ext_enabled": "1", "advisory": etag, "phrase": SAFETY_PHRASE}) + ev["accept"] = st + ctx.log("POST /settings ext_enabled=1 with the hash and the phrase -> %s", st) + ctx.check(st == 200, "the consent -> %s %r", st, reply) + ctx.check(fc.settings().get("ext_enabled") == "1", "ext_enabled does not read 1") + rec = on_disk() + entry = (rec.get("on_demand") or {}).get("extensions") or {} + ev["recorded"] = entry + ctx.check(entry.get("hash") == etag and entry.get("method") == "typed" and entry.get("accepted"), + "the record on disk does not carry the acceptance: %s", entry) + ctx.check("extensions" not in (rec.get("advisories") or {}), + "the acceptance landed among the first-run documents") + # a package's account walks through the data directory to its own files: the + # consent adds the search bit, and nothing else + now_mode = os.stat(data_dir()).st_mode & 0o7777 + ev["data_dir_mode_after"] = "%04o" % now_mode + ctx.check(now_mode == dir_mode | 0o011, "the data directory went from %04o to %04o, expected %04o", + dir_mode, now_mode, dir_mode | 0o011) + ctx.check(rec.get("advisories") == start.get("advisories") and rec.get("acceptance") == start.get("acceptance"), + "the first-run documents or their press moved") + after = wiz(fc) + ctx.check(after.get("acceptance_done") == before.get("acceptance_done") and after.get("gate") == before.get("gate"), + "the machine's gate moved with the consent: %s", wiz_summary(after)) + for form in ({"ext_enabled": "1"}, {"ext_enabled": "0"}): + st, reply = fc.post("/settings", data=form) + ctx.check(st == 200, "%s while on -> %s, expected 200", form, st) + ctx.check(fc.settings().get("ext_enabled") == "0", "ext_enabled=0 did not turn extensions off") + finally: + if prior == "": + st, reply = fc.post("/settings", params={"ext_enabled": ""}) + elif prior == "1": + st, reply = fc.post("/settings", data={"ext_enabled": "1", "advisory": etag, "phrase": SAFETY_PHRASE}) + else: + st, reply = fc.post("/settings", data={"ext_enabled": prior}) + ctx.log("restore ext_enabled=%r -> %s", prior, st) + if prior != "1": + os.chmod(data_dir(), dir_mode) + with ctx.takeover(): + write_file(record_path(), raw) + ctx.log("the previous record is back under a restart") + + ctx.check((fc.settings().get("ext_enabled") or "") == prior, "ext_enabled not restored: %r, was %r", + fc.settings().get("ext_enabled"), prior) + ctx.check(read_file(record_path()) == raw, "the record on disk is not the one found") + ctx.check(prior == "1" or os.stat(data_dir()).st_mode & 0o7777 == dir_mode, "the data directory's mode is not the one found") + final = wiz(fc) + ev["after_restore"] = wiz_summary(final) + ctx.check(final.get("acceptance_done") == before.get("acceptance_done") and final.get("gate") == before.get("gate"), + "the machine does not read as before: %s", wiz_summary(final)) + + # ---------------------------------------------------------- the operator @test("setup.factory-return", title="The return to the factory firmware is guarded", diff --git a/meta-forgefirm/recipes-forgefirm/forgeext/forgeext.bb b/meta-forgefirm/recipes-forgefirm/forgeext/forgeext.bb index 4d98914..0069d1c 100644 --- a/meta-forgefirm/recipes-forgefirm/forgeext/forgeext.bb +++ b/meta-forgefirm/recipes-forgefirm/forgeext/forgeext.bb @@ -12,7 +12,7 @@ require forgeext-pin.inc S = "${WORKDIR}/git" -inherit cmake pkgconfig forgefirm-manifest +inherit cmake pkgconfig update-rc.d forgefirm-manifest # jansson (the manifest, state.json, every answer), libarchive (the .ffx and # its payload, read streaming), libsodium (the archive's signature and the @@ -23,3 +23,13 @@ DEPENDS += "jansson libarchive libsodium" # extension is never signed with; forgefirm-sandbox is the account pool, # the cgroup tree, and the deny rules a package's service runs inside. RDEPENDS:${PN} = "fwup forgefirm-keys forgefirm-sandbox" + +# After forgectrl (90), whose read-only routes the host takes the machine's +# state from, and down before it. +INITSCRIPT_NAME = "forgeext" +INITSCRIPT_PARAMS = "start 91 2 3 4 5 . stop 9 0 1 6 ." + +do_install:append() { + install -d ${D}${sysconfdir}/init.d + install -m 0755 ${S}/init/forgeext.init ${D}${sysconfdir}/init.d/forgeext +} diff --git a/meta-forgefirm/recipes-forgefirm/images/forgefirm-image.bb b/meta-forgefirm/recipes-forgefirm/images/forgefirm-image.bb index ee6fa89..18c76cb 100644 --- a/meta-forgefirm/recipes-forgefirm/images/forgefirm-image.bb +++ b/meta-forgefirm/recipes-forgefirm/images/forgefirm-image.bb @@ -68,8 +68,10 @@ IMAGE_INSTALL:append = " forgefirm-users forgefirm-hostname forgefirm-banner for # any package exists: the ffx account pool, the cgroup v2 tree with the cpu, # memory, and pids controllers, and the nftables rules (nft comes with it) # that refuse everything a pool uid sends, loaded from rcS before the -# network starts. -IMAGE_INSTALL:append = " forgefirm-sandbox" +# network starts. forgeext: the extension host that verifies, installs, +# and runs packages inside it. It starts after forgectrl and runs nothing +# while ext_enabled is 0, which is the default. +IMAGE_INSTALL:append = " forgefirm-sandbox forgeext" # The rootfs mounts read-only on both images; /data (p3) is the writable # partition. read-only-rootfs is poky's feature for it: the root line of