diff --git a/bin/omarchy-usb-authorization-boot b/bin/omarchy-usb-authorization-boot index e72139a3..6ffb010c 100755 --- a/bin/omarchy-usb-authorization-boot +++ b/bin/omarchy-usb-authorization-boot @@ -338,6 +338,75 @@ usb_authorization_rewrite_boot_cmdlines() { rm -f "$backup" } +usb_authorization_rewrite_snapshot_manifest() { + local expected="$1" manifest="$2" temporary + + [[ -e $manifest ]] || return 0 + temporary=$(mktemp "${manifest}.omarchy-usb.XXXXXXXXXX") + if ! cp --preserve=mode,ownership "$manifest" "$temporary"; then + rm -f "$temporary" + return 1 + fi + + # Limine uses the cached lines when present, and structured command lines + # otherwise. Update both, including nested kernels and restore entries. + if ! jq --arg expected "$expected" --arg parameter "$kernel_parameter" ' + def cmdline: + if type != "string" then error("Invalid snapshot command line") else + [splits("[[:space:]]+") | select(length > 0 and . != $parameter)] + | if $expected == "enabled" then . + [$parameter] else . end + | join(" ") + end; + def lines: + map(if test("^\\s*(cmdline|kernel_cmdline):"; "i") then + capture("^(?\\s*(?:cmdline|kernel_cmdline):\\s*)(?.*)$"; "i") + | .prefix + (.value | cmdline) + else . end); + def kernel: + .cmdlineDetails |= map(.cmdline |= cmdline | .snapshotCmdline |= cmdline) + | .allInConfig |= lines + | .allInSnapshotConfig |= lines + | .subKernels |= map(kernel); + if .jsonFormatVersion != "1.3.0" or (.snapshotEntries | type) != "array" then + error("Unsupported Limine snapshot manifest format") + else + .snapshotEntries |= map(.kernelEntries |= map(kernel)) + end + ' "$manifest" >"$temporary"; then + rm -f "$temporary" + echo "Cannot update stored USB boot policy in $manifest." >&2 + return 1 + fi + + mv -f "$temporary" "$manifest" +} + +usb_authorization_sync_and_verify() ( + local expected="$1" config="$2" esp="$3" + local machine_id="${4:-$(/run/lock/boot-partition.lock + flock --timeout 30 9 || return 1 + + # Recover/upgrade the manifest first, including backups if necessary. + limine-snapper-sync --no-mutex --no-hooks || return 1 + for manifest in \ + "$esp/$machine_id/limine_history/snapshots.json" \ + "$esp/$machine_id/limine_history/snapshots.json.old" \ + "/var/cache/boot/$machine_id/lss/snapshots.json"; do + usb_authorization_rewrite_snapshot_manifest "$expected" "$manifest" || return 1 + done + + usb_authorization_rewrite_boot_cmdlines "$expected" "$config" "$machine_id" || return 1 + # Verify the regenerated menu, so synchronization cannot mask stale stored + # command lines with a one-time edit to limine.conf. + limine-snapper-sync --no-mutex --no-hooks || return 1 + limine-enroll-config || return 1 + usb_authorization_verify_boot_entries "$expected" "$config" "$esp" "$machine_id" +) + main() { local had_drop_in=0 had_snapshot_setting=0 snapshot_status temporary @@ -377,8 +446,7 @@ main() { fi if ! limine-mkinitcpio || - ! usb_authorization_rewrite_boot_cmdlines enabled "$limine_conf" || - ! usb_authorization_verify_boot_entries enabled "$limine_conf" "$boot_path"; then + ! usb_authorization_sync_and_verify enabled "$limine_conf" "$boot_path"; then echo "Boot-time USB authorization remains configured in $drop_in; rerun this command after fixing the boot-image error." >&2 exit 1 fi @@ -401,14 +469,11 @@ main() { fi had_drop_in=1 rm -f "$drop_in" - elif (( ! had_snapshot_setting )) && usb_authorization_verify_boot_entries disabled "$limine_conf" "$boot_path"; then - exit 0 fi usb_authorization_disable_snapshot_setting "$limine_defaults" if ! limine-mkinitcpio || - ! usb_authorization_rewrite_boot_cmdlines disabled "$limine_conf" || - ! usb_authorization_verify_boot_entries disabled "$limine_conf" "$boot_path"; then + ! usb_authorization_sync_and_verify disabled "$limine_conf" "$boot_path"; then if (( had_drop_in )); then printf '%s\n' "$setting" >"$drop_in" chmod 644 "$drop_in" diff --git a/test/shell.d/usb-authorization-test.sh b/test/shell.d/usb-authorization-test.sh index 1cebe809..784906de 100755 --- a/test/shell.d/usb-authorization-test.sh +++ b/test/shell.d/usb-authorization-test.sh @@ -269,6 +269,50 @@ usb_authorization_disable_snapshot_setting "$snapshot_config" fail "removing boot authorization restores future snapshot defaults" pass "boot authorization verifies generated entries despite Limine false-success exits" +manifest="$scratch/snapshots.json" +jq -n --arg hash "$boot_hash" ' + def kernel: + {cmdlineDetails: [{limineKey: "CMDLINE", cmdline: "quiet rootflags=subvol=@", snapshotCmdline: "quiet rootflags=subvol=/snapshot"}], + allInConfig: [" cmdline: quiet rootflags=subvol=@"], + allInSnapshotConfig: ["comment: kernel-id=linux", "path: boot():/vmlinuz-linux#" + $hash, "cmdline: quiet rootflags=subvol=/snapshot"], + subKernels: [], properties: {untouched: "usbcore.authorized_default=0"}}; + {jsonFormatVersion: "1.3.0", snapshotEntries: [{kernelEntries: [kernel | .subKernels = [kernel | .allInSnapshotConfig = []]]}], uuid: "preserve-me"} +' >"$manifest" +cp "$manifest" "$scratch/manifest-original.json" +for expected in enabled disabled; do + usb_authorization_rewrite_snapshot_manifest "$expected" "$manifest" + cp "$manifest" "$scratch/manifest-once.json" + usb_authorization_rewrite_snapshot_manifest "$expected" "$manifest" + cmp -s "$manifest" "$scratch/manifest-once.json" || fail "manifest updates are idempotent" + # Model both Limine render paths: cached lines and structured details. + for representation in cached structured; do + { + printf '/Omarchy\ncomment: machine-id=%s\n //linux\n' "$machine_id" + if [[ $representation == "cached" ]]; then + jq -r '.snapshotEntries[0].kernelEntries[0].allInSnapshotConfig[]' "$manifest" + else + printf 'comment: kernel-id=linux\npath: boot():/vmlinuz-linux#%s\n' "$boot_hash" + jq -r '"cmdline: " + .snapshotEntries[0].kernelEntries[0].subKernels[0].cmdlineDetails[0].snapshotCmdline' "$manifest" + fi + } >"$scratch/regenerated.conf" + usb_authorization_verify_boot_entries "$expected" "$scratch/regenerated.conf" "$scratch" "$machine_id" disabled || + fail "snapshot synchronization preserves $expected policy from $representation entries" + done + jq -e '.snapshotEntries[0].kernelEntries[0].properties.untouched == "usbcore.authorized_default=0"' "$manifest" >/dev/null || + fail "manifest changes preserve unrelated metadata" +done +jq -S . "$manifest" >"$scratch/manifest-after.json" +jq -S . "$scratch/manifest-original.json" >"$scratch/manifest-before.json" +cmp -s "$scratch/manifest-before.json" "$scratch/manifest-after.json" || + fail "disable restores all stored command lines without changing other snapshot data" +printf '{bad json' >"$scratch/bad-manifest.json" +if usb_authorization_rewrite_snapshot_manifest enabled "$scratch/bad-manifest.json" 2>/dev/null; then + fail "invalid manifests must fail closed" +fi +[[ $(<"$scratch/bad-manifest.json") == '{bad json' ]] || fail "failed manifest updates preserve the original" +pass "USB boot policy survives snapshot regeneration from cached and structured manifests" + + : >"$calls" "$ROOT/bin/omarchy-remove-security-usb-authorization" --boot-only --yes >"$scratch/remove-boot-output" grep -Fqx 'sudo ' "$calls" ||