ssh: standardize authorized_keys on /media/fat/config, with a one-time migration - #184
Conversation
The FAT-partition authorized_keys path shipped as /media/fat/linux/... in v2026.09.11-beta, chosen for local consistency with ssh.ext4 and wpa_supplicant.conf. The community had already settled this five years earlier: security_fixes.sh (MiSTer-devel/Scripts_MiSTer) has read /media/fat/config/authorized_keys since v2.1, 2021-12-17. Kreeblah raised it on the forum and asked, reasonably, for one location rather than two -- #183. config/ is also the right directory on its own terms: it holds user configuration, while linux/ holds the boot payload an update rewrites. Everything that made the FAT path work is unchanged -- same partition, same initramfs mount options, so StrictModes stays satisfied. sshd_config now lists .ssh/authorized_keys plus the config/ path, and NOT the old one: two live locations is the confusion #183 exists to end. What makes dropping it safe is a one-time migration in S50sshd, before sshd starts. It merges rather than clobbers when both files exist, verifies the destination holds every key the old file had, and only then removes the old file -- copy, verify, delete, never mv, because that file may be the only way into the box. If the move cannot be completed, sshd starts with the legacy path appended via -o AuthorizedKeysFile for that boot and warns on the console; a failed migration must not cost someone their key login. scripts/test-authorized-keys-migration.sh covers 14 cases and runs each twice, once with the host's GNU coreutils and once with BusyBox applets. That is not belt-and-braces: `grep -F -x -v -f` against an EMPTY pattern file matches nothing under GNU grep and everything under BusyBox, and an empty config/authorized_keys therefore made the merge silently produce an empty file -- destroying the key it was migrating -- while passing a naive verification. Case 12 is that bug; it cannot fail under GNU grep. lint.yml installs busybox-static if the runner lacks it. ci-tests.sh asserts, on the shipped artifacts: the new path is listed, the old one is not, StrictModes is untouched, and S50sshd still carries the migration. Verified against the real ARM userland (BusyBox 1.38.0, bash 5.2.37) in a chroot: 28/28 test cases, both start() paths, and OpenSSH 10.5p1 reporting `AuthorizedKeysFile .ssh/authorized_keys /media/fat/config/authorized_keys` with `StrictModes yes` under sshd -T. Not yet re-run on the rig. Closes #183 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
README, the FAQ, ssh-ftp-parity §1.3 and the table row, init-parity's S50sshd row, ci.md's lint.yml overview, the hardening plan's S1 and ADR 0031's Tier 1 all named the pre-#183 linux/ path. §1.3 gains the reasoning for the directory and the migration's design; the FAQ gains a note for users whose key is in the old place (they need do nothing); ci.md's "one non-shell job" paragraph was already stale and now lists all three. The hardening plan and ADR 0031 are still proposals -- only their path references move, no decision in either changes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
All three are in migrate_authorized_keys(); two were reproduced before fixing,
and each now has a test case that fails against the pre-fix code.
1. HIGH -- a destination file with no trailing newline lost a key. The merge
base was `cat "$AUTHKEYS"`, copied byte for byte, while the lines appended
to it come from grep and are always newline-terminated. Notepad does not
write a final newline, and putting the key on the card from Windows is
exactly what the FAQ describes, so an existing config/ key and the first
migrated one were spliced into a single invalid line:
"…AAAA2222 two@laptopssh-ed25519 AAAA1111 one@pc"
The mv had already landed by then, so the verification correctly failed and
kept the legacy file -- but the pre-existing key was gone. This is the one
case where copy-verify-delete did not protect the file being merged INTO.
The base is now $dest_lines (grep output), which is newline-terminated by
construction. Case 15.
2. MEDIUM -- an unwritable /run read as "this file holds no keys", and that is
the one branch that deletes. Neither grep's redirect was checked, so a failed
write and a genuine empty result were indistinguishable; the legacy file was
removed and rc=0 meant start() did not even arm the -o fallback. /run is now
proven writable by creating a scratch DIRECTORY up front, and the greps go
through filter(), which treats status 1 (no match) as success and anything
higher as a failed migration. Same guard covers the $AUTHKEYS read, which
could otherwise route a non-empty destination into the clobber branch.
Case 16.
3. LOW -- sync ran after the unlink rather than between the copy and it. exFAT
is not mounted sync here, so a power cut in that window could commit the
deletion while the new file was still only in page cache -- precisely the
ordering the design exists to rule out.
filter() itself needed the explicit `else` to be correct: after a bare
`if cmd; then ...; fi`, `$?` is the IF STATEMENT's status (zero when the
condition was false), not the command's. Caught by cases 5 and 12 going red.
Verified: 16/16 cases in both passes (GNU and the image's real ARM BusyBox
1.38.0 under qemu), each new case red against a mutant with its guard removed;
start() re-checked end to end in the chroot; sshd -T unchanged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Review round: three defects found in 1 (high) — a destination file with no trailing newline lost a key. The merge base was The 2 (medium) — an unwritable 3 (low) —
Verification: 16/16 in both passes (GNU + the image's real ARM BusyBox 1.38.0 under qemu); each new case red against a mutant with its guard removed; |
S50sshd and sshd_config are copied to every device; they should not carry essay-length rationale. Comment blocks in both are now at most two lines, and what they used to spell out moved into the docs that already cover these files: - docs/ssh-ftp-parity.md §1.3 gains an "Implementation notes" list -- the /run scratch-directory probe and filter()'s status rule, filter()'s load-bearing `else`, the empty -f pattern file behaving oppositely under BusyBox and GNU grep, why the merge base is grep output rather than `cat` of the card file, why sync sits between the copy and the delete, and why the fallback restates all three AuthorizedKeysFile paths. - docs/init-parity.md gains the host-key tmpfs fallback as item 8: why sshd must start even when the ext4 mount fails, and why that only works because the host keys are passed with -o. S50sshd: 277 -> 197 lines, code byte-identical (verified by diffing both versions with comments stripped). sshd_config: our added comment lines 71 -> 19; stock's own template comments are untouched, since trimming those would be gratuitous divergence from the file the parity audit compares against. Re-verified after the trim: 16/16 cases in both passes (GNU and the image's real ARM BusyBox 1.38.0), the three ci-tests gates, and OpenSSH 10.5p1 in the chroot still reporting PermitRootLogin/StrictModes/AuthorizedKeysFile/ PermitUserEnvironment unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Closes #183. Raised by @Kreeblah on the forum: https://misterfpga.org/viewtopic.php?p=114979#p114979
We shipped the FAT-partition
authorized_keysat/media/fat/linux/authorized_keys(v2026.09.11-beta). The community settled the question five years earlier:
security_fixes.shinMiSTer-devel/Scripts_MiSTerhas read/media/fat/config/authorized_keyssince v2.1 (2021-12-17). This moves us onto thatlocation and migrates existing users automatically.
config/is also the right directory on its own terms — it is user configuration(
device.bin, core.cfgfiles), whilelinux/is the boot payload an update rewrites.Nothing about the mechanism changes: same exFAT partition, same initramfs mount options
(
fmask=0022,dmask=0022), soStrictModes yesis still satisfied.Stock's script copies that file into
/root/.ssh, i.e. intolinux.img, so it must bere-run after every OS update. We read it in place — so sharing the location means a key
set up for stock already works here, and needs no script at all.
What changed
sshd_config—AuthorizedKeysFile .ssh/authorized_keys /media/fat/config/authorized_keys.The old path is not kept as a third entry: two live locations is exactly the confusion
#183 exists to end.
S50sshd— a one-time migration, before sshd starts. Dropping the old path is onlysafe because of this: otherwise anyone who followed our own FAQ silently loses key login
on the update that lands this change.
mv. While the function runs, the old file may bethe only way into the box, so it is removed only once the destination provably holds
every key it had.
security_fixes.sh-era key inconfig/and a different one inlinux/; both survive, and re-running cannot duplicatea key.
retired.
starts with the legacy path appended via
-o AuthorizedKeysFilefor that boot and warnson the console. A failed migration must never be why someone cannot log in.
scripts/test-authorized-keys-migration.sh(new, wired intolint.yml) — 14 cases,each run twice: once with the host's GNU coreutils, once with BusyBox applets.
That second pass is the point, not garnish:
A zero-byte
/media/fat/config/authorized_keystherefore took a GNU-clean merge andproduced an empty file under BusyBox — throwing away the key it was migrating — while
also satisfying a naive "is anything missing?" check, so the old file got deleted. Case 12
is that bug, caught before it shipped. It cannot fail under GNU grep, so the step installs
busybox-staticwhen the runner image lacks one (loud SKIP, never a silent pass, if thatdoes not work out).
scripts/ci-tests.sh— asserts on the shipped artifacts that the new path is listed,the old one is not,
StrictModes nois absent, andS50sshdstill carries themigration.
Docs — README, FAQ (with a "you need do nothing" note for existing users),
ssh-ftp-parity.md§1.3 + table row,init-parity.md'sS50sshdrow,ci.md's lintoverview, plus path references in the hardening plan and ADR 0031 (both still proposals —
only paths move, no decision changes).
Verification
Run against the real ARM userland — the built
rootfs.tarextracted and entered withunshare -r chroot, binfmt/qemu-arm, BusyBox 1.38.0 + bash 5.2.37, i.e. the applets theimage actually ships:
the empty-pattern guard removed fails case 12 under BusyBox only — the test detects
the bug it was written for.
start()end to end, sshd stubbed to log argv: normal boot migrates the key andpasses no
-o AuthorizedKeysFile; the failure path keeps the old file and passes-o AuthorizedKeysFile=.ssh/authorized_keys /media/fat/config/... /media/fat/linux/....sshd -tOK,sshd -TreportsAuthorizedKeysFile .ssh/authorized_keys /media/fat/config/authorized_keysandStrictModes yes.ci-tests.shgates verified both ways: PASS against a rootfs.tar patched with thesefiles, FAIL (all three checks) against the unpatched one.
shellcheckclean (-s shfor the init script,-xfor the test);actionlintclean.Still owed
linux/. Thedirectory change does not affect what it proved (same partition, same mount options), but
a real exFAT card, with a real key in
config/and a real migration fromlinux/, hasnot been run yet. Worth doing before this is in a release.
🤖 Generated with Claude Code