Skip to content

OpenSSH for macOS with security keys built in: the relocatable client set, the agent, one aqua package - #33

Merged
apostasie merged 2 commits into
mainfrom
claudio/20261010-openssh
Oct 11, 2026
Merged

apostasie merged 2 commits into
mainfrom
claudio/20261010-openssh

Conversation

@closer-claudio

Copy link
Copy Markdown
Contributor

🎯 Why. Apple's OpenSSH is built without FIDO support, so an sk-ssh-ed25519@openssh.com key is unusable with the system ssh, ssh-agent and ssh-keygen; the farcloser Homebrew formula built one that could, and the dev stack is leaving Homebrew. This repository (farcloser/ssh-agent until its transfer) becomes forkcloser/openssh: the package, on curl's model, with the agent's LaunchAgent that was this repository's whole content folded in. Design settled with @apostasie: no sshd, no ldns, LibreSSL under libfido2, relocation by patch, tag v10.6.1 for 10.6p1.

🛠️ What.

  • pins.yaml: OpenSSH 10.6p1 from OpenBSD's CDN (a custom Renovate datasource on the directory index, since it is not on GitHub), libfido2 1.17.0, libcbor 0.14.0, LibreSSL 4.3.3, each by sha256; build.sh reads them back with limen pins get and verifies before unpacking. CMake from aqua (just do tools add Kitware/CMake, the first commit); the compiler is Xcode's clang on macos-15, the SDK's libedit and zlib.
  • The build: LibreSSL (libcrypto only), libcbor and libfido2 static; OpenSSH --without-openssl --with-security-key-builtin --with-libedit --without-pam; the client set kept (ssh, ssh-agent, ssh-add, ssh-keygen, scp, sftp, ssh-keyscan, the sk and pkcs11 helpers), sshd, ssh-keysign and the man pages dropped; the ed25519-only ssh_config the formula carried; the licenses of everything linked; a binary that links anything but the system fails the build.
  • patches/relocate.patch: OpenSSH bakes its config dir and helper paths at build time; the patch resolves them from the running executable (the directory above bin/ or libexec/, symlinks resolved): the system ssh_config and the Include base, the global known_hosts, the sk and pkcs11 helpers, ssh-keysign, ssh-askpass, and the ssh scp and sftp execute. Twelve files, 59 lines; applied only to the exact sources it was written against (patches/relocate.sha256), so a bump that moves a site fails the build. Nothing shipped is setuid or runs as root, so the executable's directory is the trust domain of ~/.ssh/config.
  • The agent: libexec/farcloser-ssh-agent (execs the ssh-agent beside it, writes ~/.posh_ssh), the plist, bin/ssh-agent-install (points the LaunchAgent at aqua's proxy when there is one, retires Apple's agent for the next login, uninstall reverses it). The copied logging library, the brew lookups and the tap-based test go.
  • ci.yaml on curl's shape (lint, build on macos-15 then the smoke test, tools, gate); release.yaml (v* tag: build, test, attest every asset, publish with checksums.txt; dispatch is the dry run). README: the aqua registry entry, the tag mapping, the agent.

🧪 How.

  • V: just lint 16/16 rules and every lane green locally (links lane excepted: sandbox).
  • V: test-binary.sh green on a package assembled from the local proof build (the same sources, built the same way): version line OpenSSH_10.6p1, without OpenSSL, sk-ed25519 present and no RSA, the shipped config read from the package through a symlink, scp executing the package's own ssh, no build path in the binaries, the agent starting through its launch script under the system PATH and answering ssh-add.
  • U locally, V by this PR's build leg: the full build.sh end to end. cdn.openbsd.org is unreachable from the sandbox, so the release tarball (pinned by the formula's sha256) was never downloaded here; the proof built the git tag's tree with host autoconf. The patch guard hash was taken on that tree's twelve files; the release tarball's copies are expected identical, and the leg says so.
  • V: the proof binaries link only /usr/lib and /System/Library/Frameworks (otool -L).

