Skip to main content

max / alloy

ssh: seed the installer's key from the medium it was minted with The password-auth half of alloy f536e26e was already in the tree (etc/ssh/sshd_config.d/10-alloy.conf). The install half was half there: the wizard collects a key and plants it, but had no way to know about the key the medium already carries, so the headless operator was asked to paste back over ssh the credential that ssh session was authenticated with. alloy install now seeds its key field from /usr/lib/alloy/authorized_keys, which is where the Containerfile's identity step writes ALLOY_SSH_KEY and where the installer's own sshd drop-in reads it. One key store, not two. Anything that does not validate is dropped rather than seeded: a field holding a line sshd will ignore would show a key on the summary, plant it, and still refuse the login. The no-key install stays possible, because a desktop with a person at the console is a legitimate one, but it is stated twice rather than once. The summary already named it; the gate before the disk is written now names it too, since that is the screen nobody gets past without reading and the condition is not recoverable from the far side of a reboot. tests/ssh_policy.rs pins the shipped policy: both password doors shut, root key-only, no Match scoping it, and a number that still sorts ahead of Fedora's drop-ins. That last one is the silent regression, since sshd takes the first value it obtains and a rename to 60- would leave a file that reads correctly and does nothing. The drop-in's closing note said the installer does not yet collect a key. It does; corrected.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-20 22:38 UTC
Signed with PGP, not checked
Commit: 7e6565aaa99d053aa24eb4eb0b5495b27a4faf14
Parent: 55f0a47
3 files changed, +389 insertions, -8 deletions
@@ -313,6 +313,43 @@
313 313 /// Longest single hostname label, per RFC 1123.
314 314 const HOSTNAME_MAX: usize = 63;
315 315
316 + /// Where a minted medium carries the public key it was baked with.
317 + ///
318 + /// Written by the Containerfile's identity step from the `ALLOY_SSH_KEY` build
319 + /// argument, and read by `etc/ssh/sshd_config.d/20-alloy-installer.conf` as the
320 + /// `installer` account's `AuthorizedKeysFile`. So on a headless install this
321 + /// file holds the key the operator is connected with right now.
322 + ///
323 + /// The installer reads it to seed the account step rather than growing a
324 + /// second place a key can come from. The two paths are one key store: baking
325 + /// is how a key reaches the medium, and this is how it reaches the machine the
326 + /// medium installs.
327 + const BAKED_KEY: &str = "/usr/lib/alloy/authorized_keys";
328 +
329 + /// The key a medium was minted with, if it carries a usable one.
330 + ///
331 + /// Path taken as an argument so the tests can point it at a file they wrote;
332 + /// [`InstallView::new`] passes [`BAKED_KEY`].
333 + ///
334 + /// Only the first key. `authorized_keys` is a multi-key format and sshd reads
335 + /// every line of it, but the identity step writes exactly one and a medium
336 + /// carrying several is not a shape anything here produces. Taking the first is
337 + /// then a choice between one key and a field the user cannot read to the end
338 + /// of, and a seed is meant to be checked on screen.
339 + ///
340 + /// Anything that does not validate is dropped rather than seeded. A field
341 + /// pre-filled with a line sshd will ignore is worse than an empty one: the
342 + /// summary would show a key, the install would plant it, and the machine would
343 + /// refuse the login anyway.
344 + fn baked_pubkey_at(path: &str) -> Option<String> {
345 + let contents = std::fs::read_to_string(path).ok()?;
346 + let key = contents
347 + .lines()
348 + .map(str::trim)
349 + .find(|line| !line.is_empty() && !line.starts_with('#'))?;
350 + (validate_pubkey(key).is_ok() && !key.is_empty()).then(|| key.to_string())
351 + }
352 +
316 353 /// Check a hostname, returning why it is unacceptable.
317 354 ///
318 355 /// RFC 1123 rules for a single label, which is what `/etc/hostname` holds:
@@ -3181,6 +3218,12 @@
3181 3218 password: TextField,
3182 3219 confirm: TextField,
3183 3220 pubkey: TextField,
3221 + /// Whether the key field arrived seeded from the medium.
3222 + ///
3223 + /// Only to say so on screen. The field is editable either way, and what
3224 + /// gets planted is whatever it holds when the step is confirmed, so nothing
3225 + /// downstream reads this.
3226 + pubkey_from_medium: bool,
3184 3227 /// Which of the two hostname-step slots has focus.
3185 3228 machine: FocusRing,
3186 3229 /// The checkbox, until the step is confirmed and it becomes an answer.
@@ -3274,6 +3317,22 @@
3274 3317 // a field arriving pre-filled says what shape of answer is expected.
3275 3318 hostname.set(DEFAULT_HOSTNAME);
3276 3319
3320 + // Seeded from the medium for the same reason the hostname is seeded
3321 + // from its default, and for a sharper one besides. On the headless flow
3322 + // the operator reached this wizard over ssh, authenticated by exactly
3323 + // this key, so asking them to paste it again is asking them to retype a
3324 + // credential they already proved they hold, into a TUI, over that same
3325 + // connection. It is also the case with no console to recover from if
3326 + // they get it wrong.
3327 + //
3328 + // A seed, not a decision: the field is editable and the account step
3329 + // validates whatever it ends up holding.
3330 + let mut pubkey = TextField::new();
3331 + let baked = baked_pubkey_at(BAKED_KEY);
3332 + if let Some(key) = &baked {
3333 + pubkey.set(key);
3334 + }
3335 +
3277 3336 let mut view = Self {
3278 3337 steps: Steps::new(STEPS.len()),
3279 3338 backend: detect(),
@@ -3283,7 +3342,8 @@
3283 3342 username: TextField::new(),
3284 3343 password: TextField::new(),
3285 3344 confirm: TextField::new(),
3286 - pubkey: TextField::new(),
3345 + pubkey,
3346 + pubkey_from_medium: baked.is_some(),
3287 3347 machine: FocusRing::new(HOSTNAME_SLOTS),
3288 3348 locate_timezone: false,
3289 3349 fields: FocusRing::new(ACCOUNT_FIELDS),
@@ -3709,10 +3769,21 @@
3709 3769 // console is a machine this field is the only way into.
3710 3770 let hint: &[&str] = if self.pubkey.value().trim().is_empty() {
3711 3771 &[
3712 - "optional, and paste is fine.",
3772 + "paste is fine.",
3713 3773 "left empty, ssh into this machine will be impossible: the",
3714 3774 "image refuses password logins, so a key has to be added here",
3715 - "or later from the console.",
3775 + "or later from the console. A machine with no console is a",
3776 + "machine nobody can reach.",
3777 + ]
3778 + } else if self.pubkey_from_medium {
3779 + // Said because a field that filled itself in is a field people
3780 + // scroll past. Where it came from is also the check worth making:
3781 + // if this is not the key you expect, the medium is not the one you
3782 + // think it is.
3783 + &[
3784 + "authorized for the account above.",
3785 + "taken from the key this medium was minted with. Edit it if",
3786 + "the machine should answer to a different one.",
3716 3787 ]
3717 3788 } else {
3718 3789 &["authorized for the account above."]
@@ -4327,9 +4398,31 @@
4327 4398 return Flow::Continue;
4328 4399 };
4329 4400
4401 + // The no-key case, carried onto the gate rather than left on the
4402 + // summary alone. The summary names it, and the summary is the screen
4403 + // people read once and then answer from memory on the second install.
4404 + // This one is the screen nobody gets past without reading, and what it
4405 + // is warning about is not recoverable from the far side: the image
4406 + // refuses password logins (etc/ssh/sshd_config.d/10-alloy.conf), so an
4407 + // installed machine with no key in its authorized_keys answers no ssh
4408 + // login at all, and the only way back in is its own console.
4409 + //
4410 + // Stated, not blocked. A desktop install with a person sitting at it is
4411 + // a legitimate no-key install: greetd is on the console, they log in
4412 + // and add a key afterwards. Refusing here would fail the ordinary case
4413 + // to protect the headless one, and the headless one is now seeded from
4414 + // the medium's own key ([`BAKED_KEY`]), so it arrives with a key
4415 + // already in the field.
4416 + let reachable = if self.answers.pubkey.is_some() {
4417 + String::new()
4418 + } else {
4419 + " No ssh key: this machine will refuse every ssh login, and its console is the only way in."
4420 + .to_string()
4421 + };
4422 +
4330 4423 Flow::Confirm(Confirm::destructive(
4331 4424 "erase disk",
4332 - format!("Erase {disk} and install Alloy? This cannot be undone."),
4425 + format!("Erase {disk} and install Alloy? This cannot be undone.{reachable}"),
4333 4426 ))
4334 4427 }
4335 4428
@@ -5047,6 +5140,7 @@
5047 5140 password: TextField::new(),
5048 5141 confirm: TextField::new(),
5049 5142 pubkey: TextField::new(),
5143 + pubkey_from_medium: false,
5050 5144 machine: FocusRing::new(HOSTNAME_SLOTS),
5051 5145 locate_timezone: false,
5052 5146 fields: FocusRing::new(ACCOUNT_FIELDS),
@@ -5644,6 +5738,48 @@
5644 5738 assert!(confirm.message.contains("/dev/sda"), "{}", confirm.message);
5645 5739 }
5646 5740
5741 + // The gate names the lockout. The image refuses password logins, so an
5742 + // install with no key produces a machine that answers no ssh login at all,
5743 + // and the last screen before the disk is written is the one nobody skips.
5744 + #[test]
5745 + fn the_gate_says_an_install_with_no_key_cannot_be_reached_over_ssh() {
5746 + let (mut view, mut log) = at_credits();
5747 + assert!(view.answers.pubkey.is_none(), "the fixture typed a key");
5748 +
5749 + let Flow::Confirm(confirm) = view.handle(KeyEvent::from(KeyCode::Enter), &mut log) else {
5750 + panic!("the credits page ran without confirming");
5751 + };
5752 +
5753 + assert!(
5754 + confirm.message.contains("No ssh key"),
5755 + "{}",
5756 + confirm.message
5757 + );
5758 + assert!(
5759 + confirm.message.contains("console"),
5760 + "it did not say what the way in is: {}",
5761 + confirm.message,
5762 + );
5763 + }
5764 +
5765 + // The other half: a key was given, so the warning is absent. A gate that
5766 + // said it every time would be read as boilerplate and stop being a warning.
5767 + #[test]
5768 + fn the_gate_is_silent_about_ssh_when_a_key_was_given() {
5769 + let (mut view, mut log) = at_credits();
5770 + view.answers.pubkey = Some(format!("ssh-ed25519 {ED25519_BODY} max@fw13"));
5771 +
5772 + let Flow::Confirm(confirm) = view.handle(KeyEvent::from(KeyCode::Enter), &mut log) else {
5773 + panic!("the credits page ran without confirming");
5774 + };
5775 +
5776 + assert!(
5777 + !confirm.message.contains("No ssh key"),
5778 + "{}",
5779 + confirm.message
5780 + );
5781 + }
5782 +
5647 5783 // Esc from the credits is a way back to the answers, not a way out. A
5648 5784 // review screen you cannot return to from the last page would make the
5649 5785 // credits a trap rather than a step.
@@ -7346,6 +7482,97 @@
7346 7482 }
7347 7483 }
7348 7484
7485 + // Three places name this path: the Containerfile's identity step writes it,
7486 + // the installer's sshd drop-in reads it as the `installer` account's
7487 + // authorized keys, and this constant seeds the wizard's key field from it.
7488 + // The other two are pinned to each other in `tests/ssh_policy.rs`, which
7489 + // cannot see a private constant. This is that pin's third leg.
7490 + #[test]
7491 + fn the_seeded_key_is_the_one_the_medium_was_baked_with() {
7492 + let drop_in = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
7493 + .join("../../etc/ssh/sshd_config.d/20-alloy-installer.conf");
7494 + let text = std::fs::read_to_string(&drop_in)
7495 + .unwrap_or_else(|error| panic!("reading {}: {error}", drop_in.display()));
7496 +
7497 + assert!(
7498 + text.contains(&format!("AuthorizedKeysFile {BAKED_KEY}")),
7499 + "the installer seeds from {BAKED_KEY}, which the medium's sshd does not read",
7500 + );
7501 + }
7502 +
7503 + /// A scratch file holding `contents`, named for the test that wrote it.
7504 + fn scratch(name: &str, contents: &str) -> std::path::PathBuf {
7505 + let dir = std::env::temp_dir().join(format!("alloy-baked-key-{}", std::process::id()));
7506 + std::fs::create_dir_all(&dir).expect("scratch dir");
7507 + let path = dir.join(name);
7508 + std::fs::write(&path, contents).expect("write");
7509 + path
7510 + }
7511 +
7512 + // The point of the whole seeding path: a medium minted with a key hands
7513 + // that key to the install it performs, rather than asking the operator to
7514 + // paste back the credential they are already connected with.
7515 + #[test]
7516 + fn a_baked_medium_hands_its_key_to_the_install() {
7517 + let key = format!("ssh-ed25519 {ED25519_BODY} max@fw13");
7518 + let path = scratch("baked", &format!("{key}\n"));
7519 +
7520 + assert_eq!(
7521 + baked_pubkey_at(&path.display().to_string()).as_deref(),
7522 + Some(key.as_str()),
7523 + );
7524 + }
7525 +
7526 + // An unbaked medium is the ordinary desktop mint, and it is not an error.
7527 + // The field is simply empty and the person at the console types their own.
7528 + #[test]
7529 + fn a_medium_with_no_baked_key_seeds_nothing() {
7530 + assert!(baked_pubkey_at("/nonexistent/authorized_keys").is_none());
7531 + }
7532 +
7533 + // Whitespace, comments and a trailing blank line all read as no key. An
7534 + // authorized_keys that is present and holds nothing usable is the same
7535 + // state as one that is absent, and seeding a blank into the field would
7536 + // put an empty line where a key is meant to go.
7537 + #[test]
7538 + fn an_empty_or_commented_baked_file_seeds_nothing() {
7539 + for (label, contents) in [
7540 + ("empty", ""),
7541 + ("blank lines", "\n\n \n"),
7542 + ("comments only", "# minted without a key\n"),
7543 + ] {
7544 + let path = scratch("emptyish", contents);
7545 + assert!(
7546 + baked_pubkey_at(&path.display().to_string()).is_none(),
7547 + "{label} seeded a key",
7548 + );
7549 + }
7550 + }
7551 +
7552 + // A seeded field is one people scroll past, so what lands in it has to be
7553 + // a key sshd will actually read. Seeding a malformed line would show a key
7554 + // on the summary, plant it, and still produce a machine that refuses the
7555 + // login.
7556 + #[test]
7557 + fn a_baked_file_sshd_would_ignore_seeds_nothing() {
7558 + let path = scratch("bad", "ssh-ed25519 not-base64!!!\n");
7559 + assert!(baked_pubkey_at(&path.display().to_string()).is_none());
7560 + }
7561 +
7562 + // The first line only. The identity step writes exactly one key, and a
7563 + // field the user cannot read to the end of is not a field they can check.
7564 + #[test]
7565 + fn only_the_first_baked_key_is_taken() {
7566 + let first = format!("ssh-ed25519 {ED25519_BODY} first");
7567 + let second = format!("ssh-ed25519 {ED25519_BODY} second");
7568 + let path = scratch("two", &format!("{first}\n{second}\n"));
7569 +
7570 + assert_eq!(
7571 + baked_pubkey_at(&path.display().to_string()).as_deref(),
7572 + Some(first.as_str()),
7573 + );
7574 + }
7575 +
7349 7576 // The failure this exists for. `id_ed25519` and `id_ed25519.pub` differ by
7350 7577 // four characters and the wrong one is the first shell completion, so a
7351 7578 // private key pasted onto a screen in front of whoever is standing there is a
@@ -34,7 +34,16 @@
34 34
35 35 # WHAT THIS COSTS, said plainly. On a desktop install nothing: greetd is on the
36 36 # console, so a user who has not planted a key logs in locally and adds one.
37 - # On a HEADLESS install it is a lockout, because there is no console to recover
38 - # from and `alloy install` does not yet collect a public key. That gap is real
39 - # and tracked (GoingsOn alloy d1fed0d7 item 2); until it closes, a headless
40 - # install has to plant a key into the target's authorized_keys out of band.
37 + #
38 + # On a HEADLESS install it would be a lockout, and the other half of the
39 + # decision is what stops it being one. `alloy install` collects a public key on
40 + # its account step and writes it to the new user's authorized_keys, and on a
41 + # minted medium that field arrives already filled from
42 + # /usr/lib/alloy/authorized_keys, which is the key the operator is connected
43 + # with. So the headless path never reaches this file empty-handed.
44 + #
45 + # An install that ends with no key is still possible, and it is a real answer
46 + # rather than an oversight: a desktop, with a person at the console. The
47 + # installer says so on its summary and again on the gate before the disk is
48 + # written, because from the far side of a reboot there is no way to tell that
49 + # machine from a broken one.
@@ -1,0 +1,145 @@
1 + //! Alloy refuses ssh password logins, and the drop-in that says so is asserted
2 + //! rather than reviewed.
3 + //!
4 + //! `etc/ssh/sshd_config.d/10-alloy.conf` is the whole of the policy. Max
5 + //! decided it 2026-08-18 (GoingsOn alloy `f536e26e`): password authentication
6 + //! off, and `alloy install` plants a key. The two halves ship together, because
7 + //! either one alone is wrong: the drop-in without the key is a headless machine
8 + //! nobody can reach, and the key without the drop-in leaves uid 1000 reachable
9 + //! by password from anywhere that can route to it.
10 + //!
11 + //! What this guards is the quiet kind of regression. sshd takes the FIRST value
12 + //! it obtains for a keyword, so the file's authority is its number, and a
13 + //! rename to `60-alloy.conf` would leave a file that reads correctly and does
14 + //! nothing. Neither would the base complain: `50-redhat.conf` would simply win
15 + //! and password auth would be back on, with no error anywhere.
16 + //!
17 + //! Read rather than executed. Running `sshd -T` against the tree would need an
18 + //! sshd, a host key and a config that resolves paths on the machine running the
19 + //! tests, and would assert the behaviour of this machine's sshd rather than of
20 + //! the one Alloy ships.
21 +
22 + use std::path::PathBuf;
23 +
24 + fn tree(relative: &str) -> String {
25 + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
26 + .join("../..")
27 + .join(relative);
28 + std::fs::read_to_string(&path)
29 + .unwrap_or_else(|error| panic!("reading {}: {error}", path.display()))
30 + }
31 +
32 + /// The policy file's directives, comments and blank lines dropped.
33 + fn policy() -> Vec<String> {
34 + tree("etc/ssh/sshd_config.d/10-alloy.conf")
35 + .lines()
36 + .map(str::trim)
37 + .filter(|line| !line.is_empty() && !line.starts_with('#'))
38 + .map(str::to_string)
39 + .collect()
40 + }
41 +
42 + /// Both password doors are shut, and root is key-only.
43 + ///
44 + /// `KbdInteractiveAuthentication` is not redundant with the first: PAM's
45 + /// keyboard-interactive stack can authenticate a password on its own, so
46 + /// turning off `PasswordAuthentication` alone leaves the rule half applied.
47 + #[test]
48 + fn the_shipped_policy_refuses_every_password_login() {
49 + let directives = policy();
50 + for required in [
51 + "PasswordAuthentication no",
52 + "KbdInteractiveAuthentication no",
53 + "PermitRootLogin prohibit-password",
54 + ] {
55 + assert!(
56 + directives.iter().any(|line| line == required),
57 + "the ssh policy does not say `{required}`: {directives:?}",
58 + );
59 + }
60 + }
61 +
62 + /// Nothing in the file turns a door back on, and nothing scopes the rule to
63 + /// some accounts.
64 + ///
65 + /// A `Match` block here would be the failure that is hardest to see by reading:
66 + /// sshd applies a match to everything that follows it, including the two
67 + /// hundred keywords `/etc/ssh/sshd_config` sets after its `Include`, so a
68 + /// stray one would not narrow this file, it would narrow the whole config.
69 + #[test]
70 + fn the_policy_is_unconditional() {
71 + for line in policy() {
72 + assert!(
73 + !line.starts_with("Match"),
74 + "the policy is scoped by `{line}`, which applies to every keyword after it",
75 + );
76 + assert!(
77 + !line.eq_ignore_ascii_case("PasswordAuthentication yes"),
78 + "the policy turns password auth back on: {line}",
79 + );
80 + }
81 + }
82 +
83 + /// The number is the mechanism. sshd takes the first value it obtains for a
84 + /// keyword and `/etc/ssh/sshd_config` includes this directory ahead of every
85 + /// keyword it sets, so a file sorting after Fedora's own drop-ins loses to
86 + /// them silently.
87 + ///
88 + /// 40 is `40-redhat-crypto-policies.conf` and 50 is `50-redhat.conf`, the two
89 + /// the base ships. Anything below 40 wins; the file is 10 so there is room to
90 + /// override it without renaming it.
91 + #[test]
92 + fn the_policy_is_read_before_the_drop_ins_it_has_to_beat() {
93 + let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../etc/ssh/sshd_config.d");
94 + let name = "10-alloy.conf";
95 + assert!(dir.join(name).exists(), "{name} is not in the tree");
96 +
97 + let prefix: u32 = name
98 + .split_once('-')
99 + .expect("the drop-in is numbered")
100 + .0
101 + .parse()
102 + .expect("the prefix is a number");
103 + assert!(
104 + prefix < 40,
105 + "{name} sorts after Fedora's own drop-ins and would lose to them",
106 + );
107 + }
108 +
109 + /// The file reaches the image. `COPY etc/ /etc/` is what carries it, and it is
110 + /// a plain copy rather than a `RUN`, which is the reason the tree can hold this
111 + /// file at all.
112 + #[test]
113 + fn the_policy_is_copied_into_the_image() {
114 + let containerfile = tree("Containerfile");
115 + assert!(
116 + containerfile
117 + .lines()
118 + .any(|line| line.starts_with("COPY etc/") && line.contains("/etc/")),
119 + "nothing copies the etc tree into the image",
120 + );
121 + }
122 +
123 + /// The installer's own drop-in and the Containerfile's identity step name the
124 + /// same file, which is the medium-baking path: `ALLOY_SSH_KEY` writes the key
125 + /// there at build time, and sshd reads it there as the `installer` account's
126 + /// authorized keys.
127 + ///
128 + /// Pinned because `alloy install` now reads the same path to seed its key
129 + /// field, so three places agree on one string and a change to any of them
130 + /// alone is a silent break. The installer's copy of the constant is checked in
131 + /// its own unit tests, which can see it.
132 + #[test]
133 + fn the_medium_bakes_its_key_where_the_installer_reads_it() {
134 + const PATH: &str = "/usr/lib/alloy/authorized_keys";
135 +
136 + assert!(
137 + tree("etc/ssh/sshd_config.d/20-alloy-installer.conf")
138 + .contains(&format!("AuthorizedKeysFile {PATH}")),
139 + "the installer's sshd drop-in does not read {PATH}",
140 + );
141 + assert!(
142 + tree("Containerfile").contains(&format!("> {PATH}")),
143 + "the identity step does not write {PATH}",
144 + );
145 + }