Keep provisioning credentials out of OEM installs and factory snapshots
Review findings from codex and Copilot: - A rig-supplied credentials file in OEM mode now contributes only its LUKS passphrase: preserved users/root_enc_password fields would have created a hidden provisioning account despite OEM promising no user until first boot. - The factory snapshot is taken writable, scrubbed of deployment credentials (staged authorized_keys, tailscale auth key and join unit, the dead OEM LUKS key and its initramfs drop-ins), then set read-only — a reset years later must not authorize the original deployment key or rejoin its tailnet. - The missing-Node-tarball case is a hard install error on every install, not just OEM: the stash is what lets a factory reset finalize offline. - The configurator's OEM gate checks the runtime package for the service unit as well as the setup binary. - The test harness handles the deliberately skipped mode picker on blank disks when the bundled runtime lacks OEM support. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
445db32792
commit
e49ea2bc96
5 files changed
+110
-15
No files matched your search
+13
-5
@@ -614,12 +614,20 @@ drive_configurator() {
|
||||
capture_console "success-installer-02-install-disk"
|
||||
press ret # the single virtio disk
|
||||
|
||||
wait_for_screen "installation mode" 60
|
||||
capture_console "success-installer-03-install-mode"
|
||||
if $OEM; then
|
||||
press down # "OEM install" sits below "Full disk install" on a blank disk
|
||||
# A blank disk shows the mode picker only when the bundled runtime supports
|
||||
# OEM mode; otherwise the wizard goes straight to the overwrite confirm.
|
||||
wait_for_screen "installation mode\|recovery possible" 60
|
||||
if ocr_screen | grep -qi "installation mode"; then
|
||||
capture_console "success-installer-03-install-mode"
|
||||
if $OEM; then
|
||||
press down # "OEM install" sits below "Full disk install" on a blank disk
|
||||
fi
|
||||
press ret
|
||||
elif $OEM; then
|
||||
capture_console "failure-installer-no-oem-choice"
|
||||
echo "OEM flow requested but this ISO does not offer OEM mode" >&2
|
||||
return 1
|
||||
fi
|
||||
press ret
|
||||
|
||||
wait_for_screen "recovery possible" 60
|
||||
capture_console "success-installer-04-disk-warning-encrypted"
|
||||
|
||||
@@ -791,7 +791,8 @@ oem_supported() {
|
||||
OEM_SUPPORTED=true
|
||||
if [[ -d $mirror ]]; then
|
||||
pkg=$(ls "$mirror/$OMARCHY_RUNTIME_PACKAGE"-[0-9]*.pkg.tar.zst 2>/dev/null | head -1)
|
||||
if [[ -z $pkg ]] || ! tar -tf "$pkg" usr/bin/omarchy-oem-setup &>/dev/null; then
|
||||
if [[ -z $pkg ]] || ! tar -tf "$pkg" usr/bin/omarchy-oem-setup \
|
||||
usr/share/omarchy/install/oem/omarchy-oem-setup.service &>/dev/null; then
|
||||
OEM_SUPPORTED=false
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -65,6 +65,15 @@ class InstallContext:
|
||||
else:
|
||||
raise RuntimeError(f"credentials file missing: {creds_path}")
|
||||
|
||||
if oem:
|
||||
# OEM promises no account exists until first boot. A supplied
|
||||
# credentials file (a rig handing over its LUKS passphrase) must
|
||||
# not smuggle in users or a root password.
|
||||
encryption_password = user_credentials.get("encryption_password")
|
||||
user_credentials = {"users": []}
|
||||
if encryption_password:
|
||||
user_credentials["encryption_password"] = encryption_password
|
||||
|
||||
arch_configuration = dict(user_configuration)
|
||||
arch_configuration.pop("omarchy_install", None)
|
||||
state_dir = Path(os.environ.get("OMARCHY_INSTALL_STATE_DIR", "/run/omarchy-install"))
|
||||
|
||||
@@ -1208,13 +1208,13 @@ def stage_oem_state(ctx: InstallContext) -> None:
|
||||
def _stage_node_tarball(ctx: InstallContext, oem_dir) -> None:
|
||||
tarballs = sorted(NODE_PACKAGES_DIR.glob("node-v*-linux-x64.tar.gz"))
|
||||
if not tarballs:
|
||||
if ctx.oem:
|
||||
raise RuntimeError(
|
||||
"no bundled Node tarball in /opt/packages — OEM first-boot setup "
|
||||
"could not finalize the user offline"
|
||||
)
|
||||
info("› no bundled Node tarball to stash for factory reset; skipping")
|
||||
return
|
||||
# Hard error on every install, not just OEM: the stash is what lets a
|
||||
# later factory reset finalize the next owner offline, and an ISO
|
||||
# build always bundles the tarball — its absence means a broken build.
|
||||
raise RuntimeError(
|
||||
f"no bundled Node tarball in {NODE_PACKAGES_DIR} — first-boot setup "
|
||||
"and factory reset could not finalize a user offline"
|
||||
)
|
||||
|
||||
packages_dir = oem_dir / "packages"
|
||||
packages_dir.mkdir(parents=True, exist_ok=True)
|
||||
@@ -1820,13 +1820,41 @@ def create_factory_snapshot(ctx: InstallContext) -> None:
|
||||
|
||||
info("› snapshotting @ as @factory (read-only)")
|
||||
subprocess.run(
|
||||
["btrfs", "subvolume", "snapshot", "-r", str(root_subvol), str(factory)],
|
||||
["btrfs", "subvolume", "snapshot", str(root_subvol), str(factory)],
|
||||
check=True,
|
||||
)
|
||||
_scrub_factory_snapshot(factory)
|
||||
subprocess.run(
|
||||
["btrfs", "property", "set", "-ts", str(factory), "ro", "true"],
|
||||
check=True,
|
||||
)
|
||||
finally:
|
||||
subprocess.run(["umount", str(top)], check=False, capture_output=True)
|
||||
|
||||
|
||||
# Provisioning credentials staged for THIS deployment's first boot must not
|
||||
# survive into the factory image: a reset years later would otherwise hand the
|
||||
# next owner the original deployment's SSH keys or rejoin its tailnet, and a
|
||||
# stale OEM LUKS key (dead after the first re-key) has no business lingering.
|
||||
# The mkinitcpio/cmdline drop-ins go with the keyfile — a reset rebuild would
|
||||
# otherwise fail on FILES pointing at a scrubbed path.
|
||||
FACTORY_SCRUB_PATHS = (
|
||||
"var/lib/omarchy/oem/authorized_keys",
|
||||
"var/lib/omarchy/oem/luks-key",
|
||||
"etc/omarchy/oem.key",
|
||||
"etc/limine-entry-tool.d/99-omarchy-oem-unlock.conf",
|
||||
"etc/mkinitcpio.conf.d/99-omarchy-oem-key.conf",
|
||||
"etc/tailscale/authkey",
|
||||
"etc/systemd/system/omarchy-tailscale-join.service",
|
||||
"etc/systemd/system/multi-user.target.wants/omarchy-tailscale-join.service",
|
||||
)
|
||||
|
||||
|
||||
def _scrub_factory_snapshot(factory: Path) -> None:
|
||||
for rel in FACTORY_SCRUB_PATHS:
|
||||
(factory / rel).unlink(missing_ok=True)
|
||||
|
||||
|
||||
def _findmnt_value(path: Path, column: str) -> str | None:
|
||||
res = subprocess.run(
|
||||
["findmnt", "-no", column, str(path)],
|
||||
|
||||
+50
-1
@@ -121,6 +121,22 @@ class ContextOemTest(unittest.TestCase):
|
||||
"rig-secret",
|
||||
)
|
||||
|
||||
def test_oem_discards_account_material_from_supplied_credentials(self):
|
||||
# A rig credentials file must not smuggle in users or a root password.
|
||||
self.write_config(self.base_config(oem=True))
|
||||
self.write_creds({
|
||||
"users": [{"username": "backdoor", "enc_password": "x", "sudo": True}],
|
||||
"root_enc_password": "x",
|
||||
"encryption_password": "rig-secret",
|
||||
})
|
||||
ctx = self.from_env()
|
||||
self.assertEqual(ctx.user_credentials["users"], [])
|
||||
self.assertNotIn("root_enc_password", ctx.user_credentials)
|
||||
self.assertEqual(ctx.user_credentials["encryption_password"], "rig-secret")
|
||||
synthesized = json.loads(ctx.creds_path.read_text())
|
||||
self.assertEqual(synthesized["users"], [])
|
||||
self.assertNotIn("root_enc_password", synthesized)
|
||||
|
||||
def test_oem_unencrypted_touches_nothing(self):
|
||||
self.write_config(self.base_config(oem=True))
|
||||
ctx = self.from_env()
|
||||
@@ -194,6 +210,14 @@ class StageOemStateTest(unittest.TestCase):
|
||||
with self.assertRaisesRegex(RuntimeError, "Node tarball"):
|
||||
phases_impl.stage_oem_state(ctx)
|
||||
|
||||
def test_missing_node_tarball_fails_normal_installs_too(self):
|
||||
# The stash is what makes a later factory reset work offline.
|
||||
for tarball in self.packages.glob("*.tar.gz"):
|
||||
tarball.unlink()
|
||||
ctx = make_ctx(self.target, oem=False)
|
||||
with self.assertRaisesRegex(RuntimeError, "Node tarball"):
|
||||
phases_impl.stage_oem_state(ctx)
|
||||
|
||||
def test_oem_arms_first_boot_setup(self):
|
||||
self.install_runtime_oem_support()
|
||||
ctx = make_ctx(self.target)
|
||||
@@ -357,11 +381,36 @@ class CreateFactorySnapshotTest(unittest.TestCase):
|
||||
top = str(self.state_dir / "factory-top")
|
||||
self.assertIn(["mount", "-o", "subvolid=5", "/dev/mapper/omarchy_root", top], self.calls)
|
||||
self.assertIn(
|
||||
["btrfs", "subvolume", "snapshot", "-r", f"{top}/@", f"{top}/@factory"],
|
||||
["btrfs", "subvolume", "snapshot", f"{top}/@", f"{top}/@factory"],
|
||||
self.calls,
|
||||
)
|
||||
self.assertIn(
|
||||
["btrfs", "property", "set", "-ts", f"{top}/@factory", "ro", "true"],
|
||||
self.calls,
|
||||
)
|
||||
self.assertIn(["umount", top], self.calls)
|
||||
|
||||
def test_factory_snapshot_scrubs_provisioning_credentials(self):
|
||||
# Simulate the snapshot carrying deployment credentials; the scrub
|
||||
# must drop them before the snapshot goes read-only.
|
||||
top = self.state_dir / "factory-top"
|
||||
factory = top / "@factory"
|
||||
secrets = [
|
||||
factory / "var/lib/omarchy/oem/authorized_keys",
|
||||
factory / "etc/tailscale/authkey",
|
||||
factory / "etc/omarchy/oem.key",
|
||||
]
|
||||
keep = factory / "var/lib/omarchy/oem/groups"
|
||||
for path in [*secrets, keep]:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text("secret")
|
||||
|
||||
phases_impl.create_factory_snapshot(self.ctx())
|
||||
|
||||
for path in secrets:
|
||||
self.assertFalse(path.exists(), path)
|
||||
self.assertTrue(keep.exists())
|
||||
|
||||
def test_non_btrfs_target_skips(self):
|
||||
self.findmnt["FSTYPE"] = "ext4"
|
||||
phases_impl.create_factory_snapshot(self.ctx())
|
||||
|
||||
Reference in new issue
Block a user