From 4847ce293d73f7e7ebdb96ba3869065e82ef98f7 Mon Sep 17 00:00:00 2001 From: Dillard Blom Date: Wed, 28 Oct 2026 01:41:58 +0100 Subject: [PATCH] SFTP: restore the line breaks of an OpenSSH private key before use (#24) Nextcloud 35 reads keys with phpseclib 3, which refuses an OpenSSH-format key whose line breaks were lost ("Unable to read key"); phpseclib 2 on Nextcloud 34 read it. The key is fixed when used, so stored locations work again without re-entering it. --- CHANGELOG.md | 7 ++++ lib/Backend/ExternalStorageFactory.php | 22 ++++++++++++ .../Backend/ExternalStorageFactoryTest.php | 35 +++++++++++++++++++ 3 files changed, 64 insertions(+) create mode 100644 tests/Unit/Backend/ExternalStorageFactoryTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index c424573..15aa442 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,13 @@ instead carried by the app description, the git tag, the release, and this file. expected form (#22). Before, `backup:user:restore ` only said "is not a recorded user export for this location". +### Fixed +- An SFTP location with an RSA key in OpenSSH format ("BEGIN OPENSSH PRIVATE KEY") whose line + breaks were lost, e.g. turned into spaces on the way in, stopped working after an upgrade to + Nextcloud 35 with "Unable to read key": Nextcloud 35 reads keys with a stricter library. The + line breaks are now restored before the key is used, so the stored location works again without + re-entering it (#24). + ## [1.0.0-rc.6] - 2026-10-24 Sixth release candidate for 1.0.0; the app version is 0.9.12. Fixes from two disaster recovery diff --git a/lib/Backend/ExternalStorageFactory.php b/lib/Backend/ExternalStorageFactory.php index 274be9a..3912183 100644 --- a/lib/Backend/ExternalStorageFactory.php +++ b/lib/Backend/ExternalStorageFactory.php @@ -72,6 +72,10 @@ final class ExternalStorageFactory { throw new BackendException("Auth mechanism $authId does not fit backend $backendId"); } + if (is_string($options['private_key'] ?? null)) { + $options['private_key'] = self::normalizePrivateKey($options['private_key']); + } + $config = new StorageConfig(); $config->setBackend($backend); $config->setAuthMechanism($auth); @@ -86,4 +90,22 @@ final class ExternalStorageFactory { $storage = $backend->wrapStorage($storage); return $auth->wrapStorage($storage); } + + /** + * An OpenSSH-format private key with its line breaks restored. Up to Nextcloud 34 + * (phpseclib 2) a key whose newlines were lost or turned into spaces, e.g. on its way through + * a shell or a form, still loaded; Nextcloud 35 (phpseclib 3) refuses it with "Unable to read + * key" (#24). The key itself is not changed, so it is fixed when it is used, not when stored. + * Other formats (PEM, PKCS#8) still load as they are and are returned unchanged. + */ + public static function normalizePrivateKey(string $key): string { + if (!preg_match('/-----BEGIN OPENSSH PRIVATE KEY-----(.*?)-----END OPENSSH PRIVATE KEY-----/s', $key, $m)) { + return $key; + } + $body = (string)preg_replace('/\s+/', '', $m[1]); + if (!preg_match('#^[A-Za-z0-9+/]+=*$#', $body)) { + return $key; + } + return "-----BEGIN OPENSSH PRIVATE KEY-----\n" . chunk_split($body, 70, "\n") . "-----END OPENSSH PRIVATE KEY-----\n"; + } } diff --git a/tests/Unit/Backend/ExternalStorageFactoryTest.php b/tests/Unit/Backend/ExternalStorageFactoryTest.php new file mode 100644 index 0000000..26e5f73 --- /dev/null +++ b/tests/Unit/Backend/ExternalStorageFactoryTest.php @@ -0,0 +1,35 @@ +assertSame($key, ExternalStorageFactory::normalizePrivateKey($key)); + $this->assertSame($key, ExternalStorageFactory::normalizePrivateKey(str_replace("\n", ' ', $key)), 'newlines as spaces'); + $this->assertSame($key, ExternalStorageFactory::normalizePrivateKey(str_replace("\n", '', $key)), 'no newlines'); + $this->assertSame($key, ExternalStorageFactory::normalizePrivateKey(str_replace("\n", "\r\n", $key)), 'CRLF'); + $this->assertSame($key, ExternalStorageFactory::normalizePrivateKey(rtrim($key)), 'no trailing newline'); + $this->assertSame($key, ExternalStorageFactory::normalizePrivateKey(preg_replace('/^/m', ' ', $key)), 'indented'); + } + + public function testLeavesOtherFormatsAlone(): void { + $pem = "-----BEGIN RSA PRIVATE KEY-----\nProc-Type: 4,ENCRYPTED\nDEK-Info: DES-EDE3-CBC,FF44E0A94036766C\n\nAAAA\n-----END RSA PRIVATE KEY-----\n"; + $this->assertSame($pem, ExternalStorageFactory::normalizePrivateKey($pem)); + $this->assertSame('not a key', ExternalStorageFactory::normalizePrivateKey('not a key')); + $odd = "-----BEGIN OPENSSH PRIVATE KEY-----\\nAAAA\\n-----END OPENSSH PRIVATE KEY-----"; + $this->assertSame($odd, ExternalStorageFactory::normalizePrivateKey($odd), 'not base64 inside: unchanged'); + } +}