max / alloy
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
- Claude-Session
- https://claude.ai/code/session_01KD2dJJETtYs7R5kku6YyLk
4 files changed,
+400 insertions,
-8 deletions
| @@ -381,6 +381,22 @@ | |||
| 381 | 381 | Building with `TRIM=keep` keeps the documentation and the translations both. Measured | |
| 382 | 382 | and argued in [IMAGE.md](IMAGE.md#what-was-cut-2026-08-17). | |
| 383 | 383 | ||
| 384 | + | ## Secure Boot: **inherited from Fedora, stated, and read back**, ruled 2026-08-28 | |
| 385 | + | ||
| 386 | + | **Alloy signs nothing. An installed machine boots Microsoft-signed shim to Fedora-signed grub to a Fedora-signed kernel under the firmware's stock keys, and Alloy's contribution is saying so and noticing when the firmware is not enforcing.** The image carries `shim-x64`, `grub2-efi-x64` and the stock kernel, staged at `/usr/lib/bootupd/updates/EFI/fedora/`, which is where `bootc install` writes the ESP from. There is no Make Creative key, no MOK enrollment, and no per-machine firmware confirmation step, because nothing in the image needs a signature we would have to hold a key for. | |
| 387 | + | ||
| 388 | + | **The installer medium is the exception, and it is deliberate.** `build/make-iso.sh` builds its own GRUB as the removable-media binary with no shim in front of it. The signed shim and grub ship in RPMs whose files a bootc image strips out of `/boot`, so the bytes are not there to chain to, and a self-built GRUB is unsigned either way. The ISO therefore requires Secure Boot off to boot at all. Signing it is a distribution problem and distribution is not set up. | |
| 389 | + | ||
| 390 | + | **Why the state is worth reporting rather than assuming.** TPM2-LUKS seals the keyslot against PCR 7, and PCR 7 measures the Secure Boot state. That measurement is just as stable when Secure Boot is off, so on a machine with it off the disk auto-unlocks for anything that boots it, including a USB with `init=/bin/sh`. Encryption the user asked for is not the encryption they got, and nothing on the machine said so. | |
| 391 | + | ||
| 392 | + | So the console reads the firmware's own answer, from the EFI variable `SecureBoot` under `/sys/firmware/efi/efivars/`, and reports three states rather than two: enforcing, off, and unknown. Unknown covers a machine with no efivarfs, no such variable, or a legacy BIOS, and it is kept separate because reporting a firmware nobody could read as "off" invents a measurement. | |
| 393 | + | ||
| 394 | + | Three places carry it. `alloy install`'s encryption pane says what the TPM binding is worth while the chain is unverified, at the point the checkbox is ticked. The summary repeats it beside the encryption answer, worded as a defect on the server profile and a warning on the client, since a headless box has nobody standing at the firmware screen. `alloy settings` carries a `security.secure_boot` row, shown and never settable: the setting belongs to the firmware's setup utility, and no privilege this console could ask for would reach it. | |
| 395 | + | ||
| 396 | + | **None of the three refuses.** The ruling that produced this asked for a refusal at install time on the server profile, and it cannot ship: the installer medium requires Secure Boot off, so the machine running the wizard has it off in every case where it was ever on, and a refusal would refuse every install. The first moment the state is the user's to change is after the first reboot, on the signed chain, which is why the settings row is the one that can actually be acted on. | |
| 397 | + | ||
| 398 | + | Argued in wiki `alloy-hardening-posture`, "Secure Boot". | |
| 399 | + | ||
| 384 | 400 | ## Firewall: **firewalld**, ruled 2026-08-22 | |
| 385 | 401 | ||
| 386 | 402 | **firewalld, on both profiles, with the stock `public` zone on the client and an Alloy-owned zone on the server.** Until this ruling the image had no host firewall at all: `firewalld` was not installed and no nftables ruleset shipped, so every listener the preset enables was reachable from whatever network the machine was on. On a laptop behind NAT that is survivable. On the server profile it is not, and converting the Hetzner boxes to Alloy is a live plan (wiki `host-base-images`). |
| @@ -75,9 +75,11 @@ | |||
| 75 | 75 | use alloy_tui::{Cursor, FocusRing}; | |
| 76 | 76 | ||
| 77 | 77 | use crate::cli::{CommandLog, Invocation, Secret}; | |
| 78 | + | use crate::profile::Profile; | |
| 78 | 79 | use crate::recovery; | |
| 79 | 80 | use crate::run::{Sequence, Stage}; | |
| 80 | 81 | use crate::shell::{Confirm, Flow, TICK, View, block_title, truncate}; | |
| 82 | + | use crate::system::{SecureBoot, secure_boot}; | |
| 81 | 83 | use crate::wizard::Steps; | |
| 82 | 84 | use alloy_tui::TextField; | |
| 83 | 85 | ||
| @@ -3279,6 +3281,17 @@ | |||
| 3279 | 3281 | /// The encryption checkbox, until the step is confirmed and it becomes an | |
| 3280 | 3282 | /// answer. Starts ticked: Alloy encrypts unless told not to. | |
| 3281 | 3283 | encrypt: bool, | |
| 3284 | + | /// What the firmware said about Secure Boot when the wizard opened. | |
| 3285 | + | /// | |
| 3286 | + | /// Read once. The setting is the firmware's and changing it takes a reboot, | |
| 3287 | + | /// so it cannot move under a running installer. | |
| 3288 | + | secure_boot: SecureBoot, | |
| 3289 | + | /// Which image this is, for the one line whose wording turns on it. | |
| 3290 | + | /// | |
| 3291 | + | /// Read once for the same reason [`Profile::current`] is cheap to call and | |
| 3292 | + | /// still worth holding: the marker is a file the build wrote and nothing | |
| 3293 | + | /// edits it afterwards. | |
| 3294 | + | profile: Profile, | |
| 3282 | 3295 | /// How far the credits page is scrolled, in lines. | |
| 3283 | 3296 | credits_scroll: usize, | |
| 3284 | 3297 | /// How many lines of the credits page last fitted on screen. | |
| @@ -3367,6 +3380,8 @@ | |||
| 3367 | 3380 | reboot_pending: false, | |
| 3368 | 3381 | disk_intact: Arc::new(AtomicBool::new(false)), | |
| 3369 | 3382 | encrypt: true, | |
| 3383 | + | secure_boot: secure_boot(), | |
| 3384 | + | profile: Profile::current(), | |
| 3370 | 3385 | credits_scroll: 0, | |
| 3371 | 3386 | run_scroll: 0, | |
| 3372 | 3387 | run_viewport: std::cell::Cell::new(0), | |
| @@ -3809,6 +3824,82 @@ | |||
| 3809 | 3824 | frame.render_widget(Paragraph::new(lines), area); | |
| 3810 | 3825 | } | |
| 3811 | 3826 | ||
| 3827 | + | /// What the encryption pane says about Secure Boot, or nothing. | |
| 3828 | + | /// | |
| 3829 | + | /// Empty in the two states with nothing to add. With encryption unticked | |
| 3830 | + | /// there is no TPM binding to qualify, and the pane is already saying the | |
| 3831 | + | /// stronger thing about an unencrypted disk. With Secure Boot enforcing the | |
| 3832 | + | /// binding means what it claims. | |
| 3833 | + | /// | |
| 3834 | + | /// `Off` and `Unknown` are worded apart on purpose. A machine that could | |
| 3835 | + | /// not be asked has not answered no, and telling someone their firmware is | |
| 3836 | + | /// off when nothing read it would be a measurement nobody took. | |
| 3837 | + | /// | |
| 3838 | + | /// # Why this is a warning and not a refusal | |
| 3839 | + | /// | |
| 3840 | + | /// The brief that ruled this asked for a refusal on the server profile. It | |
| 3841 | + | /// cannot ship. The Alloy ISO carries a self-built, unsigned GRUB | |
| 3842 | + | /// (`build/make-iso.sh`), so the machine running this wizard has Secure | |
| 3843 | + | /// Boot off in every case where it was ever on, and a refusal here would | |
| 3844 | + | /// refuse every install. The state that can be acted on is the one after | |
| 3845 | + | /// the first reboot, once the machine is on the signed chain and the | |
| 3846 | + | /// firmware setting is the user's to change; `alloy settings` carries the | |
| 3847 | + | /// row that reports it. | |
| 3848 | + | fn secure_boot_lines(state: SecureBoot, encrypt: bool) -> &'static [&'static str] { | |
| 3849 | + | if !encrypt { | |
| 3850 | + | return &[]; | |
| 3851 | + | } | |
| 3852 | + | match state { | |
| 3853 | + | SecureBoot::Enforcing => &[], | |
| 3854 | + | SecureBoot::Off => &[ | |
| 3855 | + | "Secure Boot is off on this machine, and the TPM key is bound", | |
| 3856 | + | "to that state, so the disk will unlock for anything that boots", | |
| 3857 | + | "it until Secure Boot is enforcing.", | |
| 3858 | + | ], | |
| 3859 | + | SecureBoot::Unknown => &[ | |
| 3860 | + | "This machine does not report a Secure Boot state. The TPM key", | |
| 3861 | + | "is bound to it, so until it reads as enforcing the disk may", | |
| 3862 | + | "unlock for anything that boots it.", | |
| 3863 | + | ], | |
| 3864 | + | } | |
| 3865 | + | } | |
| 3866 | + | ||
| 3867 | + | /// The same statement as one summary row, and how loudly to say it. | |
| 3868 | + | /// | |
| 3869 | + | /// [`Severity::Warn`] on a client and [`Severity::Error`] on a server, | |
| 3870 | + | /// which is the whole of the profile's effect here. A laptop with Secure | |
| 3871 | + | /// Boot off is a machine whose owner can walk to the firmware screen; a | |
| 3872 | + | /// headless box is a defect somebody has to travel to fix, and the summary | |
| 3873 | + | /// is the last place anyone sees it before the disk is written. | |
| 3874 | + | fn secure_boot_summary( | |
| 3875 | + | state: SecureBoot, | |
| 3876 | + | encrypt: bool, | |
| 3877 | + | profile: Profile, | |
| 3878 | + | ) -> Option<(&'static str, Severity)> { | |
| 3879 | + | if Self::secure_boot_lines(state, encrypt).is_empty() { | |
| 3880 | + | return None; | |
| 3881 | + | } | |
| 3882 | + | let severity = match profile { | |
| 3883 | + | Profile::Client => Severity::Warn, | |
| 3884 | + | Profile::Server => Severity::Error, | |
| 3885 | + | }; | |
| 3886 | + | let text = match (state, profile) { | |
| 3887 | + | (SecureBoot::Off, Profile::Client) => { | |
| 3888 | + | " Secure Boot is off, so the TPM opens this disk for anything that boots it" | |
| 3889 | + | } | |
| 3890 | + | (SecureBoot::Off, Profile::Server) => { | |
| 3891 | + | " Secure Boot is off: TPM unlock here is not encryption against anyone holding the machine" | |
| 3892 | + | } | |
| 3893 | + | (_, Profile::Client) => { | |
| 3894 | + | " Secure Boot state unknown, so TPM unlock cannot be relied on yet" | |
| 3895 | + | } | |
| 3896 | + | (_, Profile::Server) => { | |
| 3897 | + | " Secure Boot state unknown: TPM unlock on this machine cannot be relied on" | |
| 3898 | + | } | |
| 3899 | + | }; | |
| 3900 | + | Some((text, severity)) | |
| 3901 | + | } | |
| 3902 | + | ||
| 3812 | 3903 | /// The encryption pane: the checkbox, then the passphrase pair it governs. | |
| 3813 | 3904 | fn render_encryption(&self, frame: &mut Frame, area: Rect, theme: &Theme) { | |
| 3814 | 3905 | let w = area.width; | |
| @@ -3875,6 +3966,24 @@ | |||
| 3875 | 3966 | format!("{:LABEL_WIDTH$} {line}", ""), | |
| 3876 | 3967 | ))); | |
| 3877 | 3968 | } | |
| 3969 | + | ||
| 3970 | + | // What the TPM half is actually worth on this machine, said where | |
| 3971 | + | // the choice is made. The key is sealed against PCR 7, which | |
| 3972 | + | // measures the Secure Boot state and is just as stable when that | |
| 3973 | + | // state is off, so an unverified boot chain turns "the TPM opens | |
| 3974 | + | // it" into "anything opens it". Not a blocker and not an extra | |
| 3975 | + | // keypress: the installer medium requires Secure Boot off to boot | |
| 3976 | + | // at all, so this is never actionable here. | |
| 3977 | + | let warn = Self::secure_boot_lines(self.secure_boot, self.encrypt); | |
| 3978 | + | if !warn.is_empty() { | |
| 3979 | + | lines.push(Line::default()); | |
| 3980 | + | for line in warn { | |
| 3981 | + | lines.push(Line::from(Span::styled( | |
| 3982 | + | format!(" {line}"), | |
| 3983 | + | Severity::Warn.style(theme), | |
| 3984 | + | ))); | |
| 3985 | + | } | |
| 3986 | + | } | |
| 3878 | 3987 | } else { | |
| 3879 | 3988 | // The consequence, stated where it is chosen and not only on the | |
| 3880 | 3989 | // summary. This is the one answer on any pane that cannot be | |
| @@ -3981,15 +4090,29 @@ | |||
| 3981 | 4090 | " change it with `alloy settings`".into(), | |
| 3982 | 4091 | ) | |
| 3983 | 4092 | }, | |
| 3984 | - | Line::default(), | |
| 3985 | - | Line::from(Span::styled( | |
| 3986 | - | format!("Everything on {disk} will be erased."), | |
| 3987 | - | Severity::Error.style(theme), | |
| 3988 | - | )), | |
| 3989 | - | Line::default(), | |
| 3990 | - | Line::from(text::muted(theme, "This runs:")), | |
| 3991 | 4093 | ]; | |
| 3992 | 4094 | ||
| 4095 | + | // Under the encryption row, because it qualifies that row's answer and | |
| 4096 | + | // nothing else on the screen. The row above says encryption is on; this | |
| 4097 | + | // says what "on" is worth while the firmware is not verifying the boot | |
| 4098 | + | // chain. | |
| 4099 | + | if let Some((note, severity)) = | |
| 4100 | + | Self::secure_boot_summary(self.secure_boot, self.answers.encrypt, self.profile) | |
| 4101 | + | { | |
| 4102 | + | lines.push(Line::from(Span::styled( | |
| 4103 | + | format!("{:>10} {note}", ""), | |
| 4104 | + | severity.style(theme), | |
| 4105 | + | ))); | |
| 4106 | + | } | |
| 4107 | + | ||
| 4108 | + | lines.push(Line::default()); | |
| 4109 | + | lines.push(Line::from(Span::styled( | |
| 4110 | + | format!("Everything on {disk} will be erased."), | |
| 4111 | + | Severity::Error.style(theme), | |
| 4112 | + | ))); | |
| 4113 | + | lines.push(Line::default()); | |
| 4114 | + | lines.push(Line::from(text::muted(theme, "This runs:"))); | |
| 4115 | + | ||
| 3993 | 4116 | for shown in self.plan_display() { | |
| 3994 | 4117 | lines.push(Line::from(text::secondary(theme, format!(" {shown}")))); | |
| 3995 | 4118 | } | |
| @@ -5133,6 +5256,75 @@ | |||
| 5133 | 5256 | } | |
| 5134 | 5257 | ||
| 5135 | 5258 | // ---- the step ---- | |
| 5259 | + | // ---- Secure Boot ---- | |
| 5260 | + | ||
| 5261 | + | // The pane speaks exactly when the TPM binding exists and the firmware is | |
| 5262 | + | // not verifying the chain. Encryption off means there is no binding to | |
| 5263 | + | // qualify, and enforcing means the binding means what it says. | |
| 5264 | + | #[test] | |
| 5265 | + | fn the_secure_boot_line_appears_only_where_it_is_true() { | |
| 5266 | + | let lines = |state, encrypt| InstallView::secure_boot_lines(state, encrypt); | |
| 5267 | + | ||
| 5268 | + | assert!(lines(SecureBoot::Enforcing, true).is_empty()); | |
| 5269 | + | assert!( | |
| 5270 | + | lines(SecureBoot::Off, false).is_empty(), | |
| 5271 | + | "no TPM key at all" | |
| 5272 | + | ); | |
| 5273 | + | assert!(lines(SecureBoot::Unknown, false).is_empty()); | |
| 5274 | + | ||
| 5275 | + | assert!(!lines(SecureBoot::Off, true).is_empty()); | |
| 5276 | + | assert!(!lines(SecureBoot::Unknown, true).is_empty()); | |
| 5277 | + | } | |
| 5278 | + | ||
| 5279 | + | // Off and unknown are different sentences. A firmware that was never read | |
| 5280 | + | // has not said no, and the wording is the only thing carrying that. | |
| 5281 | + | #[test] | |
| 5282 | + | fn an_unread_firmware_does_not_read_as_a_no() { | |
| 5283 | + | let joined = |state| InstallView::secure_boot_lines(state, true).join(" "); | |
| 5284 | + | ||
| 5285 | + | assert!(joined(SecureBoot::Off).contains("Secure Boot is off")); | |
| 5286 | + | let unknown = joined(SecureBoot::Unknown); | |
| 5287 | + | assert!(unknown.contains("does not report"), "{unknown}"); | |
| 5288 | + | assert!(!unknown.contains("is off"), "{unknown}"); | |
| 5289 | + | } | |
| 5290 | + | ||
| 5291 | + | // The profile moves how loudly the summary says it and nothing else: a | |
| 5292 | + | // headless machine has nobody at the firmware screen, so the same fact is | |
| 5293 | + | // a defect there and a note on a laptop. Neither refuses. | |
| 5294 | + | #[test] | |
| 5295 | + | fn the_summary_shouts_on_a_server_and_never_refuses() { | |
| 5296 | + | let summary = |state, profile| InstallView::secure_boot_summary(state, true, profile); | |
| 5297 | + | ||
| 5298 | + | assert_eq!(summary(SecureBoot::Enforcing, Profile::Server), None); | |
| 5299 | + | ||
| 5300 | + | let (_, severity) = summary(SecureBoot::Off, Profile::Client).unwrap(); | |
| 5301 | + | assert_eq!(severity, Severity::Warn); | |
| 5302 | + | let (text, severity) = summary(SecureBoot::Off, Profile::Server).unwrap(); | |
| 5303 | + | assert_eq!(severity, Severity::Error); | |
| 5304 | + | assert!(text.contains("Secure Boot is off"), "{text}"); | |
| 5305 | + | } | |
| 5306 | + | ||
| 5307 | + | // The wizard has to reach the end with Secure Boot off, because the ISO | |
| 5308 | + | // that carries it needs Secure Boot off to boot at all. A refusal here | |
| 5309 | + | // would refuse every install, which is why the ruling's "refuse on the | |
| 5310 | + | // server profile" could not be built as written. | |
| 5311 | + | #[test] | |
| 5312 | + | fn secure_boot_off_does_not_block_the_wizard() { | |
| 5313 | + | let (mut view, mut log) = at_encryption(); | |
| 5314 | + | view.secure_boot = SecureBoot::Off; | |
| 5315 | + | view.profile = Profile::Server; | |
| 5316 | + | fill_passphrase(&mut view, "opensesame", "opensesame"); | |
| 5317 | + | ||
| 5318 | + | view.handle(KeyEvent::from(KeyCode::Enter), &mut log); | |
| 5319 | + | ||
| 5320 | + | assert_eq!(view.step(), Step::Summary); | |
| 5321 | + | assert!(view.error.is_none(), "{:?}", view.error); | |
| 5322 | + | assert!( | |
| 5323 | + | InstallView::secure_boot_summary(view.secure_boot, view.answers.encrypt, view.profile) | |
| 5324 | + | .is_some(), | |
| 5325 | + | "the summary still carries the warning it did not refuse on", | |
| 5326 | + | ); | |
| 5327 | + | } | |
| 5136 | 5328 | ||
| 5137 | 5329 | /// A view over the real fixture's disks, built directly rather than | |
| 5138 | 5330 | /// through [`InstallView::new`] so the tests do not depend on whatever | |
| @@ -5163,6 +5355,10 @@ | |||
| 5163 | 5355 | recovery_ack: false, | |
| 5164 | 5356 | reboot_pending: false, | |
| 5165 | 5357 | encrypt: true, | |
| 5358 | + | // Fixed rather than probed, so the panes render the same on a | |
| 5359 | + | // machine with Secure Boot on as on one without. | |
| 5360 | + | secure_boot: SecureBoot::Enforcing, | |
| 5361 | + | profile: Profile::Client, | |
| 5166 | 5362 | credits_scroll: 0, | |
| 5167 | 5363 | run_scroll: 0, | |
| 5168 | 5364 | run_viewport: std::cell::Cell::new(0), |
| @@ -2835,8 +2835,10 @@ | |||
| 2835 | 2835 | "appearance.theme", | |
| 2836 | 2836 | "[secrets]", | |
| 2837 | 2837 | "secrets.age", | |
| 2838 | + | "[security]", | |
| 2839 | + | "security.secure_boot", | |
| 2838 | 2840 | ], | |
| 2839 | - | "three fronts, the console's own preference, and one file check, one form", | |
| 2841 | + | "three fronts, the console's own preference, a file check and a firmware read, one form", | |
| 2840 | 2842 | ); | |
| 2841 | 2843 | assert_eq!( | |
| 2842 | 2844 | cell(form.bind.as_ref(), form.bind.field("time.zone").unwrap()).text, |
| @@ -108,6 +108,15 @@ | |||
| 108 | 108 | pub(crate) const THEME: &str = "appearance.theme"; | |
| 109 | 109 | /// Shown, never set. See [`Age`]. | |
| 110 | 110 | const AGE: &str = "secrets.age"; | |
| 111 | + | /// Shown, never set. See [`SecureBoot`]. | |
| 112 | + | const SECURE_BOOT: &str = "security.secure_boot"; | |
| 113 | + | ||
| 114 | + | /// Where the firmware publishes whether it is verifying the boot chain. | |
| 115 | + | /// | |
| 116 | + | /// The GUID is EFI's global variable namespace and is the same on every | |
| 117 | + | /// machine; the variable is one byte of data behind four bytes of attributes. | |
| 118 | + | const SECURE_BOOT_VAR: &str = | |
| 119 | + | "/sys/firmware/efi/efivars/SecureBoot-8be4df61-93ca-11d2-aa0d-00e098032b8c"; | |
| 111 | 120 | ||
| 112 | 121 | /// Live system settings, fronted by `timedatectl`, `hostnamectl`, `localectl`. | |
| 113 | 122 | pub(crate) struct SystemBind { | |
| @@ -117,6 +126,7 @@ | |||
| 117 | 126 | host: Host, | |
| 118 | 127 | locale: Locale, | |
| 119 | 128 | age: Age, | |
| 129 | + | secure_boot: SecureBoot, | |
| 120 | 130 | } | |
| 121 | 131 | ||
| 122 | 132 | /// What `hostnamectl` reported. | |
| @@ -160,6 +170,65 @@ | |||
| 160 | 170 | present: bool, | |
| 161 | 171 | } | |
| 162 | 172 | ||
| 173 | + | /// Whether the firmware is verifying the boot chain. | |
| 174 | + | /// | |
| 175 | + | /// Three answers, not two, and the third is the one that matters. A machine | |
| 176 | + | /// with no efivarfs, no such variable, or a legacy BIOS cannot say, and | |
| 177 | + | /// reporting that as `Off` would invent a measurement nobody took. | |
| 178 | + | /// | |
| 179 | + | /// Alloy signs nothing itself. What an installed machine boots is Fedora's | |
| 180 | + | /// shim, grub and kernel under the firmware's stock keys, so this row reports | |
| 181 | + | /// an inherited chain rather than one Alloy owns. See wiki | |
| 182 | + | /// `alloy-hardening-posture`, "Secure Boot". | |
| 183 | + | /// | |
| 184 | + | /// Why it is worth a row at all: `crates/alloy/src/install.rs` binds the LUKS | |
| 185 | + | /// keyslot to PCR 7, which measures the Secure Boot state. That PCR is stable | |
| 186 | + | /// whether Secure Boot is on or off, so with it off the disk auto-unlocks for | |
| 187 | + | /// anything that boots the machine, and the encryption a user asked for is not | |
| 188 | + | /// the encryption they got. | |
| 189 | + | #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] | |
| 190 | + | pub(crate) enum SecureBoot { | |
| 191 | + | /// The firmware is verifying signatures. | |
| 192 | + | Enforcing, | |
| 193 | + | /// The firmware answered, and it is not. | |
| 194 | + | Off, | |
| 195 | + | /// Nothing answered. Not the same as off. | |
| 196 | + | #[default] | |
| 197 | + | Unknown, | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | impl SecureBoot { | |
| 201 | + | /// What the row shows. | |
| 202 | + | pub(crate) const fn label(self) -> &'static str { | |
| 203 | + | match self { | |
| 204 | + | SecureBoot::Enforcing => "enforcing", | |
| 205 | + | SecureBoot::Off => "off", | |
| 206 | + | SecureBoot::Unknown => "unknown", | |
| 207 | + | } | |
| 208 | + | } | |
| 209 | + | } | |
| 210 | + | ||
| 211 | + | /// Read the firmware's answer. | |
| 212 | + | pub(crate) fn secure_boot() -> SecureBoot { | |
| 213 | + | parse_secure_boot(std::fs::read(SECURE_BOOT_VAR).ok().as_deref()) | |
| 214 | + | } | |
| 215 | + | ||
| 216 | + | /// The variable's bytes, as a state. | |
| 217 | + | /// | |
| 218 | + | /// efivarfs prefixes every variable with four bytes of EFI attributes, so the | |
| 219 | + | /// data starts at index 4 and `SecureBoot` carries one byte of it. A file | |
| 220 | + | /// shorter than that is not a value, it is a read that went wrong, and it takes | |
| 221 | + | /// the same answer as no file: [`SecureBoot::Unknown`]. | |
| 222 | + | /// | |
| 223 | + | /// Split from the read so both shapes are testable without a firmware. | |
| 224 | + | fn parse_secure_boot(raw: Option<&[u8]>) -> SecureBoot { | |
| 225 | + | match raw { | |
| 226 | + | Some([_, _, _, _, data, ..]) if *data == 1 => SecureBoot::Enforcing, | |
| 227 | + | Some([_, _, _, _, _, ..]) => SecureBoot::Off, | |
| 228 | + | _ => SecureBoot::Unknown, | |
| 229 | + | } | |
| 230 | + | } | |
| 231 | + | ||
| 163 | 232 | /// Where gopass keeps its age identity, following gopass's own precedence. | |
| 164 | 233 | /// | |
| 165 | 234 | /// `GOPASS_HOMEDIR` wins, then `XDG_CONFIG_HOME`, then the home directory, | |
| @@ -234,6 +303,7 @@ | |||
| 234 | 303 | host: read_host(log), | |
| 235 | 304 | locale: read_locale(log), | |
| 236 | 305 | age: read_age(), | |
| 306 | + | secure_boot: secure_boot(), | |
| 237 | 307 | }; | |
| 238 | 308 | bind.annotate(); | |
| 239 | 309 | Some(bind) | |
| @@ -382,6 +452,26 @@ | |||
| 382 | 452 | // thing the provisioning decision ruled out, so this row has no setter | |
| 383 | 453 | // to open even when everything answered. | |
| 384 | 454 | set(self.field_mut(AGE), ¬e, true); | |
| 455 | + | ||
| 456 | + | // The consequence rather than the fact, in the state where there is | |
| 457 | + | // one. "Secure Boot: off" is a true sentence that tells a reader | |
| 458 | + | // nothing about what it costs them, and what it costs them is that the | |
| 459 | + | // TPM opens the disk for whoever is holding the machine. | |
| 460 | + | let note = match self.secure_boot { | |
| 461 | + | SecureBoot::Enforcing => { | |
| 462 | + | "The firmware is verifying the boot chain. Alloy inherits Fedora's signatures and signs nothing itself." | |
| 463 | + | } | |
| 464 | + | SecureBoot::Off => { | |
| 465 | + | "The firmware is not verifying the boot chain. A TPM-unlocked disk on this machine opens for anything that boots it." | |
| 466 | + | } | |
| 467 | + | SecureBoot::Unknown => { | |
| 468 | + | "This machine does not report a Secure Boot state, so a TPM-unlocked disk cannot be relied on. Not the same as off." | |
| 469 | + | } | |
| 470 | + | }; | |
| 471 | + | // Always closed, and unlike every other row on the screen no privilege | |
| 472 | + | // would open it: the setting is the firmware's, reached from its setup | |
| 473 | + | // utility before anything here is running. | |
| 474 | + | set(self.field_mut(SECURE_BOOT), note, true); | |
| 385 | 475 | } | |
| 386 | 476 | } | |
| 387 | 477 | ||
| @@ -427,6 +517,10 @@ | |||
| 427 | 517 | path: "secrets".to_string(), | |
| 428 | 518 | description: Some("Where gopass looks for its key.".to_string()), | |
| 429 | 519 | }, | |
| 520 | + | Section { | |
| 521 | + | path: "security".to_string(), | |
| 522 | + | description: Some("What the firmware is enforcing under all of this.".to_string()), | |
| 523 | + | }, | |
| 430 | 524 | ] | |
| 431 | 525 | } | |
| 432 | 526 | ||
| @@ -476,6 +570,10 @@ | |||
| 476 | 570 | // Not a setting either, and for a stronger reason than the clock: there | |
| 477 | 571 | // is no command behind it that a wider grant could reach. | |
| 478 | 572 | text(AGE, true), | |
| 573 | + | // Not a setting for the strongest reason on the screen: the answer | |
| 574 | + | // lives in the firmware, and the only way to change it is the setup | |
| 575 | + | // utility before this operating system starts. | |
| 576 | + | text(SECURE_BOOT, true), | |
| 479 | 577 | ] | |
| 480 | 578 | } | |
| 481 | 579 | ||
| @@ -659,6 +757,7 @@ | |||
| 659 | 757 | .dir | |
| 660 | 758 | .as_ref() | |
| 661 | 759 | .map(|dir| Value::String(dir.display().to_string())), | |
| 760 | + | SECURE_BOOT => Some(Value::String(self.secure_boot.label().to_string())), | |
| 662 | 761 | _ => None, | |
| 663 | 762 | } | |
| 664 | 763 | } | |
| @@ -754,6 +853,10 @@ | |||
| 754 | 853 | // Cheap, and the case worth catching: the user reads the row, places | |
| 755 | 854 | // the identity in another window, and refreshes. | |
| 756 | 855 | self.age = read_age(); | |
| 856 | + | // Cannot move while this process is running, since the firmware | |
| 857 | + | // setting takes a reboot. Re-read anyway: a row that is right only on | |
| 858 | + | // the first draw is a row nobody can trust the second time. | |
| 859 | + | self.secure_boot = secure_boot(); | |
| 757 | 860 | self.annotate(); | |
| 758 | 861 | } | |
| 759 | 862 | } | |
| @@ -791,6 +894,7 @@ | |||
| 791 | 894 | host: parse_host(tests::HOST), | |
| 792 | 895 | locale: parse_locale(tests::LOCALE_STATUS), | |
| 793 | 896 | age, | |
| 897 | + | secure_boot: SecureBoot::Enforcing, | |
| 794 | 898 | }; | |
| 795 | 899 | bind.annotate(); | |
| 796 | 900 | bind | |
| @@ -912,6 +1016,78 @@ | |||
| 912 | 1016 | // gopass's own precedence, which is what makes the row right on a machine | |
| 913 | 1017 | // that has moved its store rather than confidently naming a path nothing | |
| 914 | 1018 | // reads. | |
| 1019 | + | // Four bytes of EFI attributes, then the one byte that is the answer. The | |
| 1020 | + | // short and missing files are the cases that must not read as "off": a | |
| 1021 | + | // machine that could not be asked has not said no. | |
| 1022 | + | #[test] | |
| 1023 | + | fn the_secure_boot_variable_parses_three_ways() { | |
| 1024 | + | assert_eq!( | |
| 1025 | + | parse_secure_boot(Some(&[0x06, 0x00, 0x00, 0x00, 0x01])), | |
| 1026 | + | SecureBoot::Enforcing, | |
| 1027 | + | ); | |
| 1028 | + | assert_eq!( | |
| 1029 | + | parse_secure_boot(Some(&[0x06, 0x00, 0x00, 0x00, 0x00])), | |
| 1030 | + | SecureBoot::Off, | |
| 1031 | + | ); | |
| 1032 | + | assert_eq!( | |
| 1033 | + | parse_secure_boot(Some(&[0x06, 0x00, 0x00, 0x00])), | |
| 1034 | + | SecureBoot::Unknown, | |
| 1035 | + | "attributes with no data is a read that went wrong, not a no", | |
| 1036 | + | ); | |
| 1037 | + | assert_eq!(parse_secure_boot(Some(&[])), SecureBoot::Unknown); | |
| 1038 | + | assert_eq!( | |
| 1039 | + | parse_secure_boot(None), | |
| 1040 | + | SecureBoot::Unknown, | |
| 1041 | + | "no efivarfs, no legacy-BIOS machine reporting itself as off", | |
| 1042 | + | ); | |
| 1043 | + | } | |
| 1044 | + | ||
| 1045 | + | /// The fixture with the firmware's answer chosen, for the same reason the | |
| 1046 | + | /// age state is passed in: a test whose result depends on whether the | |
| 1047 | + | /// machine running it happens to have Secure Boot on is not a test. | |
| 1048 | + | fn with_secure_boot(state: SecureBoot) -> SystemBind { | |
| 1049 | + | let mut bind = SystemBind::fixture(); | |
| 1050 | + | bind.secure_boot = state; | |
| 1051 | + | bind.annotate(); | |
| 1052 | + | bind | |
| 1053 | + | } | |
| 1054 | + | ||
| 1055 | + | #[test] | |
| 1056 | + | fn the_secure_boot_row_reports_the_firmware_and_never_offers_a_setter() { | |
| 1057 | + | for state in [SecureBoot::Enforcing, SecureBoot::Off, SecureBoot::Unknown] { | |
| 1058 | + | let mut bind = with_secure_boot(state); | |
| 1059 | + | assert_eq!( | |
| 1060 | + | bind.read(SECURE_BOOT), | |
| 1061 | + | Some(Value::String(state.label().into())), | |
| 1062 | + | ); | |
| 1063 | + | assert!( | |
| 1064 | + | bind.field(SECURE_BOOT).unwrap().readonly, | |
| 1065 | + | "{state:?} is the firmware's, not this screen's", | |
| 1066 | + | ); | |
| 1067 | + | assert_eq!( | |
| 1068 | + | message(bind.commit(SECURE_BOOT, Value::String("enforcing".into()))), | |
| 1069 | + | "`security.secure_boot` is shown, not set", | |
| 1070 | + | ); | |
| 1071 | + | } | |
| 1072 | + | } | |
| 1073 | + | ||
| 1074 | + | // The row says what being off costs, not that it is off, and "unknown" says | |
| 1075 | + | // so in its own words rather than borrowing the ones for "off". | |
| 1076 | + | #[test] | |
| 1077 | + | fn the_secure_boot_row_names_the_consequence() { | |
| 1078 | + | assert!( | |
| 1079 | + | help(&with_secure_boot(SecureBoot::Off), SECURE_BOOT) | |
| 1080 | + | .contains("opens for anything that boots it"), | |
| 1081 | + | ); | |
| 1082 | + | let unknown = help(&with_secure_boot(SecureBoot::Unknown), SECURE_BOOT); | |
| 1083 | + | assert!(unknown.contains("does not report"), "{unknown}"); | |
| 1084 | + | assert!(unknown.contains("Not the same as off"), "{unknown}"); | |
| 1085 | + | assert!( | |
| 1086 | + | help(&with_secure_boot(SecureBoot::Enforcing), SECURE_BOOT) | |
| 1087 | + | .contains("signs nothing itself"), | |
| 1088 | + | ); | |
| 1089 | + | } | |
| 1090 | + | ||
| 915 | 1091 | #[test] | |
| 916 | 1092 | fn the_age_path_follows_gopass() { | |
| 917 | 1093 | let dir = |homedir, xdg, home| age_dir_from(homedir, xdg, home).unwrap(); | |
| @@ -1092,6 +1268,7 @@ | |||
| 1092 | 1268 | host: parse_host(HOST), | |
| 1093 | 1269 | locale: parse_locale(LOCALE_STATUS), | |
| 1094 | 1270 | age: Age::default(), | |
| 1271 | + | secure_boot: SecureBoot::Enforcing, | |
| 1095 | 1272 | }; | |
| 1096 | 1273 | bind.annotate(); | |
| 1097 | 1274 | ||
| @@ -1117,6 +1294,7 @@ | |||
| 1117 | 1294 | host: parse_host(HOST), | |
| 1118 | 1295 | locale: parse_locale(LOCALE_STATUS), | |
| 1119 | 1296 | age: Age::default(), | |
| 1297 | + | secure_boot: SecureBoot::Enforcing, | |
| 1120 | 1298 | }; | |
| 1121 | 1299 | bind.annotate(); | |
| 1122 | 1300 | bind |