feat(ssh-cert): support RSA temporary keys for FIPS hosts - #175
Conversation
Ed25519 is not FIPS-approved, so ssh-keygen fails on FIPS-mode hosts and
SSH certificates cannot be loaded.
Add the ssh.cert_key_algorithm config key (env var
{PREFIX}SSH_CERT_KEY_ALGORITHM), defaulting to "auto": use "rsa" if
/proc/sys/crypto/fips_enabled is 1, and "ed25519" otherwise. Other values
are passed to ssh-keygen as-is, restricted to [a-z0-9-] because they are
also used in the key filename (id_<algorithm>), so switching algorithms
never reuses a stale key.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The certificate parser supports only RSA and Ed25519 certificates, so reject other values (e.g. ecdsa) instead of failing after signing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Note
Reviewed — No blocking findings · 🔵 2 minor points
🔍 Full review · 4 files reviewed
🔵 Minor points
legacy/src/SshCert/Certifier.php:86— The key/cert filenames are now algorithm-derived, but nothing cleans up the files of the previous algorithm. When a user switches (sets {PREFIX}SSH_CERT_KEY_ALGORITHM=rsa, or moves the session dir onto a FIPS host so "auto" flips), the oldid_ed25519,id_ed25519.pubandid_ed25519-cert.pubstay in the session ssh directory indefinitely. Withssh.add_to_agent: truethe situation is worse: thessh-add -d $privateKeyFilenamecall at the top of doGenerateCertificate now names the new path, so the previously added identity is never removed from the agent and keeps being offered (until its -t lifetime elapses), wasting authentication attempts against MaxAuthTries.legacy/src/SshCert/Certifier.php:269— resolveKeyAlgorithm() throws \InvalidArgumentException for any value other than the exact lowercase strings 'rsa'/'ed25519'/'auto'/''. keyAlgorithm() is called from getExistingCertificate(), which runs on read-only paths that never generate a key —ssh-cert:info, SshDiagnostics after a failed SSH connection, Ssh::getSshArgs. So a mis-cased or padded env value (e.g. {PREFIX}SSH_CERT_KEY_ALGORITHM=RSA) aborts those commands instead of being tolerated; the sibling option ssh.windows_paths handles an invalid value with trigger_error plus a fallback (SshConfig.php:205). Trimming and lower-casing the value, or warning and falling back, would match existing behaviour.
Verification
- The upstream parser Platformsh\Client\SshCert\Metadata explicitly accepts ssh-rsa-cert-v01@openssh.com and reads the RSA e/n fields, so RSA certificates parse.
- No code outside Certifier.php still references the removed constants KEY_ALGORITHM / PRIVATE_KEY_FILENAME, and no Go or PHP code hardcodes the session key path.
- cert_key_algorithm is a second-level scalar in config-defaults.yaml, so Config::applyEnvironmentOverrides automatically maps {PREFIX}SSH_CERT_KEY_ALGORITHM to it, as the integration test relies on.
- cmdFactory.extraEnv and EnvPrefix exist and extraEnv is appended last to the child env, so the rsa subtest's override takes effect.
- The '' and 'auto' inputs both fall through to the FIPS file check, and the new unit test covers both plus the missing-file case.
The diff adds legacy/tests/SshCert/CertifierTest.php (run by the ci.yml legacy-php job via ./scripts/test/unit.sh) and an rsa variant of the ssh-cert:info integration test (ci.yml integration-test job, make integration-test). Nothing covers cleanup/agent state when the algorithm changes between runs.
Review details
- Commit: 38fcc4c
- Model: claude-opus-5
Review 1 of 10 for this pull request · View the full run
Address review feedback: - Normalize ssh.cert_key_algorithm (trim, lowercase), and on an invalid value warn and fall back to "auto" instead of throwing, matching ssh.windows_paths. The value is read on paths that never generate a key, such as ssh-cert:info and SSH diagnostics. - When generating a certificate, remove key files left over from the other algorithm, and remove that key from the SSH agent if ssh.add_to_agent is enabled. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
📋 PR Summary Adds support for generating the temporary SSH certificate key pair with RSA so that SSH certificates work on FIPS-mode hosts, where ed25519 key generation fails. A new Changes
|
There was a problem hiding this comment.
Note
Reviewed — No blocking findings · 🔵 4 minor points
🔁 Incremental · 4 files reviewed
🔵 Minor points
legacy/src/SshCert/Certifier.php:103— The leftover-key cleanup keys off the private key only (if (!file_exists($otherKey)) continue;). When the private key of the other algorithm is absent but its companions are not — e.g. a previous run was interrupted between therename()of the cert and the key, or the user deleted onlyid_ed25519—id_ed25519.pubandid_ed25519-cert.pubsurvive every subsequent run and are never removed. Checking any of the three paths (or unconditionally calling$this->fs->remove()on all three, which is a no-op for missing files) would close it. The same guard also meansssh-add -d $otherKeyis skipped in that state, while in the inverse state (private key present,.pubmissing)ssh-add -dcannot compute the fingerprint and the stale identity stays in the agent.legacy/src/SshCert/Certifier.php:109— The old algorithm's key, public key and certificate are deleted (and dropped from the agent) beforegetSshCertificate()is called, and beforessh-keygenruns. If the API request fails (network error, expired token) or ssh-keygen fails — the common case on the very hosts this feature targets, where the algorithm was just switched — the user ends up with no certificate at all, where previously the ed25519 certificate was still on disk and usable after unsetting{PREFIX}SSH_CERT_KEY_ALGORITHM. Deferring the removal until after the successfulrename()of the new cert/key would keep the fallback intact.legacy/src/SshCert/Certifier.php:283— The invalid-value branch now reports viatrigger_error(..., E_USER_WARNING)instead of throwing.Application::configureErrorReporting()setserror_reporting(E_PARSE | E_ERROR)at default verbosity (legacy/src/Application.php:483), so at normal verbosity the warning is never displayed: a typo such ascert_key_algorithm: rsa2048silently selects ed25519 on a non-FIPS host, and the user gets no hint that their configuration was ignored. Writing the message through$this->stdErrfromkeyAlgorithm()(or keeping the exception) would make it visible.integration-tests/ssh_cert_test.go:63— The first assertion runs with noSSH_CERT_KEY_ALGORITHMin the environment, i.e. withauto, and requiresssh-ed25519-cert-v01@openssh.com. On a host where/proc/sys/crypto/fips_enabledis1— exactly the environment this feature exists for —autoresolves torsaand both this test and thedefaultcase ofTestSSHCertsfail even though the code is behaving correctly. SettingEnvPrefix + "SSH_CERT_KEY_ALGORITHM=ed25519"explicitly for the first run makes the test independent of the host's FIPS state.
Verification
resolveKeyAlgorithmonly ever returns a value fromKEY_ALGORITHMS, so the algorithm-derived filename cannot contain a traversal component such as../rsa.ssh-add -d $otherKeyis issued beforefs->remove(), so the agent removal still has the key file it needs to compute the fingerprint.getExistingCertificate()uses the samekeyAlgorithm()helper asdoGenerateCertificate(), so a switched algorithm reliably reports 'no existing certificate'.HasTempDirTrait::tearDown()guards withempty(), which is safe for the uninitialized typed$tempDirin the test that never callstempDirSetUp().Filesystem::remove()delegates to Symfony'sremove(), which is a no-op for the.pub/-cert.pubpaths that do not exist.
The diff adds legacy/tests/SshCert/CertifierTest.php (run by the legacy-php CI job via ./scripts/test/unit.sh) covering algorithm resolution including trimming/case and the new warning fallback, plus TestSSHCertAlgorithmSwitch and an rsa case in integration-tests/ssh_cert_test.go (the integration-test job). Nothing covers the failure path where cert generation aborts after the old algorithm's files have been deleted.
Review 2 of 10 for this pull request · View the full run
Address review feedback: - Remove the other algorithm's key files only after the new certificate and key are in place, so a failed request keeps the old ones. Remove all three files unconditionally, so partial leftovers are cleaned too. - Print the invalid ssh.cert_key_algorithm warning to stderr, since PHP warnings are hidden at default verbosity. The resolved algorithm is memoized so the warning is shown once. - Set the algorithm explicitly in integration tests, so they pass on FIPS-mode hosts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Note
Reviewed — No blocking findings · 🔵 1 minor point
🔁 Incremental · 4 files reviewed
🔵 Minor point
legacy/src/SshCert/Certifier.php:143— The cleanup loop deletes the other algorithm's private key, public key and certificate from the shared sessionsshdirectory unconditionally on every successful generation, and this deletion is only serialised against otherdoGenerateCertificate()runs (thessh-cert--<session>lock), not against readers. Two concurrently-running CLI processes in the same session but with different{PREFIX}SSH_CERT_KEY_ALGORITHMvalues (e.g. one shell exportingrsa, another left at the ed25519 default) therefore delete each other's key material: a process that has just resolvedid_ed25519-cert.pubviagetExistingCertificate()and is about to hand it tossh -ifinds the file gone, and the SSH connection fails with a missing identity file.
Verification
- The stale-algorithm cleanup now runs only after both
rename()calls succeed, so a failed ssh-keygen or API call leaves the previous algorithm's cert usable. keyAlgorithm()memoises through the typed-but-uninitialised$keyAlgorithmproperty viaisset(), so the invalid-value warning is printed at most once per process.- The new
InvalidArgumentExceptionfromresolveKeyAlgorithm()has exactly one non-test caller,keyAlgorithm(), which catches it and falls back toauto. - Both integration tests now export
SSH_CERT_KEY_ALGORITHMexplicitly, so neither asserts an outcome that depends on the host's/proc/sys/crypto/fips_enabled. ssh-add -dfor the superseded algorithm runs beforefs->remove()and before the new key is added to the agent, so the agent is not left holding a deleted identity.
legacy/tests/SshCert/CertifierTest.php covers resolveKeyAlgorithm (valid, empty, whitespace/case and rejected values) and runs in the legacy-php CI job via ./scripts/test/unit.sh; the ed25519/rsa cases of TestSSHCerts and TestSSHCertAlgorithmSwitch run in the integration-test job via make integration-test. Nothing exercises the warn-and-fall-back path in keyAlgorithm() end to end, nor the agent-removal branch (ssh.add_to_agent stays false in the tests).
Review 3 of 10 for this pull request · View the full run
Deleting the other algorithm's key files breaks concurrent CLI processes in the same session that use a different algorithm, and makes them regenerate keys in turn. Leftover files are harmless: filenames are algorithm-specific, and agent identities expire with the certificate. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Note
Reviewed — No blocking findings · 🔵 1 minor point
🔁 Incremental · 2 files reviewed
🔵 Minor point
legacy/src/SshCert/Certifier.php:142— Deleting the stale-algorithm cleanup means that whenssh.cert_key_algorithmchanges (e.g. a user setsrsaafter having used the defaulted25519, or theautoFIPS probe flips), the previous algorithm'sid_ed25519,id_ed25519.pubandid_ed25519-cert.pubstay in the sessionsshdirectory: nothing else in the code path touches files for an algorithm other than the current one (doGenerateCertificate()andgetExistingCertificate()both build the path fromkeyAlgorithm()only, andApi::logout()is the sole thing that wipes the directory). Withssh.add_to_agentenabled the old private key plus its certificate also remain loaded in the ssh-agent, since the startupssh-add -dis issued only for the current algorithm's path; the agent keeps offering that identity until its-tlifetime expires. Result: usable credentials linger on disk for the rest of the session and an extra identity is offered on every SSH connection (which can eat into the server's MaxAuthTries when other keys are present).
Verification
- Each table case builds its own cmdFactory, so
f.homedefaults to a fresht.TempDir()per subtest and the two algorithms cannot collide. - The
filename/keyTypeassertions are algorithm-specific:id_rsa-cert.pubcannot match the ed25519 run and vice versa. - Removing the cleanup loop leaves
self::KEY_ALGORITHMSstill referenced byresolveKeyAlgorithm(), so no unused-constant or undefined-symbol breakage. - The certificate returned after the rename block is unchanged by the deletion;
$certificate = new Certificate(...)still runs on both the regenerate and reuse paths.
Verified by the integration-test job (make integration-test), which now runs TestSSHCerts as an ed25519/rsa table, and by the PHPUnit job (./scripts/test/unit.sh) covering CertifierTest::resolveKeyAlgorithm; this push deletes TestSSHCertAlgorithmSwitch, so nothing now covers switching algorithms within one session/home directory.
Review 4 of 10 for this pull request · View the full run
|
Keeping the previous algorithm's key files as they are, without cleanup. This is intentional:
Switching algorithms is rare, so a bounded leftover is preferable to breaking concurrent SSH connections. 🤖 Generated with Claude Code |
The temporary key pair for SSH certificates was always generated with
ssh-keygen -t ed25519. Ed25519 is not FIPS-approved, so key generation fails on FIPS-mode hosts and SSH certificates can't be used there.ssh.cert_key_algorithm(env var{PREFIX}SSH_CERT_KEY_ALGORITHM), defaultauto.autousesrsaif/proc/sys/crypto/fips_enabledis1, anded25519otherwise.rsaanded25519can be set explicitly (case-insensitive). Other values print a warning and are treated asauto, because the certificate parser only supports these two.id_rsa,id_ed25519), so switching algorithms never reuses a stale key.Tests: a PHP unit test for the algorithm selection and an
rsavariant of thessh-cert:infointegration test.🤖 Generated with Claude Code