📝 Notes. Conflicts with #32 on renovate.json by construction (both set the extends line and the forkcloser App identity); whichever lands second rebases. The aqua registry entry in the README is the consumer's to copy into limen's canonical registry once a release exists; farcloser/ssh-agent's successor is this package's ssh-agent-install.

…ibfido2

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: closer-claudio <claudio@farcloser.world>
@closer-claudio
closer-claudio force-pushed the claudio/20261010-openssh branch 2 times, most recently from 587f50d to 6888be4 Compare October 11, 2026 03:38

@closer-claudio closer-claudio left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛑 at 6888be4: the supply chain is exactly as described and the relocation patch is sound; two things stop it, both in the plumbing around the build and the agent, both small.

  • 🛑 build.sh, the libfido2 step: configure fails on the runner with "could not find libcbor" (this head's build leg, CMakeLists.txt:231). libfido2's non-MSVC branch resolves libcbor, libcrypto and zlib through pkg-config (CMakeLists.txt:225-237 of 1.17.0); the -DCBOR_* and -DCRYPTO_* hints only feed include_directories/link_directories afterwards, HAVE_CBOR_H is probed on the default include path at line 97, and ZLIB_FOUND can only ever come from pkg-config. On macos-15 CMake finds Homebrew's pkgconf through its own /opt/homebrew prefix search, not PATH, so the hermetic PATH does not shield this step: that pkgconf knows zlib (Homebrew ships a .pc for the system zlib) and nothing of ${deps}. Two consistent ways out: PKG_CONFIG_PATH="${deps}/lib/pkgconfig" on this step (libcbor and LibreSSL both install .pc files there) with the host's pkg-config stated as a build requirement; or a pinned pkgconf plus a zlib.pc for the SDK's zlib in the repository, pkg-config then ours end to end, the hermetic shape the rest of the script has. Either way the build leg is the proof, and test-binary.sh then runs on the runner for the first time too.
  • 🛑 agent/ssh-agent-install: on an aqua machine the LaunchAgent it writes cannot start. The installer prefers aqua's ssh-agent-launchd proxy as ProgramArguments, and launchd runs it with HOME and the system PATH, none of the shell rc's exports. Reproduced here with aqua 2.64.0 against a global-config package (limen), cwd /: PATH /usr/bin:/bin:/usr/sbin:/sbin → aqua isn't found (the proxy locates aqua through PATH); aqua's bin dir on PATH → code 004 command is not found (no AQUA_GLOBAL_CONFIG, and limen-install's global config is at ~/.config/aqua/aqua.yaml, not aqua's default location); plus AQUA_GLOBAL_CONFIG → code 002 this package isn't allowed; plus AQUA_POLICY_CONFIG → runs. With KeepAlive launchd respawns it until throttled. Fix in the proxy branch: an EnvironmentVariables dict in the plist with PATH (aqua's bin dir ahead of the system's), AQUA_GLOBAL_CONFIG and AQUA_POLICY_CONFIG taken from the installing shell's environment, refusing to install the proxy form when they are unset; or point the plist at the package's own path and accept a re-run per upgrade. test-binary.sh item 6 runs the script directly under the system PATH, which is why it is green.
  • ⚠️ test-binary.sh item 5: strings … | grep -q -E … inside if under pipefail. When grep matches early and closes the pipe, strings dies of SIGPIPE (141), the pipeline's status is 141, the if reads it as "no match", and a binary that does carry a build path passes. The same hazard item 4's comment describes. Drop -q and send grep's output to /dev/null (grep then reads to EOF), or write strings to a file first.
  • 💡 ssh-agent-install: proxy="${AQUA_ROOT_DIR:-${HOME}/.local/share/aquaproj-aqua}/bin/…" skips XDG_DATA_HOME; limen-install's expression is ${AQUA_ROOT_DIR:-${XDG_DATA_HOME:-$HOME/.local/share}/aquaproj-aqua}.
  • 💡 config/ssh_config: KexAlgorithms curve25519-sha256,sntrup761x25519-sha512@openssh.com carries the formula's list; OpenSSH 10 puts mlkem768x25519-sha256 first by default (the NIST hybrid, sntrup761's successor). If the list is deliberate, fine; if it was carried over, the newer hybrid belongs in it.
  • 📝 The proxy form also presumes openssh sits in a global aqua config reachable from launchd. Today limen-install's global config holds limen alone and is rewritten on every run; whichever config is to carry openssh, its path is what the plist has to see.
  • 📝 Conflicts with #32 on renovate.json by construction, as the description says; the second to land rebases. closer-claudio has write on this repository now and main requires an approving review, so the ✅, when it comes, arms auto-merge as elsewhere.
  • 🔍 Checked: the four pins.yaml digests at their sources by download, openssh-10.6p1 from cdn.openbsd.org (reachable from here; the same sha256 Homebrew core and the farcloser formula carry), libfido2 1.17.0 and libcbor v0.14.0 tag archives, libressl-4.3.3 release asset; the patch guard 58ce223b…dfb2 recomputed on the tarball's twelve files, equal; patches/relocate.patch dry-run and real apply on that tree: twelve files, no fuzz, no offsets; no _PATH_SSH_*/SSHDIR site left in the twelve, and none of those macros in another client-side file. The patch itself: reloc_path resolves the executable, strips two components, caches the prefix, xasprintf per call (a few bytes leaked per call in ssh_keysign's three uses, a child process; fine). CMake 4.4.4: five checksums = the release's API digests = cmake-4.4.4-SHA-256.txt. Action pins: checkout v7.0.1, cache v6.1.0, upload-artifact v7.0.2, download-artifact v8.0.2, attest-build-provenance v4.2.2, each tag → the pinned commit. ci.yaml and release.yaml against curl's: one macos-15 leg instead of the matrix, a build/dl cache on pins.yaml, --generate-notes added, the rest the same shape (gate asserts all three, always(), env not interpolated). renovate.json: the forkcloser App identity is the one curl's carries; the preset's pins.yaml manager regex matches each entry's renovate:/extract-version/versioning/version: block by reading. The version guard in build.sh accepts 10.6.1 and 10.6.1.N, refuses 10.7.1, 10.6.12 and a fifth segment. shellcheck clean on the four scripts, no bare golangci-lint added. The tools leg installs CMake for real, the lint lane is green.

