Skip to main content

max / alloy

Collect an ssh key at install, so a machine is reachable The image refuses password logins over ssh (etc/ssh/sshd_config.d/10-alloy.conf, key-only by policy), and the installer asked for no key. So every install produced a machine with exactly one way in: the console in front of it. That was already the state of the one running install, which is why enabling Tailscale SSH on it by hand was necessary before it could be looked at remotely. Optional, per the minting decision: a minted image carries the key already, so this is the recovery path rather than the happy one. Left empty the account pane says plainly that ssh will be impossible rather than merely inconvenient, and the summary says "none" instead of printing a dash, because that is the answer worth reviewing. The validation earns its keep on one case. A private key pasted by mistake gets told so by name: id_ed25519 and id_ed25519.pub differ by four characters, the wrong one is the first shell completion, and a generic rejection is answered by pasting it again. Everything else refused here is something sshd would accept into the file and then ignore silently, which is indistinguishable from the installer having dropped the key. Written before the recursive chown the account stages already do, so ownership needs no second place that knows the numeric ids, with the modes sshd insists on: it ignores a group-writable authorized_keys and logs the reason nowhere the user will look. The key field also gave field_line a caret that scrolls. An ed25519 key is 68 characters and RSA nearer 400, so without it the caret leaves the pane and typing continues off screen, which is a poor place to be checking a paste.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-30 04:13 UTC
Signed with PGP, not checked
Commit: bb91e8dc04bb98761ea4eff56499c3fdd757d53e
Parent: d2dfeca
2 files changed, +303 insertions, -21 deletions
M Cargo.lock +2 -2
@@ -895,9 +895,9 @@
895 895
896 896 [[package]]
897 897 name = "makeover"
898 - version = "2.2.0"
898 + version = "2.3.0"
899 899 source = "registry+https://github.com/rust-lang/crates.io-index"
900 - checksum = "f3d7c2a566230afeed8b2b9d8decc2edfdc9b1f067e7791303df8301a73aeff1"
900 + checksum = "e35cc903581eea6df09a3a102f101d85a6b941ea18431fcd0a70738762271fdf"
901 901 dependencies = [
902 902 "include_dir",
903 903 "serde",
@@ -118,7 +118,8 @@
118 118 const FIELD_USERNAME: usize = 0;
119 119 const FIELD_PASSWORD: usize = 1;
120 120 const FIELD_CONFIRM: usize = 2;
121 - const ACCOUNT_FIELDS: usize = 3;
121 + const FIELD_PUBKEY: usize = 3;
122 + const ACCOUNT_FIELDS: usize = 4;
122 123
123 124 /// Which slot of the hostname step has focus.
124 125 ///
@@ -132,6 +133,13 @@
132 133 /// Longest username `useradd` accepts.
133 134 const USERNAME_MAX: usize = 32;
134 135
136 + /// Width of the right-aligned label column on the form panes.
137 + ///
138 + /// Named because two things depend on it agreeing: the label itself, and the
139 + /// budget [`field_line`](InstallView::field_line) has left for a value that has
140 + /// to scroll under its caret.
141 + const LABEL_WIDTH: usize = 10;
142 +
135 143 /// Check a username against what `useradd` will accept.
136 144 ///
137 145 /// Rules are the portable ones NAME_REGEX enforces on Fedora: start with a
@@ -221,6 +229,105 @@
221 229 Ok(())
222 230 }
223 231
232 + /// The key types OpenSSH will accept in an `authorized_keys` line.
233 + ///
234 + /// `ssh-dss` is deliberately absent: OpenSSH disabled DSA at runtime years ago
235 + /// and removed it outright in 9.8, so accepting one here would write a file the
236 + /// target's sshd ignores, which is indistinguishable from the installer having
237 + /// dropped the key.
238 + const PUBKEY_TYPES: [&str; 6] = [
239 + "ssh-ed25519",
240 + "sk-ssh-ed25519@openssh.com",
241 + "ssh-rsa",
242 + "ecdsa-sha2-nistp256",
243 + "ecdsa-sha2-nistp384",
244 + "ecdsa-sha2-nistp521",
245 + ];
246 +
247 + /// Check an SSH public key, returning why it is unacceptable.
248 + ///
249 + /// **Empty is acceptable.** The field is optional by the 2026-07-25 minting
250 + /// decision: a minted image carries the key already, so this is the recovery
251 + /// path rather than the happy one. What is not acceptable is a value that looks
252 + /// like a key and is not, because sshd's response to a malformed
253 + /// `authorized_keys` line is to ignore it silently, and the user finds out by
254 + /// being unable to log in.
255 + ///
256 + /// The case worth its own message is a **private** key. `id_ed25519` and
257 + /// `id_ed25519.pub` differ by four characters, the wrong one is the first
258 + /// completion in most shells, and a private key pasted into a field on screen in
259 + /// front of whoever is standing there is a real thing to have happen. Naming it
260 + /// is the difference between "that is the wrong file" and a generic rejection
261 + /// the user answers by pasting it again.
262 + pub(crate) fn validate_pubkey(key: &str) -> Result<(), String> {
263 + let key = key.trim();
264 + if key.is_empty() {
265 + return Ok(());
266 + }
267 +
268 + // Checked before the shape, because a private key fails the shape check too
269 + // and would otherwise get the unhelpful message.
270 + if key.starts_with("-----BEGIN") {
271 + return Err("that is a private key; paste the .pub file instead".into());
272 + }
273 +
274 + let mut parts = key.split_whitespace();
275 + let (Some(kind), Some(body)) = (parts.next(), parts.next()) else {
276 + return Err("a key is '<type> <base64>', as found in a .pub file".into());
277 + };
278 + if !PUBKEY_TYPES.contains(&kind) {
279 + return Err(format!("'{kind}' is not a key type openssh accepts"));
280 + }
281 + // The base64 body, not the whole line: a comment may hold anything, and
282 + // people put spaces and punctuation in them.
283 + if body.len() % 4 != 0 || !body.trim_end_matches('=').bytes().all(is_base64) {
284 + return Err("the key body is not valid base64; the paste may be truncated".into());
285 + }
286 + // Every accepted type encodes its own name at the front of the body, and
287 + // base64 maps the first three bytes of that to a fixed prefix. So a body
288 + // pasted from a different line than its type is catchable without decoding
289 + // anything: `ssh-ed25519` bodies begin `AAAAC3NzaC1lZDI1`, `ssh-rsa` bodies
290 + // `AAAAB3NzaC1yc2E`. Checking the shared `AAAA` alone is enough to catch a
291 + // body that is base64 of something else entirely, which is the realistic
292 + // paste error rather than a crafted mismatch.
293 + if !body.starts_with("AAAA") {
294 + return Err("that base64 is not an ssh key body".into());
295 + }
296 + Ok(())
297 + }
298 +
299 + /// A base64 digit, in the standard alphabet `authorized_keys` uses.
300 + fn is_base64(b: u8) -> bool {
301 + b.is_ascii_alphanumeric() || b == b'+' || b == b'/'
302 + }
303 +
304 + /// A key as a review screen should show it.
305 + ///
306 + /// The full body is 68 characters for ed25519 and nearer 400 for RSA, which on a
307 + /// summary would push every other row off the screen and still not be something
308 + /// anyone reads character by character. What a person checks is the type, the
309 + /// comment naming which machine it came from, and enough of the body to tell two
310 + /// keys apart, so that is what this keeps.
311 + fn pubkey_summary(key: &str) -> String {
312 + let mut parts = key.split_whitespace();
313 + let (Some(kind), Some(body)) = (parts.next(), parts.next()) else {
314 + return key.to_string();
315 + };
316 + // The tail rather than the head: every body of a given type starts with the
317 + // same encoded type name, so the first characters are the ones that do not
318 + // distinguish anything.
319 + let tail: String = body
320 + .chars()
321 + .skip(body.chars().count().saturating_sub(8))
322 + .collect();
323 + let comment = parts.collect::<Vec<_>>().join(" ");
324 + if comment.is_empty() {
325 + format!("{kind} …{tail}")
326 + } else {
327 + format!("{kind} …{tail} {comment}")
328 + }
329 + }
330 +
224 331 // ---- what the installer has been told ----
225 332
226 333 /// The answers collected so far.
@@ -240,6 +347,13 @@
240 347 /// [`TextField`] until the plan is built, which keeps the number of
241 348 /// places holding a readable copy to one. See [`Secret`].
242 349 pub username: Option<String>,
350 + /// The SSH public key authorized for that account, if one was given.
351 + ///
352 + /// Not a [`Secret`], unlike the password, and the distinction is the point:
353 + /// this half of a keypair is meant to be published. Holding it here rather
354 + /// than leaving it in its [`TextField`] is therefore fine, and the summary
355 + /// step needs to read it back to say whether the machine will be reachable.
356 + pub pubkey: Option<String>,
243 357 /// Whether the install may ask the network where this machine is, to set
244 358 /// the timezone from it.
245 359 ///
@@ -478,6 +592,15 @@
478 592 /// Mode for a new home directory, matching Fedora's `HOME_MODE`.
479 593 const HOME_MODE: &str = "700";
480 594
595 + /// Modes sshd requires of `~/.ssh` and `authorized_keys`.
596 + ///
597 + /// Not arbitrary hardening: sshd refuses to read an `authorized_keys` that is
598 + /// writable by anyone but its owner, and says so only in the server's own log.
599 + /// A key written with the wrong mode therefore looks exactly like a key that was
600 + /// never written.
601 + const SSH_DIR_MODE: &str = "700";
602 + const SSH_FILE_MODE: &str = "600";
603 +
481 604 /// The shell new accounts get.
482 605 ///
483 606 /// nushell is the shell Alloy's config tree is written for: the aliases, the
@@ -751,6 +874,7 @@
751 874 hostname: &str,
752 875 username: &str,
753 876 password: &str,
877 + pubkey: Option<&str>,
754 878 locate_timezone: bool,
755 879 root: &str,
756 880 ) -> Result<Vec<Stage>, String> {
@@ -838,6 +962,48 @@
838 962 .arg(format!("{root}/etc/skel/."))
839 963 .arg(&home),
840 964 ),
965 + ];
966 +
967 + // Before the chown below, deliberately. That stage is already recursive over
968 + // the home directory, so a `.ssh` written here is covered by it and there is
969 + // no second place that has to know the numeric ids. Writing the key after it
970 + // would leave the directory root-owned, and sshd refuses an authorized_keys
971 + // it does not trust the ownership of, silently.
972 + if let Some(key) = pubkey {
973 + let ssh_dir = format!("{home}/.ssh");
974 + let authorized = format!("{ssh_dir}/authorized_keys");
975 + stages.extend([
976 + Stage::Run(Invocation::new("mkdir").args(["-p", &ssh_dir])),
977 + // `tee` because the key arrives on stdin, and stdin because an
978 + // Invocation is argv with no shell (see [`Invocation`]) so there is
979 + // no redirect to write with. The value goes through [`Secret`],
980 + // which is the only stdin this type takes; the redaction that comes
981 + // with it is incidental rather than a claim that a public key needs
982 + // hiding.
983 + //
984 + // Note that tee copies stdin to stdout, so the key does appear in
985 + // the run screen's streamed output even though it is absent from the
986 + // rendered argv. That is wanted rather than tolerated: this half of
987 + // the pair is meant to be published, and seeing it echoed is how the
988 + // user confirms the key that landed is the key they pasted.
989 + //
990 + // Trailing newline: sshd parses authorized_keys by line, and a final
991 + // line without one is not reliably read.
992 + Stage::Run(
993 + Invocation::new("tee")
994 + .arg(&authorized)
995 + .stdin(Secret::new(format!("{key}\n"))),
996 + ),
997 + // sshd enforces these itself: a group- or world-writable .ssh or
998 + // authorized_keys is ignored, with the reason going only to the
999 + // server's log. So getting them wrong produces exactly the symptom
1000 + // this whole field exists to prevent.
1001 + Stage::Run(Invocation::new("chmod").args([SSH_DIR_MODE, &ssh_dir])),
1002 + Stage::Run(Invocation::new("chmod").args([SSH_FILE_MODE, &authorized])),
1003 + ]);
1004 + }
1005 +
1006 + stages.extend([
841 1007 // First discovery: which uid and gid useradd picked. Reading the file
842 1008 // rather than asking getent, because getent answers about this machine.
843 1009 Stage::Resolve {
@@ -859,7 +1025,7 @@
859 1025 .arg("--encrypted")
860 1026 .stdin(Secret::new(format!("{username}:{hash}\n"))),
861 1027 ),
862 - ];
1028 + ]);
863 1029
864 1030 // Last of the answers, and deliberately after the account: it is the only
865 1031 // stage that can decline to do anything, and the only one that touches the
@@ -975,11 +1141,13 @@
975 1141 hostname: &str,
976 1142 username: &str,
977 1143 password: &str,
1144 + pubkey: Option<&str>,
978 1145 locate_timezone: bool,
979 1146 ) -> Vec<Stage> {
980 1147 let hostname = hostname.to_string();
981 1148 let username = username.to_string();
982 1149 let password = password.to_string();
1150 + let pubkey = pubkey.map(str::to_string);
983 1151
984 1152 vec![
985 1153 // --wipe is explicit rather than implied by the confirm the user just
@@ -1066,6 +1234,7 @@
1066 1234 &hostname,
1067 1235 &username,
1068 1236 &password,
1237 + pubkey.as_deref(),
1069 1238 locate_timezone,
1070 1239 deployment,
1071 1240 )
@@ -1414,6 +1583,7 @@
1414 1583 username: TextField,
1415 1584 password: TextField,
1416 1585 confirm: TextField,
1586 + pubkey: TextField,
1417 1587 /// Which of the two hostname-step slots has focus.
1418 1588 machine: FocusRing,
1419 1589 /// The checkbox, until the step is confirmed and it becomes an answer.
@@ -1452,6 +1622,7 @@
1452 1622 username: TextField::new(),
1453 1623 password: TextField::new(),
1454 1624 confirm: TextField::new(),
1625 + pubkey: TextField::new(),
1455 1626 machine: FocusRing::new(HOSTNAME_SLOTS),
1456 1627 locate_timezone: false,
1457 1628 fields: FocusRing::new(ACCOUNT_FIELDS),
@@ -1568,6 +1739,7 @@
1568 1739 match self.fields.current() {
1569 1740 FIELD_PASSWORD => &mut self.password,
1570 1741 FIELD_CONFIRM => &mut self.confirm,
1742 + FIELD_PUBKEY => &mut self.pubkey,
1571 1743 _ => &mut self.username,
1572 1744 }
1573 1745 }
@@ -1590,8 +1762,18 @@
1590 1762 self.fields.focus(FIELD_CONFIRM);
1591 1763 return Flow::Continue;
1592 1764 }
1765 + if let Err(message) = validate_pubkey(self.pubkey.value()) {
1766 + self.error = Some(message);
1767 + self.fields.focus(FIELD_PUBKEY);
1768 + return Flow::Continue;
1769 + }
1593 1770
1594 1771 self.answers.username = Some(self.username.value().to_string());
1772 + // Trimmed and emptied to None together: a field holding only whitespace
1773 + // is a field the user left alone, and it must not become a blank line in
1774 + // authorized_keys.
1775 + let key = self.pubkey.value().trim();
1776 + self.answers.pubkey = (!key.is_empty()).then(|| key.to_string());
1595 1777 self.error = None;
1596 1778 self.steps.advance();
1597 1779 Flow::Continue
@@ -1607,7 +1789,7 @@
1607 1789 KeyCode::Tab | KeyCode::Down => self.fields.next(),
1608 1790 KeyCode::BackTab | KeyCode::Up => self.fields.prev(),
1609 1791 KeyCode::Enter => {
1610 - if self.fields.current() == FIELD_CONFIRM {
1792 + if self.fields.current() == FIELD_PUBKEY {
1611 1793 return self.create_account();
1612 1794 }
1613 1795 self.fields.next();
@@ -1637,11 +1819,12 @@
1637 1819 field: &TextField,
1638 1820 slot: usize,
1639 1821 masked: bool,
1822 + width: u16,
1640 1823 ) -> Line<'a> {
1641 1824 let focused = self.fields.is_focused(slot);
1642 1825 let (before, under, after) = field.split();
1643 1826
1644 - let (before, under, after) = if masked {
1827 + let (mut before, under, mut after) = if masked {
1645 1828 (
1646 1829 "•".repeat(before.chars().count()),
1647 1830 under.map(|_| '•'),
@@ -1651,11 +1834,33 @@
1651 1834 (before.to_string(), under, after.to_string())
1652 1835 };
1653 1836
1837 + // Scroll the value under a fixed caret when it is wider than the pane.
1838 + //
1839 + // Every other field on this screen is short enough that this never
1840 + // triggers: a username is capped at 32. An ed25519 public key is 68
1841 + // characters before its comment and an RSA one is nearer 400, against a
1842 + // label column and a pane that can be 80 wide. Without this the caret
1843 + // walks off the right edge and the user is typing somewhere they cannot
1844 + // see, which is a poor place to be checking a paste for truncation.
1845 + let budget = usize::from(width).saturating_sub(LABEL_WIDTH + 3);
1846 + if budget > 0 {
1847 + let len = before.chars().count();
1848 + if len > budget {
1849 + before = std::iter::once('…')
1850 + .chain(before.chars().skip(len - budget + 1))
1851 + .collect();
1852 + }
1853 + let tail = budget.saturating_sub(before.chars().count());
1854 + if after.chars().count() > tail {
1855 + after = after.chars().take(tail).collect();
1856 + }
1857 + }
1858 +
1654 1859 let mut spans = vec![
1655 1860 if focused {
1656 - text::bold(theme, format!("{label:>10} "))
1861 + text::bold(theme, format!("{label:>LABEL_WIDTH$} "))
1657 1862 } else {
1658 - text::muted(theme, format!("{label:>10} "))
1863 + text::muted(theme, format!("{label:>LABEL_WIDTH$} "))
1659 1864 },
1660 1865 text::primary(theme, before),
1661 1866 ];
@@ -1675,18 +1880,45 @@
1675 1880 Line::from(spans)
1676 1881 }
1677 1882
1678 - /// The account pane: three fields, the passwords masked.
1883 + /// The account pane: four fields, the passwords masked.
1679 1884 fn render_account(&self, frame: &mut Frame, area: Rect, theme: &Theme) {
1680 - let lines = vec![
1885 + let w = area.width;
1886 + let mut lines = vec![
1681 1887 Line::from(text::muted(
1682 1888 theme,
1683 1889 "The account you will log in with. It can become root with run0.",
1684 1890 )),
1685 1891 Line::default(),
1686 - self.field_line(theme, "username", &self.username, FIELD_USERNAME, false),
1687 - self.field_line(theme, "password", &self.password, FIELD_PASSWORD, true),
1688 - self.field_line(theme, "confirm", &self.confirm, FIELD_CONFIRM, true),
1892 + self.field_line(theme, "username", &self.username, FIELD_USERNAME, false, w),
1893 + self.field_line(theme, "password", &self.password, FIELD_PASSWORD, true, w),
1894 + self.field_line(theme, "confirm", &self.confirm, FIELD_CONFIRM, true, w),
1895 + Line::default(),
1896 + self.field_line(theme, "ssh key", &self.pubkey, FIELD_PUBKEY, false, w),
1689 1897 ];
1898 +
1899 + // Said here rather than only at the summary, because this is where the
1900 + // decision gets made. The image refuses password logins over ssh
1901 + // (etc/ssh/sshd_config.d/10-alloy.conf), so an empty field does not mean
1902 + // "ssh is less convenient", it means ssh cannot work at all until
1903 + // somebody adds a key from the console. A machine that will not have a
1904 + // console is a machine this field is the only way into.
1905 + let hint: &[&str] = if self.pubkey.value().trim().is_empty() {
1906 + &[
1907 + "optional, and paste is fine.",
1908 + "left empty, ssh into this machine will be impossible: the",
1909 + "image refuses password logins, so a key has to be added here",
1910 + "or later from the console.",
1911 + ]
1912 + } else {
1913 + &["authorized for the account above."]
1914 + };
1915 + for line in hint {
1916 + lines.push(Line::from(text::muted(
1917 + theme,
1918 + format!("{:LABEL_WIDTH$} {line}", ""),
1919 + )));
1920 + }
1921 +
1690 1922 frame.render_widget(Paragraph::new(lines), area);
1691 1923 }
1692 1924
@@ -1726,6 +1958,19 @@
1726 1958 self.answers.username.as_deref().unwrap_or("-"),
1727 1959 String::new(),
1728 1960 ),
1961 + // Same rule as the timezone below: named whichever way it went. The
1962 + // no-key case is the one worth a review screen, since it decides
1963 + // whether this machine is reachable at all, so it says what it means
1964 + // rather than printing a dash.
1965 + match self.answers.pubkey.as_deref() {
1966 + Some(key) => row(theme, "ssh key", &pubkey_summary(key), String::new()),
1967 + None => row(
1968 + theme,
1969 + "ssh key",
1970 + "none",
1971 + " no ssh access until one is added".into(),
1972 + ),
1973 + },
1729 1974 // Named on the summary whichever way it went. "UTC" is a decision
1730 1975 // the install is about to make, and a review screen that only
1731 1976 // listed the answers someone changed would hide the defaults it is
@@ -1857,6 +2102,7 @@
1857 2102 hostname,
1858 2103 username,
1859 2104 password,
2105 + self.answers.pubkey.as_deref(),
1860 2106 self.answers.locate_timezone,
1861 2107 )
1862 2108 }
@@ -2369,6 +2615,7 @@
2369 2615 username: TextField::new(),
2370 2616 password: TextField::new(),
2371 2617 confirm: TextField::new(),
2618 + pubkey: TextField::new(),
2372 2619 machine: FocusRing::new(HOSTNAME_SLOTS),
2373 2620 locate_timezone: false,
2374 2621 fields: FocusRing::new(ACCOUNT_FIELDS),
@@ -2616,7 +2863,11 @@
2616 2863 (view, log)
2617 2864 }
2618 2865
2619 - /// Fill the three account fields, moving between them the way a user does.
2866 + /// Fill the account fields, moving between them the way a user does.
2867 + ///
2868 + /// Leaves focus on the key field with it empty, which is where Enter
2869 + /// submits. That is also the default install: the key is optional, so a user
2870 + /// who does not want one tabs past it.
2620 2871 fn fill_account(view: &mut InstallView, user: &str, pass: &str, confirm: &str) {
2621 2872 let mut log = CommandLog::new();
2622 2873 view.fields.focus(FIELD_USERNAME);
@@ -2625,6 +2876,7 @@
2625 2876 type_into(view, pass, &mut log);
2626 2877 view.fields.focus(FIELD_CONFIRM);
2627 2878 type_into(view, confirm, &mut log);
2879 + view.fields.focus(FIELD_PUBKEY);
2628 2880 }
2629 2881
2630 2882 #[test]
@@ -2837,7 +3089,7 @@
2837 3089 /// Without the location lookup, which is the default and what almost every
2838 3090 /// assertion below is about. `configured_locating` is the other one.
2839 3091 fn configured() -> Vec<String> {
2840 - configure_plan("workshop", "max", "hunter2", false, DEPLOYMENT)
3092 + configure_plan("workshop", "max", "hunter2", None, false, DEPLOYMENT)
2841 3093 .expect("a well-formed deployment path")
2842 3094 .iter()
2843 3095 .map(Stage::display)
@@ -3227,11 +3479,12 @@
3227 3479
3228 3480 #[test]
3229 3481 fn a_located_install_asks_and_bounds_the_asking() {
3230 - let shown: Vec<String> = configure_plan("workshop", "max", "hunter2", true, DEPLOYMENT)
3231 - .expect("a well-formed deployment path")
3232 - .iter()
3233 - .map(Stage::display)
3234 - .collect();
3482 + let shown: Vec<String> =
3483 + configure_plan("workshop", "max", "hunter2", None, true, DEPLOYMENT)
3484 + .expect("a well-formed deployment path")
3485 + .iter()
3486 + .map(Stage::display)
3487 + .collect();
3235 3488
3236 3489 let lookup = shown
3237 3490 .iter()
@@ -3467,7 +3720,7 @@
3467 3720 // partition table between bootc writing it and udev settling.
3468 3721 #[test]
3469 3722 fn the_plan_settles_udev_before_it_reads_the_partition_table() {
3470 - let plan = install_plan("/dev/sda", "host", "user", "pw", false);
3723 + let plan = install_plan("/dev/sda", "host", "user", "pw", None, false);
3471 3724 let lines: Vec<String> = plan.iter().map(Stage::display).collect();
3472 3725 let settle = lines
3473 3726 .iter()
@@ -3483,6 +3736,229 @@
3483 3736 );
3484 3737 }
3485 3738
3739 + // ---- the ssh key ----
3740 +
3741 + // Optional by the minting decision: a minted image already carries the key,
3742 + // so this field is the recovery path. Empty must therefore pass validation
3743 + // rather than being the one answer that blocks the install.
3744 + #[test]
3745 + fn no_key_at_all_is_a_valid_answer() {
3746 + assert!(validate_pubkey("").is_ok());
3747 + assert!(validate_pubkey(" ").is_ok());
3748 + assert!(validate_pubkey("\t\n ").is_ok());
3749 + }
3750 +
3751 + /// A well-formed ed25519 body: 68 base64 characters, which is what
3752 + /// `ssh-keygen` emits and what the length check is calibrated against.
3753 + const ED25519_BODY: &str =
3754 + "AAAAC3NzaC1lZDI1NTE5AAAAIABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopq";
3755 +
3756 + #[test]
3757 + fn the_shapes_a_pub_file_actually_holds_are_accepted() {
3758 + let ed = format!("ssh-ed25519 {ED25519_BODY}");
3759 + let ecdsa = format!(
3760 + "ecdsa-sha2-nistp256 {}",
3761 + "AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBBBB"
3762 + );
3763 + for key in [
3764 + ed.clone(),
3765 + format!("{ed} max@fw13"),
3766 + // A comment with spaces in it, which is what a key generated on a Mac
3767 + // gets by default.
Lines truncated