Skip to main content

max / alloy

State Alloy's Secure Boot posture, and read the firmware back Alloy signs nothing: an installed machine boots Fedora's signed shim, grub and kernel under stock firmware keys. What was missing was saying so, and noticing when the firmware is not enforcing, which matters because TPM2-LUKS seals against PCR 7 and PCR 7 is stable whether Secure Boot is on or off. With it off the disk opens for anything that boots the machine. system.rs gains secure_boot(), reading the SecureBoot EFI variable as three states rather than two: a machine that could not be asked has not answered no. A security.secure_boot row reports it, shown and never settable, since the setting belongs to the firmware's setup utility. The installer warns rather than refuses, correcting the ruling: the Alloy ISO is unsigned and needs Secure Boot off to boot, so a refusal at install time would refuse every install.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session
https://claude.ai/code/session_01KD2dJJETtYs7R5kku6YyLk
Author: Max Johnson <me@maxj.phd> · 2026-08-29 16:29 UTC
Signed with PGP, not checked
Commit: 0a6c57410262cde2a28c550d1150584ce9ed05e6
Parent: bf807c7
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), &note, 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