Skip to content

ssh: standardize authorized_keys on /media/fat/config, with a one-time migration - #184

Merged
mcfbytes merged 4 commits into
masterfrom
fix/authorized-keys-standard-location
Sep 18, 2026
Merged

mcfbytes merged 4 commits into
masterfrom
fix/authorized-keys-standard-location

Conversation

@mcfbytes

Copy link
Copy Markdown
Owner

Closes #183. Raised by @Kreeblah on the forum: https://misterfpga.org/viewtopic.php?p=114979#p114979

We shipped the FAT-partition authorized_keys at /media/fat/linux/authorized_keys
(v2026.09.11-beta). The community settled the question five years earlier:
security_fixes.sh in MiSTer-devel/Scripts_MiSTer has read
/media/fat/config/authorized_keys since v2.1 (2021-12-17). This moves us onto that
location and migrates existing users automatically.

config/ is also the right directory on its own terms — it is user configuration
(device.bin, core .cfg files), while linux/ is the boot payload an update rewrites.
Nothing about the mechanism changes: same exFAT partition, same initramfs mount options
(fmask=0022,dmask=0022), so StrictModes yes is still satisfied.

Stock's script copies that file into /root/.ssh, i.e. into linux.img, so it must be
re-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_configAuthorizedKeysFile .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 only
safe because of this: otherwise anyone who followed our own FAQ silently loses key login
on the update that lands this change.

  • Copy, verify, then delete — never mv. While the function runs, the old file may be
    the only way into the box, so it is removed only once the destination provably holds
    every key it had.
  • Merges, never clobbers. A user can legitimately have a security_fixes.sh-era key in
    config/ and a different one in linux/; both survive, and re-running cannot duplicate
    a key.
  • Comments carried over, blank lines dropped; a file with no keys at all is just
    retired.
  • Fails safe. If the move cannot be completed (card mounted read-only, no space), sshd
    starts with the legacy path appended via -o AuthorizedKeysFile for that boot and warns
    on the console. A failed migration must never be why someone cannot log in.

scripts/test-authorized-keys-migration.sh (new, wired into lint.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:

grep -F -x -v -f <EMPTY pattern file> <input>
  GNU grep     -> empty pattern set matches nothing, every line passes
  BusyBox grep -> empty pattern set matches everything, no line passes

A zero-byte /media/fat/config/authorized_keys therefore took a GNU-clean merge and
produced 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-static when the runner image lacks one (loud SKIP, never a silent pass, if that
does not work out).

scripts/ci-tests.sh — asserts on the shipped artifacts that the new path is listed,
the old one is not, StrictModes no is absent, and S50sshd still carries the
migration.

Docs — README, FAQ (with a "you need do nothing" note for existing users),
ssh-ftp-parity.md §1.3 + table row, init-parity.md's S50sshd row, ci.md's lint
overview, 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.tar extracted and entered with
unshare -r chroot, binfmt/qemu-arm, BusyBox 1.38.0 + bash 5.2.37, i.e. the applets the
image actually ships:

  • 28/28 migration cases (both passes) green; the same suite run against a mutant with
    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 and
    passes no -o AuthorizedKeysFile; the failure path keeps the old file and passes
    -o AuthorizedKeysFile=.ssh/authorized_keys /media/fat/config/... /media/fat/linux/....
  • OpenSSH 10.5p1 (this image's own binary) parses the shipped config: sshd -t OK,
    sshd -T reports
    AuthorizedKeysFile .ssh/authorized_keys /media/fat/config/authorized_keys and
    StrictModes yes.
  • ci-tests.sh gates verified both ways: PASS against a rootfs.tar patched with these
    files, FAIL (all three checks) against the unpatched one.
  • shellcheck clean (-s sh for the init script, -x for the test); actionlint clean.

Still owed

  • Rig test. The on-hardware key-auth proof in §1.3 predates this and used linux/. The
    directory 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 from linux/, has
    not been run yet. Worth doing before this is in a release.
  • The forum thread should get a line once it ships, since @Kreeblah asked for exactly this.

🤖 Generated with Claude Code

mcfbytes and others added 3 commits September 17, 2026 11:47
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>
@mcfbytes

Copy link
Copy Markdown
Owner Author

Review round: three defects found in migrate_authorized_keys(), all fixed in 34e570a. Two were reproduced first, and each now has a test case that is red against the pre-fix code.

1 (high) — a destination file with no trailing newline lost a key. The merge base was cat "$AUTHKEYS", byte for byte, while what gets appended to it is grep output and always newline-terminated. Notepad writes no final newline, and putting the key on the card from Windows is the workflow the FAQ describes, so the existing config/ key and the first migrated one were spliced into one invalid line:

before: config = "…AAAA2222 two@laptop"   (no final NL)
        linux  = "…AAAA1111 one@pc\n"
after:  config = "…AAAA2222 two@laptopssh-ed25519 AAAA1111 one@pc"

The mv had already landed, so verification correctly failed and kept the legacy file — but the pre-existing key was gone. The one case where copy-verify-delete did not protect the file being merged into. The base is now $dest_lines, newline-terminated by construction. Case 15.

2 (medium) — an unwritable /run read as "this file holds no keys", which is the single branch that deletes. Neither grep 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. The 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.

filter() itself needed an 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. Cases 5 and 12 went red and caught it.

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; start() re-checked end to end in the chroot (migrating boot passes no -o AuthorizedKeysFile, failing boot passes both FAT paths); sshd -T unchanged; shellcheck/dash/bash parse clean.

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>
@mcfbytes
mcfbytes merged commit 912a293 into master Sep 18, 2026
4 checks passed
@mcfbytes
mcfbytes deleted the fix/authorized-keys-standard-location branch September 18, 2026 01:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Standardize authorized_keys on /media/fat/config, the location security_fixes.sh already uses

1 participant