@closer-claudio
closer-claudio force-pushed the claudio/20261010-openssh branch 2 times, most recently from 82638f7 to 26dea7f Compare October 11, 2026 03:58
…h the agent

The repository becomes forkcloser/openssh: a package of OpenSSH portable
for macOS arm64 built without OpenSSL, with libfido2 and libcbor linked
statically and LibreSSL's libcrypto under libfido2 only, by Apple's clang
on the macOS runner, released as one tarball per version with GitHub
Artifact Attestations and a checksums.txt, for aqua. Apple's OpenSSH has
no FIDO support, which is the reason it exists; the farcloser Homebrew
formula it replaces built the same thing in a Cellar.

Every source is a pins.yaml entry read back by build.sh and verified by
sha256 before unpacking; OpenSSH comes from OpenBSD's CDN, watched by a
custom Renovate datasource on the directory index since it is not on
GitHub. The build keeps the client set (ssh, ssh-agent, ssh-add,
ssh-keygen, scp, sftp, ssh-keyscan, the sk and pkcs11 helpers), drops the
server, ssh-keysign and the man pages, writes the ed25519-only ssh_config
the formula carried, and refuses a binary that links anything but the
system.

OpenSSH bakes its configuration directory and helper paths at build time.
patches/relocate.patch resolves them from the running executable instead
(the directory above bin/ or libexec/, symlinks resolved): the system
ssh_config and the Include base, the global known_hosts files, the sk and
pkcs11 helpers, ssh-keysign, ssh-askpass, and the ssh that scp and sftp
execute. Twelve files, applied only to the exact sources it was written
against (patches/relocate.sha256), so a bump that moves a site fails the
build. The package then reads its own ssh_config and never Apple's, from
wherever it is unpacked, aqua's versioned package directory included, and
no environment variable is needed. Nothing shipped runs setuid or as root,
so the executable's directory is the same trust domain as ~/.ssh/config.

