SFTP: restore the line breaks of an OpenSSH private key before use (#24)
CI / unit (8.2) (push) Has been cancelled
CI / unit (8.3) (push) Has been cancelled
CI / unit (8.4) (push) Has been cancelled
CI / psalm (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / integration (mariadb, 32) (push) Has been cancelled
CI / integration (mariadb, 33) (push) Has been cancelled
CI / integration (mariadb, 34) (push) Has been cancelled
CI / integration (mariadb, 35) (push) Has been cancelled
CI / integration (pgsql, 32) (push) Has been cancelled
CI / integration (pgsql, 33) (push) Has been cancelled
CI / integration (pgsql, 34) (push) Has been cancelled
CI / integration (pgsql, 35) (push) Has been cancelled
CI / integration (sqlite, 32) (push) Has been cancelled
CI / integration (sqlite, 33) (push) Has been cancelled
CI / integration (sqlite, 34) (push) Has been cancelled
CI / integration (sqlite, 35) (push) Has been cancelled
CI / integration-targets (push) Has been cancelled
CI / integration-user (32) (push) Has been cancelled
CI / integration-user (33) (push) Has been cancelled
CI / integration-user (34) (push) Has been cancelled
CI / integration-user (35) (push) Has been cancelled
CI / integration-full-restore (push) Has been cancelled
CI / unit (8.2) (push) Has been cancelled
CI / unit (8.3) (push) Has been cancelled
CI / unit (8.4) (push) Has been cancelled
CI / psalm (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / integration (mariadb, 32) (push) Has been cancelled
CI / integration (mariadb, 33) (push) Has been cancelled
CI / integration (mariadb, 34) (push) Has been cancelled
CI / integration (mariadb, 35) (push) Has been cancelled
CI / integration (pgsql, 32) (push) Has been cancelled
CI / integration (pgsql, 33) (push) Has been cancelled
CI / integration (pgsql, 34) (push) Has been cancelled
CI / integration (pgsql, 35) (push) Has been cancelled
CI / integration (sqlite, 32) (push) Has been cancelled
CI / integration (sqlite, 33) (push) Has been cancelled
CI / integration (sqlite, 34) (push) Has been cancelled
CI / integration (sqlite, 35) (push) Has been cancelled
CI / integration-targets (push) Has been cancelled
CI / integration-user (32) (push) Has been cancelled
CI / integration-user (33) (push) Has been cancelled
CI / integration-user (34) (push) Has been cancelled
CI / integration-user (35) (push) Has been cancelled
CI / integration-full-restore (push) Has been cancelled
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.
This commit is contained in:
@@ -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 <location> <uid>` 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
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\NgBackup\Tests\Unit\Backend;
|
||||
|
||||
use OCA\NgBackup\Backend\ExternalStorageFactory;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/** ExternalStorageFactory::normalizePrivateKey(): keys that phpseclib 2 read, for phpseclib 3 (#24). */
|
||||
class ExternalStorageFactoryTest extends TestCase {
|
||||
/** An OpenSSH-format key laid out as ssh-keygen writes it; the body is random, not a real key. */
|
||||
private static function key(): string {
|
||||
$body = base64_encode(random_bytes(1000));
|
||||
return "-----BEGIN OPENSSH PRIVATE KEY-----\n" . chunk_split($body, 70, "\n") . "-----END OPENSSH PRIVATE KEY-----\n";
|
||||
}
|
||||
|
||||
public function testRestoresLostLineBreaks(): void {
|
||||
$key = self::key();
|
||||
$this->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');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user