The agent files of farcloser/ssh-agent move into the package: the launch
script finds the ssh-agent beside it, the installer writes the LaunchAgent
pointing at aqua's proxy when there is one (an upgrade is then the next
start) and retires Apple's agent for the next login. The copied logging
library, the brew lookups and the tap-based test go.

CI is curl's shape: lint, the one build leg on macos-15 followed by the
smoke test, tools, gate. The smoke test unpacks the package elsewhere and
checks the version line, the security-key algorithms and the absence of
RSA, the shipped config read through a symlink, scp executing the
package's ssh, no build path in the binaries, and the agent starting
through its launch script under the system PATH, as launchd runs it. The
release workflow builds on a v* tag, attests and publishes; the tag names
the OpenSSH version with the portable level as the third segment (v10.6.1
for 10.6p1) and a fourth segment for a rebuild.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: closer-claudio <claudio@farcloser.world>
@closer-claudio
closer-claudio force-pushed the claudio/20261010-openssh branch from 26dea7f to 82abaec Compare October 11, 2026 04:11

@closer-claudio closer-claudio left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ at 82abaec: the two blockers are closed the right way, the smoke test now proves both, and nothing in the supply chain moved since the first review.

  • 🔍 Since 6888be4: the libfido2 step takes no pkg-config at all (-DPKG_CONFIG_EXECUTABLE=/usr/bin/false, whose failing --version makes CMake drop it; -DCMAKE_REQUIRED_INCLUDES for the header probes; the libraries by directory; -DZLIB_FOUND=ON -DZLIB_LIBRARIES=z for the SDK's zlib). I reproduced the one point I doubted against the pinned cmake 4.4.4: a preset ZLIB_FOUND survives pkg_search_module when the executable is gone, on a stripped PATH too, so the step no longer depends on what the host carries. -ffile-prefix-map=${work}=. on the three cmake builds and in OpenSSH's CFLAGS (with -O2 kept, since a set CFLAGS replaces configure's default), which is what takes LibreSSL's OPENSSL_assert sites out of the binaries; the inert fixed prefix /opt/forkcloser/openssh for what the patch does not reach, installed through DESTDIR and flattened (mv "${stage}${install_prefix}"/*, rm opt; the var/empty the install creates falls to the existing rm -rf var). The LaunchAgent names the launch script by the package's real path (pwd -P), the README and the installer say to re-run after an upgrade, and the registry entry no longer lists the launch script. Test item 5 reads strings from a file; item 3 follows the new cipher line. config/ssh_config: every name is one this OpenSSH has (kex.h: mlkem768x25519-sha256, sntrup761x25519-sha512@openssh.com, curve25519-sha256; cipher.c: chacha20-poly1305@openssh.com, aes256-ctr; mac.c: hmac-sha2-512-etm@openssh.com; ssh-mldsa-eddsa.c, ssh-ed25519.c, ssh-ed25519-sk.c for the key forms), the ML-DSA hybrids under USE_MLDSA, which does not depend on WITH_OPENSSL; ssh -G in item 3 would refuse an unknown name, so the smoke test is the proof on the built binary. Unchanged since the first review and re-checked as byte-identical in the diff: pins.yaml (four digests verified at source), patches/relocate.patch and its guard, the CMake checksums, the action pins, renovate.json, the workflows. No bare golangci-lint added. Lint green; the build leg's just build then just test at this head is the end-to-end proof the first review asked for.
  • 📝 Conflicts with #32 and now #34 on renovate.json/the aqua files by construction; whichever lands second rebases. closer-claudio has write here and main requires an approving review.
  • 📝 security is red at this head by the lychee links recipe alone: github.com answered 503 on blob links in the canonical AGENTS.md (the storm; farcloser/limen#344 widens the exclusion with the next release). Red is storm only, bypass-safe. Auto-merge is not armed for that reason.

@apostasie
apostasie merged commit fa99104 into main Oct 11, 2026
6 of 8 checks passed
@apostasie
apostasie deleted the claudio/20261010-openssh branch October 11, 2026 04:31
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.

2 participants