From 7dc1eeb73cf29fc47d7fbc467b671009f857a228 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?lollipopkit=F0=9F=8F=B3=EF=B8=8F=E2=80=8D=E2=9A=A7?= =?UTF-8?q?=EF=B8=8F?= <10864310+lollipopkit@users.noreply.github.com> Date: Sun, 23 Aug 2026 22:50:32 +0800 Subject: [PATCH 01/11] feat(key): generate SSH key pairs in the app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ed25519, ECDSA P-256 and RSA 2048/4096, on another isolate because RSA is a search for primes — about a second for 4096 bits on a desktop. dartssh2 does the OpenSSH serialisation, including the encrypted form it just learned to write, so nothing here hand-rolls a container format. The tests run ssh-keygen against what was generated, for every algorithm and both with and without a passphrase. Every mistake worth making here is invisible to the app alone — it would write a key, read it back, agree with itself, and hand a server a public key that does not match the private one. `iqmp` is the clearest: it is q's inverse mod p, the two are interchangeable to any round trip through dartssh2, and OpenSSH rejects the swapped form. Adds pointycastle as a direct dependency. It was already here through dartssh2; naming it is what makes the import legal. --- lib/core/utils/ssh_keygen.dart | 182 +++++++++++++++++++++++++++++++++ packages/dartssh2 | 2 +- packages/fl_lib | 2 +- pubspec.lock | 2 +- pubspec.yaml | 4 + test/ssh_keygen_test.dart | 166 ++++++++++++++++++++++++++++++ 6 files changed, 355 insertions(+), 3 deletions(-) create mode 100644 lib/core/utils/ssh_keygen.dart create mode 100644 test/ssh_keygen_test.dart diff --git a/lib/core/utils/ssh_keygen.dart b/lib/core/utils/ssh_keygen.dart new file mode 100644 index 0000000000..bbe749d954 --- /dev/null +++ b/lib/core/utils/ssh_keygen.dart @@ -0,0 +1,182 @@ +import 'dart:convert'; +import 'dart:math'; +import 'dart:typed_data'; + +import 'package:computer/computer.dart'; +import 'package:dartssh2/dartssh2.dart'; +import 'package:meta/meta.dart'; +import 'package:pinenacl/ed25519.dart' as ed25519; +import 'package:pointycastle/export.dart'; + +/// The algorithms this app will make a key with. +/// +/// Not everything OpenSSH understands: DSA is long dead, and ECDSA P-384 and +/// P-521 are the same trade-off as P-256 with a longer key, which nobody needs +/// a phone to choose between. What is here is one modern default and the two +/// answers to "the server will not take that" — an older RSA-only sshd, and a +/// policy that requires NIST curves. +enum SshKeyAlgorithm { + /// The default, and the right answer unless something refuses it: small + /// keys, fast signatures, and no parameters to get wrong. + ed25519, + ecdsaP256, + rsa2048, + rsa4096; + + /// What the public key calls itself, which is also what goes at the start of + /// an `authorized_keys` line. + String get keyType => switch (this) { + ed25519 => 'ssh-ed25519', + ecdsaP256 => 'ecdsa-sha2-nistp256', + rsa2048 || rsa4096 => 'ssh-rsa', + }; + + /// Roughly how long generating one takes, which is the only reason a person + /// would want to know: RSA searches for primes and the others do not. + bool get isSlow => this == rsa2048 || this == rsa4096; +} + +/// A key pair that has just been made, in the two forms it is needed in. +@immutable +class GeneratedSshKey { + const GeneratedSshKey({required this.privatePem, required this.publicLine}); + + /// `OPENSSH PRIVATE KEY`, encrypted when a passphrase was given. + final String privatePem; + + /// One `authorized_keys` line: ` `. + final String publicLine; +} + +/// Makes a key pair. +/// +/// On another isolate, because RSA is a search for primes and takes long enough +/// to drop frames — about a second for 4096 bits on a desktop, and several on a +/// phone. The others are instant and go the same way rather than having two +/// paths. +Future generateSshKey({ + required SshKeyAlgorithm algorithm, + required String comment, + String? passphrase, +}) async { + final result = await Computer.shared.start(generateSshKeyPair, [ + algorithm.name, + comment, + passphrase ?? '', + ]); + return GeneratedSshKey(privatePem: result[0], publicLine: result[1]); +} + +/// The isolate half of [generateSshKey]. +/// +/// Top-level and stringly-typed for the same reason `decryptPem` is: a +/// [Computer] task is a closure sent to another isolate, and what crosses has +/// to be a plain value. +/// +/// [args] : [algorithm name, comment, passphrase — empty for none] +/// Returns: [private PEM, public key line] +List generateSshKeyPair(List args) { + final algorithm = SshKeyAlgorithm.values.byName(args[0]); + final comment = args[1]; + final passphrase = args[2]; + + final pair = switch (algorithm) { + SshKeyAlgorithm.ed25519 => _ed25519(comment), + SshKeyAlgorithm.ecdsaP256 => _ecdsaP256(comment), + SshKeyAlgorithm.rsa2048 => _rsa(2048, comment), + SshKeyAlgorithm.rsa4096 => _rsa(4096, comment), + }; + + return [ + pair.toPem(passphrase: passphrase.isEmpty ? null : passphrase), + publicKeyLine(pair, comment), + ]; +} + +/// One `authorized_keys` line for [pair]. +/// +/// The type is read back out of the encoded public key rather than taken from +/// the algorithm that was asked for, so the line cannot disagree with the bytes +/// beside it — and an RSA pair is the case that would: it signs as +/// `rsa-sha2-256` while its public key is still `ssh-rsa`. +@visibleForTesting +String publicKeyLine(OpenSSHKeyPair pair, String comment) { + final blob = pair.toPublicKey().encode(); + final line = '${SSHHostKey.getType(blob)} ${base64.encode(blob)}'; + final trimmed = comment.trim(); + return trimmed.isEmpty ? line : '$line $trimmed'; +} + +OpenSSHEd25519KeyPair _ed25519(String comment) { + final signing = ed25519.SigningKey.generate(); + // 64 bytes — the seed followed by the public key — which is the form the + // OpenSSH format stores and the form `sign` reads back. + return OpenSSHEd25519KeyPair( + Uint8List.fromList(signing.verifyKey.asTypedList), + Uint8List.fromList(signing.asTypedList), + comment, + ); +} + +OpenSSHEcdsaKeyPair _ecdsaP256(String comment) { + final generator = ECKeyGenerator() + ..init( + ParametersWithRandom( + ECKeyGeneratorParameters(ECCurve_secp256r1()), + _seededRandom(), + ), + ); + final pair = generator.generateKeyPair(); + final public = pair.publicKey; + final private = pair.privateKey; + return OpenSSHEcdsaKeyPair( + 'nistp256', + // Uncompressed: `0x04 || X || Y`, which is the only encoding the SSH wire + // format uses for a point. + public.Q!.getEncoded(false), + private.d!, + comment, + ); +} + +OpenSSHRsaKeyPair _rsa(int bits, String comment) { + final generator = RSAKeyGenerator() + ..init( + ParametersWithRandom( + // 65537, and the 64 is the Miller-Rabin certainty pointycastle's own + // examples use. + RSAKeyGeneratorParameters(BigInt.from(65537), bits, 64), + _seededRandom(), + ), + ); + final pair = generator.generateKeyPair(); + final public = pair.publicKey; + final private = pair.privateKey; + final p = private.p!; + final q = private.q!; + return OpenSSHRsaKeyPair( + public.modulus!, + public.publicExponent!, + private.privateExponent!, + // `iqmp` is q's inverse mod p, in that order. Swapping the two produces a + // key that still round-trips through this app and that ssh-keygen rejects. + q.modInverse(p), + p, + q, + comment, + ); +} + +/// A CSPRNG for pointycastle, seeded from the platform's. +/// +/// Fortuna needs a seed and will happily take a predictable one, which for key +/// material is the whole game. [Random.secure] is the platform generator. +SecureRandom _seededRandom() { + final secure = Random.secure(); + return FortunaRandom() + ..seed( + KeyParameter( + Uint8List.fromList(List.generate(32, (_) => secure.nextInt(256))), + ), + ); +} diff --git a/packages/dartssh2 b/packages/dartssh2 index 9cbb3396d5..c1040ea5bc 160000 --- a/packages/dartssh2 +++ b/packages/dartssh2 @@ -1 +1 @@ -Subproject commit 9cbb3396d548c03e0bfa562dadb11e07b19f8e96 +Subproject commit c1040ea5bc37f9a59f18bc4f8a291a12c9dbe151 diff --git a/packages/fl_lib b/packages/fl_lib index bb04c4748f..1799e99c7f 160000 --- a/packages/fl_lib +++ b/packages/fl_lib @@ -1 +1 @@ -Subproject commit bb04c4748f8957e7fce19595dd672361ff05bfbb +Subproject commit 1799e99c7f0e102b833d81ad04c5a11c6b17e6f6 diff --git a/pubspec.lock b/pubspec.lock index fe4e7b9c38..557e7e90b7 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1281,7 +1281,7 @@ packages: source: hosted version: "2.1.8" pointycastle: - dependency: transitive + dependency: "direct main" description: name: pointycastle sha256: "92aa3841d083cc4b0f4709b5c74fd6409a3e6ba833ffc7dc6a8fee096366acf5" diff --git a/pubspec.yaml b/pubspec.yaml index f2a62bece5..44c0de4e15 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -24,6 +24,10 @@ dependencies: crypto: ^3.0.0 dio: ^5.2.1 pinenacl: ^0.6.0 + # ECDSA and RSA key generation for the in-app keygen. Ed25519 comes from + # pinenacl; dartssh2 does the OpenSSH serialisation for all three. Already + # here transitively through dartssh2 — named so the import is legal. + pointycastle: ^4.0.0 dynamic_color: ^1.6.6 equatable: ^2.1.0 easy_isolate: ^1.3.0 diff --git a/test/ssh_keygen_test.dart b/test/ssh_keygen_test.dart new file mode 100644 index 0000000000..c77680bbf2 --- /dev/null +++ b/test/ssh_keygen_test.dart @@ -0,0 +1,166 @@ +import 'dart:io'; + +import 'package:dartssh2/dartssh2.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:server_box/core/utils/ssh_keygen.dart'; + +/// Generating a key pair in the app. +/// +/// The tests that matter run `ssh-keygen` against what was generated. Every +/// mistake worth making here is invisible to this app on its own: it would +/// write a key, read it back, agree with itself, and hand the server a public +/// key that does not match the private one. `iqmp` is the clearest case — it is +/// q's inverse mod p, the two are interchangeable as far as any round trip +/// through dartssh2 is concerned, and OpenSSH rejects the swapped form. +void main() { + const passphrase = 'a passphrase with spaces'; + + /// The generator's isolate half, called directly: `Computer` is the wrapper, + /// and a test does not need another isolate to check bytes. + GeneratedSshKey generate( + SshKeyAlgorithm algorithm, { + String comment = 'serverbox', + String pass = '', + }) { + final result = generateSshKeyPair([algorithm.name, comment, pass]); + return GeneratedSshKey(privatePem: result[0], publicLine: result[1]); + } + + final sshKeygen = _whichSshKeygen(); + + group('interop with ssh-keygen', () { + for (final algorithm in SshKeyAlgorithm.values) { + for (final encrypted in [false, true]) { + final label = encrypted ? 'with a passphrase' : 'unencrypted'; + test('${algorithm.name} $label', () { + final key = generate( + algorithm, + pass: encrypted ? passphrase : '', + ); + final dir = Directory.systemTemp.createTempSync('sb-keygen-'); + addTearDown(() => dir.deleteSync(recursive: true)); + final file = File('${dir.path}/id') + ..writeAsStringSync(key.privatePem); + // ssh-keygen refuses a key the rest of the world can read. + Process.runSync('chmod', ['600', file.path]); + + final result = Process.runSync(sshKeygen!, [ + '-y', + '-P', + encrypted ? passphrase : '', + '-f', + file.path, + ]); + expect( + result.exitCode, + 0, + reason: 'ssh-keygen rejected it: ${result.stderr}', + ); + + // Field by field: `-y` also prints the comment it read out of the + // private key, so this checks that the comment survived being + // written — including through encryption, where it is inside the + // part that gets encrypted. + final ours = key.publicLine.split(' '); + final theirs = (result.stdout as String).trim().split(' '); + expect(theirs[0], ours[0]); + expect( + theirs[1], + ours[1], + reason: 'the public key ssh-keygen derived is not the one we ' + 'would hand a server', + ); + expect(theirs.skip(2).join(' '), ours.skip(2).join(' ')); + }); + } + } + }, skip: sshKeygen == null ? 'ssh-keygen not on PATH' : null); + + group('the private key this app will read back', () { + test('opens without a passphrase when none was set', () { + for (final algorithm in SshKeyAlgorithm.values) { + final key = generate(algorithm); + expect(SSHKeyPair.isEncryptedPem(key.privatePem), isFalse); + expect( + SSHKeyPair.fromPem(key.privatePem), + hasLength(1), + reason: algorithm.name, + ); + } + }); + + test('needs the passphrase when one was set', () { + final key = generate(SshKeyAlgorithm.ed25519, pass: passphrase); + expect(SSHKeyPair.isEncryptedPem(key.privatePem), isTrue); + expect(SSHKeyPair.fromPem(key.privatePem, passphrase), hasLength(1)); + expect( + () => SSHKeyPair.fromPem(key.privatePem, 'wrong'), + throwsA(isA()), + ); + }); + }); + + group('the public key line', () { + test('is type, key and comment', () { + final key = generate(SshKeyAlgorithm.ed25519, comment: 'phone'); + final fields = key.publicLine.split(' '); + expect(fields, hasLength(3)); + expect(fields[0], 'ssh-ed25519'); + expect(fields[2], 'phone'); + }); + + test('an empty comment leaves two fields, not a trailing space', () { + // `authorized_keys` takes everything after the key as the comment, so a + // trailing space is a comment of one space rather than none. + final key = generate(SshKeyAlgorithm.ed25519, comment: ' '); + expect(key.publicLine.split(' '), hasLength(2)); + expect(key.publicLine, isNot(endsWith(' '))); + }); + + test('names the type the key actually is', () { + for (final algorithm in SshKeyAlgorithm.values) { + expect( + generate(algorithm).publicLine.split(' ').first, + algorithm.keyType, + reason: '${algorithm.name} announces the wrong type', + ); + } + }); + }); + + test('two keys of the same kind are different keys', () { + // A generator seeded from something predictable would pass every other + // test here. + final a = generate(SshKeyAlgorithm.ed25519); + final b = generate(SshKeyAlgorithm.ed25519); + expect(a.publicLine, isNot(b.publicLine)); + expect(a.privatePem, isNot(b.privatePem)); + }); + + test('the same key encrypted twice does not repeat its ciphertext', () { + // The salt has to be fresh per encryption. Reusing one would be invisible + // to ssh-keygen and would leak that two files hold the same key. + final pem = generate(SshKeyAlgorithm.ed25519).privatePem; + final pair = SSHKeyPair.fromPem(pem).single as OpenSSHKeyPair; + expect( + pair.toPem(passphrase: passphrase), + isNot(pair.toPem(passphrase: passphrase)), + ); + }); +} + +String? _whichSshKeygen() { + for (final path in const [ + '/usr/bin/ssh-keygen', + '/bin/ssh-keygen', + '/usr/local/bin/ssh-keygen', + ]) { + if (File(path).existsSync()) return path; + } + final result = Process.runSync(Platform.isWindows ? 'where' : 'which', [ + 'ssh-keygen', + ]); + if (result.exitCode != 0) return null; + final found = (result.stdout as String).trim().split('\n').first.trim(); + return found.isEmpty ? null : found; +} From d4e1ecce9ac610b99c7babccf006d96f1596e25a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?lollipopkit=F0=9F=8F=B3=EF=B8=8F=E2=80=8D=E2=9A=A7?= =?UTF-8?q?=EF=B8=8F?= <10864310+lollipopkit@users.noreply.github.com> Date: Sun, 23 Aug 2026 23:15:13 +0800 Subject: [PATCH 02/11] feat(key): generate SSH key pairs, and keep them encrypted at rest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the loop the issue describes: a key can be made here instead of on a PC with ssh-keygen or in another app, and the public key copied straight out to paste into `authorized_keys`. The list page's add button now asks which of the two it is — generate or import — and generating opens a page rather than a dialog, because the public key is the point of having made one and a screen that vanished on save would leave someone with a key they cannot use yet. Ed25519 is the default; ECDSA P-256 and RSA 2048/4096 are there for the servers that refuse it. Private keys are now stored as they were given. Importing an encrypted key used to decrypt it and store the plain form, so every key the app held sat in the clear behind nothing but the database cipher. It stays encrypted now, and is opened when a connection first needs it — once per key per run, held in memory only, dropped when the key is edited or deleted. A passphrase typed at import is checked rather than applied, so a typo is reported on the page where it can be fixed instead of at the next connection as a key that will not open. Two places had to learn about this. `_authenticatedClient` opens the key before building identities, which covers every connection. The transfer path opens it before the credential bundle crosses to its isolate, because that isolate has no screen to ask on and a key still locked when it gets there can only fail with nothing to say why. `compute`, not `Computer.shared`, for both the generating and the opening: that one has to be turned on, and is not in the transfer isolate nor under `flutter test`. The public half of a stored key can now be shown from the edit page. It is derived, never stored — and without it, the moment just after generating would have been the only chance to see it. dartssh2 gains the encrypted-write path this needs; it could read one and not produce one. The tests that matter run ssh-keygen against what was written, for every algorithm and both with and without a passphrase, because everything worth getting wrong here is invisible to a round trip through this app alone. --- lib/core/utils/server.dart | 25 +++ lib/core/utils/ssh_key_unlock.dart | 201 +++++++++++++++++++++ lib/core/utils/ssh_keygen.dart | 19 +- lib/data/model/file/file_ref.dart | 30 ++++ lib/data/model/file/transfer_status.dart | 6 + lib/generated/l10n/l10n.dart | 60 +++++++ lib/generated/l10n/l10n_de.dart | 36 ++++ lib/generated/l10n/l10n_en.dart | 36 ++++ lib/generated/l10n/l10n_es.dart | 36 ++++ lib/generated/l10n/l10n_fr.dart | 36 ++++ lib/generated/l10n/l10n_id.dart | 36 ++++ lib/generated/l10n/l10n_it.dart | 36 ++++ lib/generated/l10n/l10n_ja.dart | 36 ++++ lib/generated/l10n/l10n_ko.dart | 35 ++++ lib/generated/l10n/l10n_nl.dart | 36 ++++ lib/generated/l10n/l10n_pt.dart | 36 ++++ lib/generated/l10n/l10n_ru.dart | 36 ++++ lib/generated/l10n/l10n_tr.dart | 36 ++++ lib/generated/l10n/l10n_uk.dart | 36 ++++ lib/generated/l10n/l10n_zh.dart | 68 +++++++ lib/l10n/app_de.arb | 10 ++ lib/l10n/app_en.arb | 24 +++ lib/l10n/app_es.arb | 10 ++ lib/l10n/app_fr.arb | 10 ++ lib/l10n/app_id.arb | 10 ++ lib/l10n/app_it.arb | 10 ++ lib/l10n/app_ja.arb | 10 ++ lib/l10n/app_ko.arb | 10 ++ lib/l10n/app_nl.arb | 10 ++ lib/l10n/app_pt.arb | 10 ++ lib/l10n/app_ru.arb | 10 ++ lib/l10n/app_tr.arb | 10 ++ lib/l10n/app_uk.arb | 10 ++ lib/l10n/app_zh.arb | 10 ++ lib/l10n/app_zh_tw.arb | 10 ++ lib/view/page/private_key/edit.dart | 71 +++++++- lib/view/page/private_key/generate.dart | 218 +++++++++++++++++++++++ lib/view/page/private_key/list.dart | 36 +++- test/ssh_key_unlock_test.dart | 196 ++++++++++++++++++++ 39 files changed, 1549 insertions(+), 12 deletions(-) create mode 100644 lib/core/utils/ssh_key_unlock.dart create mode 100644 lib/view/page/private_key/generate.dart create mode 100644 test/ssh_key_unlock_test.dart diff --git a/lib/core/utils/server.dart b/lib/core/utils/server.dart index 8a6182f11a..cefa044fe1 100644 --- a/lib/core/utils/server.dart +++ b/lib/core/utils/server.dart @@ -11,6 +11,7 @@ import 'package:server_box/core/extension/context/locale.dart'; import 'package:server_box/core/utils/proxy_command_socket.dart'; import 'package:server_box/core/utils/ssh_auth.dart'; import 'package:server_box/core/utils/ssh_config.dart'; +import 'package:server_box/core/utils/ssh_key_unlock.dart'; import 'package:server_box/data/model/app/error.dart'; import 'package:server_box/data/model/server/server_private_info.dart'; import 'package:server_box/data/model/server/ssh_credential.dart'; @@ -40,6 +41,21 @@ String decryptPem(List args) { enum GenSSHClientStatus { socket, key, pwd } +/// What to call a key when asking the person about it. +/// +/// [keyRef] is an id for a key the store holds and a path for one the user's +/// own `~/.ssh` holds, so the lookup missing is not a failure — the path is +/// already the name. Guarded because this is also reached from the transfer +/// isolate, which has no stores; there the reference is the best that can be +/// said. +String privateKeyDisplayName(String keyRef) { + try { + return Stores.key.fetchOne(keyRef)?.name ?? keyRef; + } catch (_) { + return keyRef; + } +} + String getPrivateKey(String id) { final pki = Stores.key.fetchOne(id); if (pki == null) { @@ -318,6 +334,15 @@ Future _authenticatedClient({ } onStatus?.call(GenSSHClientStatus.key); + // A key stored encrypted is opened here, once per key per run. Nothing + // happens for a key that is not — including one already opened before it was + // handed to another isolate, which is why the transfer path can reach this + // line with no screen to ask on. + privateKey = await PrivateKeyUnlock.open( + privateKey, + cacheKey: keyRef, + keyName: privateKeyDisplayName(keyRef), + ); return SSHClient( socket, username: ssh.user, diff --git a/lib/core/utils/ssh_key_unlock.dart b/lib/core/utils/ssh_key_unlock.dart new file mode 100644 index 0000000000..c9c1e736be --- /dev/null +++ b/lib/core/utils/ssh_key_unlock.dart @@ -0,0 +1,201 @@ +import 'dart:async'; + +import 'package:dartssh2/dartssh2.dart'; +import 'package:fl_lib/fl_lib.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:server_box/core/app_navigator.dart'; +import 'package:server_box/core/extension/context/locale.dart'; +import 'package:server_box/core/utils/server.dart'; +import 'package:server_box/data/model/app/error.dart'; + +/// Asks for a key's passphrase and answers with it, or null if the person +/// declined. +/// +/// A parameter so the policy below can be exercised without a screen — see +/// [PrivateKeyUnlock.promptOverrideForTesting]. +typedef PassphrasePrompt = + Future Function({required String keyName, required bool retry}); + +/// Opening a private key that is stored encrypted. +/// +/// The app stores the key as it was given: one generated here with a +/// passphrase, or imported still encrypted, stays that way in the database. +/// Something has to open it before a connection can use it, and that is here. +/// +/// Once per key per run. The opened form is held in memory only — a passphrase +/// that survived a restart would be protecting nothing — and is dropped when +/// the key is edited or deleted. +abstract final class PrivateKeyUnlock { + /// Opened keys, by the reference that named them. + static final _opened = {}; + + /// The ask in progress for a key, so several servers reaching for the same + /// one at the same moment produce one dialog rather than a stack of them. + static final _inFlight = >{}; + + /// How many times a wrong passphrase may be given before the attempt is + /// abandoned. Not a security limit — the person can start again — it is what + /// stops a loop with no way out when the dialog cannot be shown. + static const maxAttempts = 3; + + @visibleForTesting + static PassphrasePrompt? promptOverrideForTesting; + + /// Whether [pem] cannot be used without a passphrase. + /// + /// False for anything unreadable rather than throwing: whether a key is + /// encrypted is asked in order to decide whether to ask for a passphrase, + /// and a key that cannot be parsed at all fails later, where the error says + /// what it is. + static bool isLocked(String pem) { + try { + return SSHKeyPair.isEncryptedPem(pem); + } catch (_) { + return false; + } + } + + /// [pem] in a form dartssh2 can load, opening it first if it needs opening. + /// + /// [cacheKey] names the key — `SshCredential.keyRef`, so a key referred to by + /// id and the same key referred to by path are not confused for one another. + /// [keyName] is what to call it when asking. + static Future open( + String pem, { + required String cacheKey, + required String keyName, + }) async { + if (!isLocked(pem)) return pem; + + final already = _opened[cacheKey]; + if (already != null) return already; + + final inFlight = _inFlight[cacheKey]; + if (inFlight != null) return inFlight; + + final attempt = _ask(pem, cacheKey: cacheKey, keyName: keyName); + _inFlight[cacheKey] = attempt; + try { + return await attempt; + } finally { + _inFlight.remove(cacheKey); + } + } + + /// The opened form of [pem] if there is one, and [pem] itself when it needs + /// no opening. + /// + /// Null means "locked, and nobody has opened it". For the callers that build + /// credentials for another isolate from a synchronous context and so cannot + /// ask — they report that rather than handing over a key that will fail + /// somewhere with no screen to say so. + static String? openedOrNull(String pem, {required String cacheKey}) { + if (!isLocked(pem)) return pem; + return _opened[cacheKey]; + } + + /// Forgets an opened key, which the next connection will ask for again. + /// + /// Called when the key changes or goes away: the passphrase held here is for + /// the bytes that were there when it was given. + static void forget(String cacheKey) => _opened.remove(cacheKey); + + static void forgetAll() => _opened.clear(); + + @visibleForTesting + static bool isOpened(String cacheKey) => _opened.containsKey(cacheKey); + + static Future _ask( + String pem, { + required String cacheKey, + required String keyName, + }) async { + final prompt = promptOverrideForTesting ?? _showDialog; + + for (var attempt = 0; attempt < maxAttempts; attempt++) { + final passphrase = await prompt(keyName: keyName, retry: attempt > 0); + if (passphrase == null) { + throw SSHErr( + type: SSHErrType.noPrivateKey, + message: l10n.sshKeyLockedFmt(keyName), + ); + } + + try { + // On another isolate: bcrypt_pbkdf is deliberately slow, which is the + // point of it, and 16 rounds is long enough to drop frames. + // + // `compute`, not `Computer.shared`: that one has to be turned on, and + // is not in the transfer isolate — which reaches this file through + // `genClient` — nor under `flutter test`. + final opened = await compute(decryptPem, [pem, passphrase]); + _opened[cacheKey] = opened; + return opened; + } on SSHKeyDecryptError { + // Round again, saying so. Any other failure is not about the + // passphrase and belongs to the caller. + continue; + } + } + + throw SSHErr( + type: SSHErrType.noPrivateKey, + message: l10n.sshKeyLockedFmt(keyName), + ); + } + + static Future _showDialog({ + required String keyName, + required bool retry, + }) async { + final context = AppNavigator.context; + if (context == null || !context.mounted) return null; + + final controller = TextEditingController(); + try { + return await context.showRoundDialog( + title: libL10n.authRequired, + childBuilder: (dialogContext) => Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(dialogContext.l10n.sshKeyUnlockTip(keyName)), + if (retry) ...[ + const SizedBox(height: 8), + Text( + dialogContext.l10n.sshKeyPassphraseWrong, + style: TextStyle( + color: Theme.of(dialogContext).colorScheme.error, + ), + ), + ], + const SizedBox(height: 12), + Input( + controller: controller, + autoFocus: true, + obscureText: true, + label: libL10n.pwd, + icon: Icons.password, + suggestion: false, + onSubmitted: (_) => + Navigator.of(dialogContext).pop(controller.text), + ), + ], + ), + actionsBuilder: (dialogContext) => [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: Text(libL10n.cancel), + ), + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(controller.text), + child: Text(libL10n.ok), + ), + ], + ); + } finally { + controller.dispose(); + } + } +} diff --git a/lib/core/utils/ssh_keygen.dart b/lib/core/utils/ssh_keygen.dart index bbe749d954..76850e31c5 100644 --- a/lib/core/utils/ssh_keygen.dart +++ b/lib/core/utils/ssh_keygen.dart @@ -1,10 +1,8 @@ import 'dart:convert'; import 'dart:math'; -import 'dart:typed_data'; -import 'package:computer/computer.dart'; import 'package:dartssh2/dartssh2.dart'; -import 'package:meta/meta.dart'; +import 'package:flutter/foundation.dart'; import 'package:pinenacl/ed25519.dart' as ed25519; import 'package:pointycastle/export.dart'; @@ -59,7 +57,7 @@ Future generateSshKey({ required String comment, String? passphrase, }) async { - final result = await Computer.shared.start(generateSshKeyPair, [ + final result = await compute(generateSshKeyPair, [ algorithm.name, comment, passphrase ?? '', @@ -69,9 +67,9 @@ Future generateSshKey({ /// The isolate half of [generateSshKey]. /// -/// Top-level and stringly-typed for the same reason `decryptPem` is: a -/// [Computer] task is a closure sent to another isolate, and what crosses has -/// to be a plain value. +/// Top-level and stringly-typed for the same reason `decryptPem` is: what goes +/// to another isolate is sent, not captured, so it has to be a plain value and +/// a plain function. /// /// [args] : [algorithm name, comment, passphrase — empty for none] /// Returns: [private PEM, public key line] @@ -99,8 +97,11 @@ List generateSshKeyPair(List args) { /// the algorithm that was asked for, so the line cannot disagree with the bytes /// beside it — and an RSA pair is the case that would: it signs as /// `rsa-sha2-256` while its public key is still `ssh-rsa`. -@visibleForTesting -String publicKeyLine(OpenSSHKeyPair pair, String comment) { +/// +/// Takes any [SSHKeyPair], not only the OpenSSH ones this file makes: the same +/// line is what a key imported in the older `RSA PRIVATE KEY` form needs, and +/// deriving it is the only way to see the public half of a key the app holds. +String publicKeyLine(SSHKeyPair pair, String comment) { final blob = pair.toPublicKey().encode(); final line = '${SSHHostKey.getType(blob)} ${base64.encode(blob)}'; final trimmed = comment.trim(); diff --git a/lib/data/model/file/file_ref.dart b/lib/data/model/file/file_ref.dart index bfe16fc0be..fa2e51cec2 100644 --- a/lib/data/model/file/file_ref.dart +++ b/lib/data/model/file/file_ref.dart @@ -1,6 +1,7 @@ import 'package:fl_lib/fl_lib.dart'; import 'package:server_box/core/utils/jump_chain.dart'; import 'package:server_box/core/utils/server.dart'; +import 'package:server_box/core/utils/ssh_key_unlock.dart'; import 'package:server_box/data/model/server/connect_credential.dart'; import 'package:server_box/data/model/server/monitor_http_credential.dart'; import 'package:server_box/data/model/server/server_private_info.dart'; @@ -222,5 +223,34 @@ class SshTransferCreds { String? jumpPrivateKey; Map? jumpSpisById; Map? privateKeysByKeyId; + + /// Opens any key in this bundle that is stored encrypted. + /// + /// Not in the constructor, for two reasons that point the same way: asking + /// for a passphrase is a dialog and the constructor is synchronous, and the + /// isolate this bundle is *for* has no screen to ask on. A key that is still + /// locked when it crosses can only fail over there, with nothing to say why. + /// + /// Awaited once, where the transfer starts. A key already opened this run + /// costs nothing here. + Future unlockKeys() async { + final keys = privateKeysByKeyId; + if (keys == null) return; + for (final ref in keys.keys.toList()) { + final pem = keys[ref]!; + if (!PrivateKeyUnlock.isLocked(pem)) continue; + keys[ref] = await PrivateKeyUnlock.open( + pem, + cacheKey: ref, + keyName: privateKeyDisplayName(ref), + ); + } + // The two hold the same string for the main server's key, so the copy + // outside the map has to be moved along with it. + final mainRef = spi.ssh?.keyRef; + if (mainRef != null) privateKey = keys[mainRef] ?? privateKey; + final jumpRef = jumpSpi?.ssh?.keyRef; + if (jumpRef != null) jumpPrivateKey = keys[jumpRef] ?? jumpPrivateKey; + } Map? knownHostFingerprints; } diff --git a/lib/data/model/file/transfer_status.dart b/lib/data/model/file/transfer_status.dart index 24ce9dc516..ec3ca33da9 100644 --- a/lib/data/model/file/transfer_status.dart +++ b/lib/data/model/file/transfer_status.dart @@ -130,6 +130,12 @@ class FileTransferStatus { Future _initWorker() async { try { + // Before the bundle crosses: the isolate has no screen to ask a + // passphrase on, so a key stored encrypted has to be opened on this side + // or it fails over there with nothing to say why. + for (final ref in [job.from, job.to]) { + if (ref is SftpFileRef) await ref.creds.unlockKeys(); + } await worker!.init(); } catch (e, s) { Loggers.app.warning('Failed to initialize the transfer worker', e, s); diff --git a/lib/generated/l10n/l10n.dart b/lib/generated/l10n/l10n.dart index d0ff0b3e1c..ee2f4e0c6c 100644 --- a/lib/generated/l10n/l10n.dart +++ b/lib/generated/l10n/l10n.dart @@ -1119,6 +1119,66 @@ abstract class AppLocalizations { /// **'Show hidden files'** String get showHiddenFiles; + /// No description provided for @sshKeyAlgorithm. + /// + /// In en, this message translates to: + /// **'Algorithm'** + String get sshKeyAlgorithm; + + /// No description provided for @sshKeyComment. + /// + /// In en, this message translates to: + /// **'Comment'** + String get sshKeyComment; + + /// No description provided for @sshKeyGenerate. + /// + /// In en, this message translates to: + /// **'Generate key pair'** + String get sshKeyGenerate; + + /// No description provided for @sshKeyGenerating. + /// + /// In en, this message translates to: + /// **'Generating…'** + String get sshKeyGenerating; + + /// No description provided for @sshKeyLockedFmt. + /// + /// In en, this message translates to: + /// **'The private key [{name}] was not unlocked.'** + String sshKeyLockedFmt(String name); + + /// No description provided for @sshKeyPassphraseTip. + /// + /// In en, this message translates to: + /// **'Optional. A key with a passphrase is stored encrypted, and you are asked for it the first time a connection uses the key.'** + String get sshKeyPassphraseTip; + + /// No description provided for @sshKeyPassphraseWrong. + /// + /// In en, this message translates to: + /// **'Wrong passphrase.'** + String get sshKeyPassphraseWrong; + + /// No description provided for @sshKeyPublicKey. + /// + /// In en, this message translates to: + /// **'Public key'** + String get sshKeyPublicKey; + + /// No description provided for @sshKeyPublicKeyTip. + /// + /// In en, this message translates to: + /// **'Append this line to ~/.ssh/authorized_keys on the server.'** + String get sshKeyPublicKeyTip; + + /// No description provided for @sshKeyUnlockTip. + /// + /// In en, this message translates to: + /// **'Enter the passphrase for the private key [{name}].'** + String sshKeyUnlockTip(String name); + /// No description provided for @unused. /// /// In en, this message translates to: diff --git a/lib/generated/l10n/l10n_de.dart b/lib/generated/l10n/l10n_de.dart index c16bb166c0..6315f5015e 100644 --- a/lib/generated/l10n/l10n_de.dart +++ b/lib/generated/l10n/l10n_de.dart @@ -577,6 +577,42 @@ class AppLocalizationsDe extends AppLocalizations { @override String get showHiddenFiles => 'Versteckte Dateien anzeigen'; + @override + String get sshKeyAlgorithm => 'Algorithmus'; + + @override + String get sshKeyComment => 'Kommentar'; + + @override + String get sshKeyGenerate => 'Schlüsselpaar erzeugen'; + + @override + String get sshKeyGenerating => 'Wird erzeugt…'; + + @override + String sshKeyLockedFmt(String name) { + return 'Der private Schlüssel [$name] wurde nicht entsperrt.'; + } + + @override + String get sshKeyPassphraseTip => + 'Optional. Ein Schlüssel mit Passphrase wird verschlüsselt gespeichert und beim ersten Verbinden abgefragt.'; + + @override + String get sshKeyPassphraseWrong => 'Falsche Passphrase.'; + + @override + String get sshKeyPublicKey => 'Öffentlicher Schlüssel'; + + @override + String get sshKeyPublicKeyTip => + 'Diese Zeile an ~/.ssh/authorized_keys auf dem Server anhängen.'; + + @override + String sshKeyUnlockTip(String name) { + return 'Passphrase für den privaten Schlüssel [$name] eingeben.'; + } + @override String get unused => 'Ungenutzt'; diff --git a/lib/generated/l10n/l10n_en.dart b/lib/generated/l10n/l10n_en.dart index 6eca41fb7e..b543608607 100644 --- a/lib/generated/l10n/l10n_en.dart +++ b/lib/generated/l10n/l10n_en.dart @@ -568,6 +568,42 @@ class AppLocalizationsEn extends AppLocalizations { @override String get showHiddenFiles => 'Show hidden files'; + @override + String get sshKeyAlgorithm => 'Algorithm'; + + @override + String get sshKeyComment => 'Comment'; + + @override + String get sshKeyGenerate => 'Generate key pair'; + + @override + String get sshKeyGenerating => 'Generating…'; + + @override + String sshKeyLockedFmt(String name) { + return 'The private key [$name] was not unlocked.'; + } + + @override + String get sshKeyPassphraseTip => + 'Optional. A key with a passphrase is stored encrypted, and you are asked for it the first time a connection uses the key.'; + + @override + String get sshKeyPassphraseWrong => 'Wrong passphrase.'; + + @override + String get sshKeyPublicKey => 'Public key'; + + @override + String get sshKeyPublicKeyTip => + 'Append this line to ~/.ssh/authorized_keys on the server.'; + + @override + String sshKeyUnlockTip(String name) { + return 'Enter the passphrase for the private key [$name].'; + } + @override String get unused => 'Unused'; diff --git a/lib/generated/l10n/l10n_es.dart b/lib/generated/l10n/l10n_es.dart index 49194e5723..f3e8ec7a29 100644 --- a/lib/generated/l10n/l10n_es.dart +++ b/lib/generated/l10n/l10n_es.dart @@ -581,6 +581,42 @@ class AppLocalizationsEs extends AppLocalizations { @override String get showHiddenFiles => 'Mostrar archivos ocultos'; + @override + String get sshKeyAlgorithm => 'Algoritmo'; + + @override + String get sshKeyComment => 'Comentario'; + + @override + String get sshKeyGenerate => 'Generar par de claves'; + + @override + String get sshKeyGenerating => 'Generando…'; + + @override + String sshKeyLockedFmt(String name) { + return 'La clave privada [$name] no se ha desbloqueado.'; + } + + @override + String get sshKeyPassphraseTip => + 'Opcional. Una clave con frase de contraseña se guarda cifrada y se pide la primera vez que una conexión la usa.'; + + @override + String get sshKeyPassphraseWrong => 'Frase de contraseña incorrecta.'; + + @override + String get sshKeyPublicKey => 'Clave pública'; + + @override + String get sshKeyPublicKeyTip => + 'Añade esta línea a ~/.ssh/authorized_keys en el servidor.'; + + @override + String sshKeyUnlockTip(String name) { + return 'Introduce la frase de contraseña de la clave privada [$name].'; + } + @override String get unused => 'Sin usar'; diff --git a/lib/generated/l10n/l10n_fr.dart b/lib/generated/l10n/l10n_fr.dart index 21162d0ef5..5b81c01600 100644 --- a/lib/generated/l10n/l10n_fr.dart +++ b/lib/generated/l10n/l10n_fr.dart @@ -583,6 +583,42 @@ class AppLocalizationsFr extends AppLocalizations { @override String get showHiddenFiles => 'Afficher les fichiers cachés'; + @override + String get sshKeyAlgorithm => 'Algorithme'; + + @override + String get sshKeyComment => 'Commentaire'; + + @override + String get sshKeyGenerate => 'Générer une paire de clés'; + + @override + String get sshKeyGenerating => 'Génération…'; + + @override + String sshKeyLockedFmt(String name) { + return 'La clé privée [$name] n\'a pas été déverrouillée.'; + } + + @override + String get sshKeyPassphraseTip => + 'Facultatif. Une clé avec phrase secrète est stockée chiffrée, et celle-ci est demandée à la première connexion qui l\'utilise.'; + + @override + String get sshKeyPassphraseWrong => 'Phrase secrète incorrecte.'; + + @override + String get sshKeyPublicKey => 'Clé publique'; + + @override + String get sshKeyPublicKeyTip => + 'Ajoutez cette ligne à ~/.ssh/authorized_keys sur le serveur.'; + + @override + String sshKeyUnlockTip(String name) { + return 'Saisissez la phrase secrète de la clé privée [$name].'; + } + @override String get unused => 'Inutilisé'; diff --git a/lib/generated/l10n/l10n_id.dart b/lib/generated/l10n/l10n_id.dart index afef32adce..1b1ef7e83e 100644 --- a/lib/generated/l10n/l10n_id.dart +++ b/lib/generated/l10n/l10n_id.dart @@ -574,6 +574,42 @@ class AppLocalizationsId extends AppLocalizations { @override String get showHiddenFiles => 'Tampilkan berkas tersembunyi'; + @override + String get sshKeyAlgorithm => 'Algoritme'; + + @override + String get sshKeyComment => 'Komentar'; + + @override + String get sshKeyGenerate => 'Buat pasangan kunci'; + + @override + String get sshKeyGenerating => 'Membuat…'; + + @override + String sshKeyLockedFmt(String name) { + return 'Kunci privat [$name] belum dibuka.'; + } + + @override + String get sshKeyPassphraseTip => + 'Opsional. Kunci dengan frasa sandi disimpan terenkripsi, dan frasa itu diminta saat koneksi pertama memakai kunci ini.'; + + @override + String get sshKeyPassphraseWrong => 'Frasa sandi salah.'; + + @override + String get sshKeyPublicKey => 'Kunci publik'; + + @override + String get sshKeyPublicKeyTip => + 'Tambahkan baris ini ke ~/.ssh/authorized_keys di server.'; + + @override + String sshKeyUnlockTip(String name) { + return 'Masukkan frasa sandi untuk kunci privat [$name].'; + } + @override String get unused => 'Tidak terpakai'; diff --git a/lib/generated/l10n/l10n_it.dart b/lib/generated/l10n/l10n_it.dart index be731c7fe7..5cb9b57191 100644 --- a/lib/generated/l10n/l10n_it.dart +++ b/lib/generated/l10n/l10n_it.dart @@ -580,6 +580,42 @@ class AppLocalizationsIt extends AppLocalizations { @override String get showHiddenFiles => 'Mostra i file nascosti'; + @override + String get sshKeyAlgorithm => 'Algoritmo'; + + @override + String get sshKeyComment => 'Commento'; + + @override + String get sshKeyGenerate => 'Genera coppia di chiavi'; + + @override + String get sshKeyGenerating => 'Generazione…'; + + @override + String sshKeyLockedFmt(String name) { + return 'La chiave privata [$name] non è stata sbloccata.'; + } + + @override + String get sshKeyPassphraseTip => + 'Facoltativo. Una chiave con passphrase viene salvata cifrata e la passphrase è richiesta al primo uso della chiave.'; + + @override + String get sshKeyPassphraseWrong => 'Passphrase errata.'; + + @override + String get sshKeyPublicKey => 'Chiave pubblica'; + + @override + String get sshKeyPublicKeyTip => + 'Aggiungi questa riga a ~/.ssh/authorized_keys sul server.'; + + @override + String sshKeyUnlockTip(String name) { + return 'Inserisci la passphrase della chiave privata [$name].'; + } + @override String get unused => 'Inutilizzato'; diff --git a/lib/generated/l10n/l10n_ja.dart b/lib/generated/l10n/l10n_ja.dart index e59ee1f527..e5c8effc06 100644 --- a/lib/generated/l10n/l10n_ja.dart +++ b/lib/generated/l10n/l10n_ja.dart @@ -543,6 +543,42 @@ class AppLocalizationsJa extends AppLocalizations { @override String get showHiddenFiles => '隠しファイルを表示'; + @override + String get sshKeyAlgorithm => 'アルゴリズム'; + + @override + String get sshKeyComment => 'コメント'; + + @override + String get sshKeyGenerate => '鍵ペアを生成'; + + @override + String get sshKeyGenerating => '生成中…'; + + @override + String sshKeyLockedFmt(String name) { + return '秘密鍵 [$name] のロックが解除されていません。'; + } + + @override + String get sshKeyPassphraseTip => + '任意。パスフレーズを設定すると秘密鍵は暗号化して保存され、接続でこの鍵を最初に使うときに入力を求められます。'; + + @override + String get sshKeyPassphraseWrong => 'パスフレーズが違います。'; + + @override + String get sshKeyPublicKey => '公開鍵'; + + @override + String get sshKeyPublicKeyTip => + 'この行をサーバーの ~/.ssh/authorized_keys に追記してください。'; + + @override + String sshKeyUnlockTip(String name) { + return '秘密鍵 [$name] のパスフレーズを入力してください。'; + } + @override String get unused => '未使用'; diff --git a/lib/generated/l10n/l10n_ko.dart b/lib/generated/l10n/l10n_ko.dart index f8d3f08a6b..ff8e70628b 100644 --- a/lib/generated/l10n/l10n_ko.dart +++ b/lib/generated/l10n/l10n_ko.dart @@ -542,6 +542,41 @@ class AppLocalizationsKo extends AppLocalizations { @override String get showHiddenFiles => '숨김 파일 표시'; + @override + String get sshKeyAlgorithm => '알고리즘'; + + @override + String get sshKeyComment => '설명'; + + @override + String get sshKeyGenerate => '키 쌍 생성'; + + @override + String get sshKeyGenerating => '생성 중…'; + + @override + String sshKeyLockedFmt(String name) { + return '개인 키 [$name]의 잠금이 해제되지 않았습니다.'; + } + + @override + String get sshKeyPassphraseTip => + '선택 사항. 암호를 설정하면 개인 키가 암호화되어 저장되며, 연결에서 이 키를 처음 사용할 때 입력을 요구합니다.'; + + @override + String get sshKeyPassphraseWrong => '암호가 올바르지 않습니다.'; + + @override + String get sshKeyPublicKey => '공개 키'; + + @override + String get sshKeyPublicKeyTip => '이 줄을 서버의 ~/.ssh/authorized_keys에 추가하세요.'; + + @override + String sshKeyUnlockTip(String name) { + return '개인 키 [$name]의 암호를 입력하세요.'; + } + @override String get unused => '미사용'; diff --git a/lib/generated/l10n/l10n_nl.dart b/lib/generated/l10n/l10n_nl.dart index 35a2dce409..6689e16a71 100644 --- a/lib/generated/l10n/l10n_nl.dart +++ b/lib/generated/l10n/l10n_nl.dart @@ -576,6 +576,42 @@ class AppLocalizationsNl extends AppLocalizations { @override String get showHiddenFiles => 'Verborgen bestanden tonen'; + @override + String get sshKeyAlgorithm => 'Algoritme'; + + @override + String get sshKeyComment => 'Opmerking'; + + @override + String get sshKeyGenerate => 'Sleutelpaar genereren'; + + @override + String get sshKeyGenerating => 'Bezig met genereren…'; + + @override + String sshKeyLockedFmt(String name) { + return 'De privésleutel [$name] is niet ontgrendeld.'; + } + + @override + String get sshKeyPassphraseTip => + 'Optioneel. Een sleutel met wachtwoordzin wordt versleuteld opgeslagen en wordt gevraagd zodra een verbinding de sleutel voor het eerst gebruikt.'; + + @override + String get sshKeyPassphraseWrong => 'Onjuiste wachtwoordzin.'; + + @override + String get sshKeyPublicKey => 'Publieke sleutel'; + + @override + String get sshKeyPublicKeyTip => + 'Voeg deze regel toe aan ~/.ssh/authorized_keys op de server.'; + + @override + String sshKeyUnlockTip(String name) { + return 'Voer de wachtwoordzin voor de privésleutel [$name] in.'; + } + @override String get unused => 'Ongebruikt'; diff --git a/lib/generated/l10n/l10n_pt.dart b/lib/generated/l10n/l10n_pt.dart index 6453c8be98..96fb45db11 100644 --- a/lib/generated/l10n/l10n_pt.dart +++ b/lib/generated/l10n/l10n_pt.dart @@ -576,6 +576,42 @@ class AppLocalizationsPt extends AppLocalizations { @override String get showHiddenFiles => 'Mostrar ficheiros ocultos'; + @override + String get sshKeyAlgorithm => 'Algoritmo'; + + @override + String get sshKeyComment => 'Comentário'; + + @override + String get sshKeyGenerate => 'Gerar par de chaves'; + + @override + String get sshKeyGenerating => 'A gerar…'; + + @override + String sshKeyLockedFmt(String name) { + return 'A chave privada [$name] não foi desbloqueada.'; + } + + @override + String get sshKeyPassphraseTip => + 'Opcional. Uma chave com frase-passe é guardada cifrada e esta é pedida na primeira vez que uma ligação a usa.'; + + @override + String get sshKeyPassphraseWrong => 'Frase-passe incorreta.'; + + @override + String get sshKeyPublicKey => 'Chave pública'; + + @override + String get sshKeyPublicKeyTip => + 'Acrescente esta linha a ~/.ssh/authorized_keys no servidor.'; + + @override + String sshKeyUnlockTip(String name) { + return 'Introduza a frase-passe da chave privada [$name].'; + } + @override String get unused => 'Não utilizado'; diff --git a/lib/generated/l10n/l10n_ru.dart b/lib/generated/l10n/l10n_ru.dart index d98beb494a..5b5f3c5d12 100644 --- a/lib/generated/l10n/l10n_ru.dart +++ b/lib/generated/l10n/l10n_ru.dart @@ -579,6 +579,42 @@ class AppLocalizationsRu extends AppLocalizations { @override String get showHiddenFiles => 'Показывать скрытые файлы'; + @override + String get sshKeyAlgorithm => 'Алгоритм'; + + @override + String get sshKeyComment => 'Комментарий'; + + @override + String get sshKeyGenerate => 'Создать пару ключей'; + + @override + String get sshKeyGenerating => 'Создание…'; + + @override + String sshKeyLockedFmt(String name) { + return 'Закрытый ключ [$name] не разблокирован.'; + } + + @override + String get sshKeyPassphraseTip => + 'Необязательно. Ключ с парольной фразой хранится в зашифрованном виде, и она запрашивается при первом использовании ключа.'; + + @override + String get sshKeyPassphraseWrong => 'Неверная парольная фраза.'; + + @override + String get sshKeyPublicKey => 'Открытый ключ'; + + @override + String get sshKeyPublicKeyTip => + 'Добавьте эту строку в ~/.ssh/authorized_keys на сервере.'; + + @override + String sshKeyUnlockTip(String name) { + return 'Введите парольную фразу закрытого ключа [$name].'; + } + @override String get unused => 'Не используется'; diff --git a/lib/generated/l10n/l10n_tr.dart b/lib/generated/l10n/l10n_tr.dart index 8e56d81c37..ff0a970179 100644 --- a/lib/generated/l10n/l10n_tr.dart +++ b/lib/generated/l10n/l10n_tr.dart @@ -575,6 +575,42 @@ class AppLocalizationsTr extends AppLocalizations { @override String get showHiddenFiles => 'Gizli dosyaları göster'; + @override + String get sshKeyAlgorithm => 'Algoritma'; + + @override + String get sshKeyComment => 'Açıklama'; + + @override + String get sshKeyGenerate => 'Anahtar çifti oluştur'; + + @override + String get sshKeyGenerating => 'Oluşturuluyor…'; + + @override + String sshKeyLockedFmt(String name) { + return '[$name] özel anahtarının kilidi açılmadı.'; + } + + @override + String get sshKeyPassphraseTip => + 'İsteğe bağlı. Parola belirlenen anahtar şifreli saklanır ve bir bağlantı anahtarı ilk kez kullandığında parola sorulur.'; + + @override + String get sshKeyPassphraseWrong => 'Parola yanlış.'; + + @override + String get sshKeyPublicKey => 'Genel anahtar'; + + @override + String get sshKeyPublicKeyTip => + 'Bu satırı sunucudaki ~/.ssh/authorized_keys dosyasına ekleyin.'; + + @override + String sshKeyUnlockTip(String name) { + return '[$name] özel anahtarının parolasını girin.'; + } + @override String get unused => 'Kullanılmıyor'; diff --git a/lib/generated/l10n/l10n_uk.dart b/lib/generated/l10n/l10n_uk.dart index 89f7afb69f..340ebacdbd 100644 --- a/lib/generated/l10n/l10n_uk.dart +++ b/lib/generated/l10n/l10n_uk.dart @@ -578,6 +578,42 @@ class AppLocalizationsUk extends AppLocalizations { @override String get showHiddenFiles => 'Показувати приховані файли'; + @override + String get sshKeyAlgorithm => 'Алгоритм'; + + @override + String get sshKeyComment => 'Коментар'; + + @override + String get sshKeyGenerate => 'Створити пару ключів'; + + @override + String get sshKeyGenerating => 'Створення…'; + + @override + String sshKeyLockedFmt(String name) { + return 'Закритий ключ [$name] не розблоковано.'; + } + + @override + String get sshKeyPassphraseTip => + 'Необов\'язково. Ключ із парольною фразою зберігається зашифрованим, і її запитують під час першого використання ключа.'; + + @override + String get sshKeyPassphraseWrong => 'Неправильна парольна фраза.'; + + @override + String get sshKeyPublicKey => 'Відкритий ключ'; + + @override + String get sshKeyPublicKeyTip => + 'Додайте цей рядок до ~/.ssh/authorized_keys на сервері.'; + + @override + String sshKeyUnlockTip(String name) { + return 'Введіть парольну фразу закритого ключа [$name].'; + } + @override String get unused => 'Не використовується'; diff --git a/lib/generated/l10n/l10n_zh.dart b/lib/generated/l10n/l10n_zh.dart index fec08867a5..e8c1be6c8d 100644 --- a/lib/generated/l10n/l10n_zh.dart +++ b/lib/generated/l10n/l10n_zh.dart @@ -532,6 +532,40 @@ class AppLocalizationsZh extends AppLocalizations { @override String get showHiddenFiles => '显示隐藏文件'; + @override + String get sshKeyAlgorithm => '算法'; + + @override + String get sshKeyComment => '备注'; + + @override + String get sshKeyGenerate => '生成密钥对'; + + @override + String get sshKeyGenerating => '生成中…'; + + @override + String sshKeyLockedFmt(String name) { + return '私钥 [$name] 未解锁。'; + } + + @override + String get sshKeyPassphraseTip => '可选。设置口令后,私钥将加密存储,每次连接首次使用该密钥时会要求输入。'; + + @override + String get sshKeyPassphraseWrong => '口令错误。'; + + @override + String get sshKeyPublicKey => '公钥'; + + @override + String get sshKeyPublicKeyTip => '将此行追加到服务器的 ~/.ssh/authorized_keys。'; + + @override + String sshKeyUnlockTip(String name) { + return '请输入私钥 [$name] 的口令。'; + } + @override String get unused => '未使用'; @@ -1953,6 +1987,40 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get showHiddenFiles => '顯示隱藏檔案'; + @override + String get sshKeyAlgorithm => '演算法'; + + @override + String get sshKeyComment => '備註'; + + @override + String get sshKeyGenerate => '產生金鑰對'; + + @override + String get sshKeyGenerating => '產生中…'; + + @override + String sshKeyLockedFmt(String name) { + return '私密金鑰 [$name] 未解鎖。'; + } + + @override + String get sshKeyPassphraseTip => '選填。設定通行密碼後,私密金鑰將加密儲存,每次連線首次使用該金鑰時會要求輸入。'; + + @override + String get sshKeyPassphraseWrong => '通行密碼錯誤。'; + + @override + String get sshKeyPublicKey => '公開金鑰'; + + @override + String get sshKeyPublicKeyTip => '將此行附加到伺服器的 ~/.ssh/authorized_keys。'; + + @override + String sshKeyUnlockTip(String name) { + return '請輸入私密金鑰 [$name] 的通行密碼。'; + } + @override String get unused => '未使用'; diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index af459098bf..4c7602e8e1 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -99,6 +99,16 @@ "macDmgTip": "Lokales Terminal und Snippets lokal ausführen (DMG-Version)", "macDmgTitle": "DMG-Build", "showHiddenFiles": "Versteckte Dateien anzeigen", + "sshKeyAlgorithm": "Algorithmus", + "sshKeyComment": "Kommentar", + "sshKeyGenerate": "Schlüsselpaar erzeugen", + "sshKeyGenerating": "Wird erzeugt…", + "sshKeyLockedFmt": "Der private Schlüssel [{name}] wurde nicht entsperrt.", + "sshKeyPassphraseTip": "Optional. Ein Schlüssel mit Passphrase wird verschlüsselt gespeichert und beim ersten Verbinden abgefragt.", + "sshKeyPassphraseWrong": "Falsche Passphrase.", + "sshKeyPublicKey": "Öffentlicher Schlüssel", + "sshKeyPublicKeyTip": "Diese Zeile an ~/.ssh/authorized_keys auf dem Server anhängen.", + "sshKeyUnlockTip": "Passphrase für den privaten Schlüssel [{name}] eingeben.", "unused": "Ungenutzt", "dangling": "Verwaist", "pruneUnusedImages": "Ungenutzte Images bereinigen", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 9a3ec19da6..9e254eee68 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -175,6 +175,30 @@ "macDmgTip": "Local terminal and running snippets locally (DMG build)", "macDmgTitle": "DMG build", "showHiddenFiles": "Show hidden files", + "sshKeyAlgorithm": "Algorithm", + "sshKeyComment": "Comment", + "sshKeyGenerate": "Generate key pair", + "sshKeyGenerating": "Generating…", + "sshKeyLockedFmt": "The private key [{name}] was not unlocked.", + "@sshKeyLockedFmt": { + "placeholders": { + "name": { + "type": "String" + } + } + }, + "sshKeyPassphraseTip": "Optional. A key with a passphrase is stored encrypted, and you are asked for it the first time a connection uses the key.", + "sshKeyPassphraseWrong": "Wrong passphrase.", + "sshKeyPublicKey": "Public key", + "sshKeyPublicKeyTip": "Append this line to ~/.ssh/authorized_keys on the server.", + "sshKeyUnlockTip": "Enter the passphrase for the private key [{name}].", + "@sshKeyUnlockTip": { + "placeholders": { + "name": { + "type": "String" + } + } + }, "unused": "Unused", "dangling": "Dangling", "pruneUnusedImages": "Prune unused images", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index efa219d719..a8af1a5cba 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -99,6 +99,16 @@ "macDmgTip": "Terminal local y ejecutar snippets en local (versión DMG)", "macDmgTitle": "Versión DMG", "showHiddenFiles": "Mostrar archivos ocultos", + "sshKeyAlgorithm": "Algoritmo", + "sshKeyComment": "Comentario", + "sshKeyGenerate": "Generar par de claves", + "sshKeyGenerating": "Generando…", + "sshKeyLockedFmt": "La clave privada [{name}] no se ha desbloqueado.", + "sshKeyPassphraseTip": "Opcional. Una clave con frase de contraseña se guarda cifrada y se pide la primera vez que una conexión la usa.", + "sshKeyPassphraseWrong": "Frase de contraseña incorrecta.", + "sshKeyPublicKey": "Clave pública", + "sshKeyPublicKeyTip": "Añade esta línea a ~/.ssh/authorized_keys en el servidor.", + "sshKeyUnlockTip": "Introduce la frase de contraseña de la clave privada [{name}].", "unused": "Sin usar", "dangling": "Colgante", "pruneUnusedImages": "Limpiar imágenes sin usar", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index e6d0e80926..9d9be6ebdf 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -99,6 +99,16 @@ "macDmgTip": "Terminal local et exécution locale des snippets (version DMG)", "macDmgTitle": "Version DMG", "showHiddenFiles": "Afficher les fichiers cachés", + "sshKeyAlgorithm": "Algorithme", + "sshKeyComment": "Commentaire", + "sshKeyGenerate": "Générer une paire de clés", + "sshKeyGenerating": "Génération…", + "sshKeyLockedFmt": "La clé privée [{name}] n'a pas été déverrouillée.", + "sshKeyPassphraseTip": "Facultatif. Une clé avec phrase secrète est stockée chiffrée, et celle-ci est demandée à la première connexion qui l'utilise.", + "sshKeyPassphraseWrong": "Phrase secrète incorrecte.", + "sshKeyPublicKey": "Clé publique", + "sshKeyPublicKeyTip": "Ajoutez cette ligne à ~/.ssh/authorized_keys sur le serveur.", + "sshKeyUnlockTip": "Saisissez la phrase secrète de la clé privée [{name}].", "unused": "Inutilisé", "dangling": "Fantôme", "pruneUnusedImages": "Nettoyer les images inutilisées", diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index edaa335269..229d5233cc 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -99,6 +99,16 @@ "macDmgTip": "Terminal lokal dan menjalankan snippet secara lokal (versi DMG)", "macDmgTitle": "Versi DMG", "showHiddenFiles": "Tampilkan berkas tersembunyi", + "sshKeyAlgorithm": "Algoritme", + "sshKeyComment": "Komentar", + "sshKeyGenerate": "Buat pasangan kunci", + "sshKeyGenerating": "Membuat…", + "sshKeyLockedFmt": "Kunci privat [{name}] belum dibuka.", + "sshKeyPassphraseTip": "Opsional. Kunci dengan frasa sandi disimpan terenkripsi, dan frasa itu diminta saat koneksi pertama memakai kunci ini.", + "sshKeyPassphraseWrong": "Frasa sandi salah.", + "sshKeyPublicKey": "Kunci publik", + "sshKeyPublicKeyTip": "Tambahkan baris ini ke ~/.ssh/authorized_keys di server.", + "sshKeyUnlockTip": "Masukkan frasa sandi untuk kunci privat [{name}].", "unused": "Tidak terpakai", "dangling": "Menggantung", "pruneUnusedImages": "Bersihkan gambar tidak terpakai", diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index 313f6f98dd..191d3028ff 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -99,6 +99,16 @@ "macDmgTip": "Terminale locale ed esecuzione locale degli snippet (versione DMG)", "macDmgTitle": "Versione DMG", "showHiddenFiles": "Mostra i file nascosti", + "sshKeyAlgorithm": "Algoritmo", + "sshKeyComment": "Commento", + "sshKeyGenerate": "Genera coppia di chiavi", + "sshKeyGenerating": "Generazione…", + "sshKeyLockedFmt": "La chiave privata [{name}] non è stata sbloccata.", + "sshKeyPassphraseTip": "Facoltativo. Una chiave con passphrase viene salvata cifrata e la passphrase è richiesta al primo uso della chiave.", + "sshKeyPassphraseWrong": "Passphrase errata.", + "sshKeyPublicKey": "Chiave pubblica", + "sshKeyPublicKeyTip": "Aggiungi questa riga a ~/.ssh/authorized_keys sul server.", + "sshKeyUnlockTip": "Inserisci la passphrase della chiave privata [{name}].", "unused": "Inutilizzato", "dangling": "Orfana", "pruneUnusedImages": "Rimuovi immagini inutilizzate", diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index af4d0085f6..714cddae77 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -99,6 +99,16 @@ "macDmgTip": "ローカルターミナルと snippet のローカル実行(DMG 版)", "macDmgTitle": "DMG 版", "showHiddenFiles": "隠しファイルを表示", + "sshKeyAlgorithm": "アルゴリズム", + "sshKeyComment": "コメント", + "sshKeyGenerate": "鍵ペアを生成", + "sshKeyGenerating": "生成中…", + "sshKeyLockedFmt": "秘密鍵 [{name}] のロックが解除されていません。", + "sshKeyPassphraseTip": "任意。パスフレーズを設定すると秘密鍵は暗号化して保存され、接続でこの鍵を最初に使うときに入力を求められます。", + "sshKeyPassphraseWrong": "パスフレーズが違います。", + "sshKeyPublicKey": "公開鍵", + "sshKeyPublicKeyTip": "この行をサーバーの ~/.ssh/authorized_keys に追記してください。", + "sshKeyUnlockTip": "秘密鍵 [{name}] のパスフレーズを入力してください。", "unused": "未使用", "dangling": "未タグ", "pruneUnusedImages": "未使用イメージをクリーンアップ", diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 4504fd0de0..7408022b2d 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -112,6 +112,16 @@ "macDmgTip": "로컬 터미널과 로컬 snippet 실행 (DMG 버전)", "macDmgTitle": "DMG 버전", "showHiddenFiles": "숨김 파일 표시", + "sshKeyAlgorithm": "알고리즘", + "sshKeyComment": "설명", + "sshKeyGenerate": "키 쌍 생성", + "sshKeyGenerating": "생성 중…", + "sshKeyLockedFmt": "개인 키 [{name}]의 잠금이 해제되지 않았습니다.", + "sshKeyPassphraseTip": "선택 사항. 암호를 설정하면 개인 키가 암호화되어 저장되며, 연결에서 이 키를 처음 사용할 때 입력을 요구합니다.", + "sshKeyPassphraseWrong": "암호가 올바르지 않습니다.", + "sshKeyPublicKey": "공개 키", + "sshKeyPublicKeyTip": "이 줄을 서버의 ~/.ssh/authorized_keys에 추가하세요.", + "sshKeyUnlockTip": "개인 키 [{name}]의 암호를 입력하세요.", "unused": "미사용", "dangling": "댕글링", "pruneUnusedImages": "미사용 이미지 정리", diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb index a71343e3f7..26030a72bd 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -99,6 +99,16 @@ "macDmgTip": "Lokale terminal en snippets lokaal uitvoeren (DMG-versie)", "macDmgTitle": "DMG-versie", "showHiddenFiles": "Verborgen bestanden tonen", + "sshKeyAlgorithm": "Algoritme", + "sshKeyComment": "Opmerking", + "sshKeyGenerate": "Sleutelpaar genereren", + "sshKeyGenerating": "Bezig met genereren…", + "sshKeyLockedFmt": "De privésleutel [{name}] is niet ontgrendeld.", + "sshKeyPassphraseTip": "Optioneel. Een sleutel met wachtwoordzin wordt versleuteld opgeslagen en wordt gevraagd zodra een verbinding de sleutel voor het eerst gebruikt.", + "sshKeyPassphraseWrong": "Onjuiste wachtwoordzin.", + "sshKeyPublicKey": "Publieke sleutel", + "sshKeyPublicKeyTip": "Voeg deze regel toe aan ~/.ssh/authorized_keys op de server.", + "sshKeyUnlockTip": "Voer de wachtwoordzin voor de privésleutel [{name}] in.", "unused": "Ongebruikt", "dangling": "Bungelend", "pruneUnusedImages": "Ongebruikte images opschonen", diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index 571231c8c7..5470bad7da 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -99,6 +99,16 @@ "macDmgTip": "Terminal local e executar snippets localmente (versão DMG)", "macDmgTitle": "Versão DMG", "showHiddenFiles": "Mostrar ficheiros ocultos", + "sshKeyAlgorithm": "Algoritmo", + "sshKeyComment": "Comentário", + "sshKeyGenerate": "Gerar par de chaves", + "sshKeyGenerating": "A gerar…", + "sshKeyLockedFmt": "A chave privada [{name}] não foi desbloqueada.", + "sshKeyPassphraseTip": "Opcional. Uma chave com frase-passe é guardada cifrada e esta é pedida na primeira vez que uma ligação a usa.", + "sshKeyPassphraseWrong": "Frase-passe incorreta.", + "sshKeyPublicKey": "Chave pública", + "sshKeyPublicKeyTip": "Acrescente esta linha a ~/.ssh/authorized_keys no servidor.", + "sshKeyUnlockTip": "Introduza a frase-passe da chave privada [{name}].", "unused": "Não utilizado", "dangling": "Sem referência", "pruneUnusedImages": "Limpar imagens não utilizadas", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 5414e9d47e..7a08dd492f 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -99,6 +99,16 @@ "macDmgTip": "Локальный терминал и запуск сниппетов локально (версия DMG)", "macDmgTitle": "Сборка DMG", "showHiddenFiles": "Показывать скрытые файлы", + "sshKeyAlgorithm": "Алгоритм", + "sshKeyComment": "Комментарий", + "sshKeyGenerate": "Создать пару ключей", + "sshKeyGenerating": "Создание…", + "sshKeyLockedFmt": "Закрытый ключ [{name}] не разблокирован.", + "sshKeyPassphraseTip": "Необязательно. Ключ с парольной фразой хранится в зашифрованном виде, и она запрашивается при первом использовании ключа.", + "sshKeyPassphraseWrong": "Неверная парольная фраза.", + "sshKeyPublicKey": "Открытый ключ", + "sshKeyPublicKeyTip": "Добавьте эту строку в ~/.ssh/authorized_keys на сервере.", + "sshKeyUnlockTip": "Введите парольную фразу закрытого ключа [{name}].", "unused": "Не используется", "dangling": "Висячий", "pruneUnusedImages": "Очистить неиспользуемые образы", diff --git a/lib/l10n/app_tr.arb b/lib/l10n/app_tr.arb index 2dfb7bac6b..1696b27dc3 100644 --- a/lib/l10n/app_tr.arb +++ b/lib/l10n/app_tr.arb @@ -99,6 +99,16 @@ "macDmgTip": "Yerel terminal ve snippet’leri yerelde çalıştırma (DMG sürümü)", "macDmgTitle": "DMG sürümü", "showHiddenFiles": "Gizli dosyaları göster", + "sshKeyAlgorithm": "Algoritma", + "sshKeyComment": "Açıklama", + "sshKeyGenerate": "Anahtar çifti oluştur", + "sshKeyGenerating": "Oluşturuluyor…", + "sshKeyLockedFmt": "[{name}] özel anahtarının kilidi açılmadı.", + "sshKeyPassphraseTip": "İsteğe bağlı. Parola belirlenen anahtar şifreli saklanır ve bir bağlantı anahtarı ilk kez kullandığında parola sorulur.", + "sshKeyPassphraseWrong": "Parola yanlış.", + "sshKeyPublicKey": "Genel anahtar", + "sshKeyPublicKeyTip": "Bu satırı sunucudaki ~/.ssh/authorized_keys dosyasına ekleyin.", + "sshKeyUnlockTip": "[{name}] özel anahtarının parolasını girin.", "unused": "Kullanılmıyor", "dangling": "Askıda", "pruneUnusedImages": "Kullanılmayan görüntüleri temizle", diff --git a/lib/l10n/app_uk.arb b/lib/l10n/app_uk.arb index 251641fe9d..1d1733fe41 100644 --- a/lib/l10n/app_uk.arb +++ b/lib/l10n/app_uk.arb @@ -99,6 +99,16 @@ "macDmgTip": "Локальний термінал і запуск сніпетів локально (версія DMG)", "macDmgTitle": "Збірка DMG", "showHiddenFiles": "Показувати приховані файли", + "sshKeyAlgorithm": "Алгоритм", + "sshKeyComment": "Коментар", + "sshKeyGenerate": "Створити пару ключів", + "sshKeyGenerating": "Створення…", + "sshKeyLockedFmt": "Закритий ключ [{name}] не розблоковано.", + "sshKeyPassphraseTip": "Необов'язково. Ключ із парольною фразою зберігається зашифрованим, і її запитують під час першого використання ключа.", + "sshKeyPassphraseWrong": "Неправильна парольна фраза.", + "sshKeyPublicKey": "Відкритий ключ", + "sshKeyPublicKeyTip": "Додайте цей рядок до ~/.ssh/authorized_keys на сервері.", + "sshKeyUnlockTip": "Введіть парольну фразу закритого ключа [{name}].", "unused": "Не використовується", "dangling": "Висячий", "pruneUnusedImages": "Очистити невикористані образи", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 811f30f3da..af040377b9 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -172,6 +172,16 @@ "macDmgTip": "本机终端、在本机运行 snippet(DMG 版)", "macDmgTitle": "DMG 版", "showHiddenFiles": "显示隐藏文件", + "sshKeyAlgorithm": "算法", + "sshKeyComment": "备注", + "sshKeyGenerate": "生成密钥对", + "sshKeyGenerating": "生成中…", + "sshKeyLockedFmt": "私钥 [{name}] 未解锁。", + "sshKeyPassphraseTip": "可选。设置口令后,私钥将加密存储,每次连接首次使用该密钥时会要求输入。", + "sshKeyPassphraseWrong": "口令错误。", + "sshKeyPublicKey": "公钥", + "sshKeyPublicKeyTip": "将此行追加到服务器的 ~/.ssh/authorized_keys。", + "sshKeyUnlockTip": "请输入私钥 [{name}] 的口令。", "unused": "未使用", "dangling": "悬空", "pruneUnusedImages": "清理未使用镜像", diff --git a/lib/l10n/app_zh_tw.arb b/lib/l10n/app_zh_tw.arb index d257637a7a..c2112b3ac0 100644 --- a/lib/l10n/app_zh_tw.arb +++ b/lib/l10n/app_zh_tw.arb @@ -172,6 +172,16 @@ "macDmgTip": "本機終端、在本機執行 snippet(DMG 版)", "macDmgTitle": "DMG 版", "showHiddenFiles": "顯示隱藏檔案", + "sshKeyAlgorithm": "演算法", + "sshKeyComment": "備註", + "sshKeyGenerate": "產生金鑰對", + "sshKeyGenerating": "產生中…", + "sshKeyLockedFmt": "私密金鑰 [{name}] 未解鎖。", + "sshKeyPassphraseTip": "選填。設定通行密碼後,私密金鑰將加密儲存,每次連線首次使用該金鑰時會要求輸入。", + "sshKeyPassphraseWrong": "通行密碼錯誤。", + "sshKeyPublicKey": "公開金鑰", + "sshKeyPublicKeyTip": "將此行附加到伺服器的 ~/.ssh/authorized_keys。", + "sshKeyUnlockTip": "請輸入私密金鑰 [{name}] 的通行密碼。", "unused": "未使用", "dangling": "懸空", "pruneUnusedImages": "清理未使用映像檔", diff --git a/lib/view/page/private_key/edit.dart b/lib/view/page/private_key/edit.dart index fc14372e80..07d1215d7a 100644 --- a/lib/view/page/private_key/edit.dart +++ b/lib/view/page/private_key/edit.dart @@ -1,12 +1,15 @@ import 'dart:io'; import 'package:computer/computer.dart'; +import 'package:dartssh2/dartssh2.dart'; import 'package:fl_lib/fl_lib.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:server_box/core/extension/context/locale.dart'; import 'package:server_box/core/utils/server.dart'; +import 'package:server_box/core/utils/ssh_key_unlock.dart'; +import 'package:server_box/core/utils/ssh_keygen.dart'; import 'package:server_box/data/model/server/private_key_info.dart'; import 'package:server_box/data/provider/private_key.dart'; import 'package:server_box/data/res/misc.dart'; @@ -100,6 +103,11 @@ class _PrivateKeyEditPageState extends ConsumerState { final pki = this.pki; final actions = pki != null ? [ + IconButton( + tooltip: l10n.sshKeyPublicKey, + onPressed: () => _showPublicKey(pki), + icon: const Icon(Icons.public), + ), IconButton( tooltip: libL10n.delete, onPressed: () async { @@ -115,6 +123,7 @@ class _PrivateKeyEditPageState extends ConsumerState { actions: Btn.ok(red: true).toList, ); if (confirmed != true || !context.mounted) return; + PrivateKeyUnlock.forget(pki.id); await _notifier.delete(pki); context.pop(); }, @@ -125,6 +134,52 @@ class _PrivateKeyEditPageState extends ConsumerState { return CustomAppBar(title: Text(libL10n.edit), actions: actions); } + /// Derives the public half and offers it for copying. + /// + /// Derived rather than stored: only the private key is kept, and the public + /// key is a function of it. For an encrypted key this is the same unlock a + /// connection does, so it asks once and both paths share the answer. + Future _showPublicKey(PrivateKeyInfo pki) async { + String line; + try { + final opened = await PrivateKeyUnlock.open( + pki.key, + cacheKey: pki.id, + keyName: pki.name, + ); + line = publicKeyLine(SSHKeyPair.fromPem(opened).first, pki.name); + } catch (e) { + Toast.error(e.toString()); + return; + } + if (!mounted) return; + await context.showRoundDialog( + title: l10n.sshKeyPublicKey, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(l10n.sshKeyPublicKeyTip, style: UIs.textGrey), + const SizedBox(height: 12), + SelectableText( + line, + style: const TextStyle(fontFamily: 'monospace', fontSize: 12), + ), + ], + ), + actions: [ + TextButton( + onPressed: () async { + await Clipboard.setData(ClipboardData(text: line)); + Toast.success(libL10n.success); + }, + child: Text(libL10n.copy), + ), + TextButton(onPressed: context.popDialog, child: Text(libL10n.ok)), + ], + ); + } + String _standardizeLineSeparators(String value) { return value.replaceAll('\r\n', '\n').replaceAll('\r', '\n'); } @@ -278,15 +333,27 @@ class _PrivateKeyEditPageState extends ConsumerState { FocusScope.of(context).unfocus(); _loading.value = SizedLoading.medium; try { - final decrypted = await Computer.shared.start(decryptPem, [key, pwd]); + // Stored as it was given. An encrypted key stays encrypted — the + // passphrase is what protects it, and stripping it here left every + // imported key lying in the database in the clear. + // + // A passphrase typed alongside it is checked rather than applied: a typo + // found now says so on this page, where it can be fixed, instead of at + // the next connection as a key that will not open. + if (pwd.isNotEmpty && PrivateKeyUnlock.isLocked(key)) { + await Computer.shared.start(decryptPem, [key, pwd]); + } // The id of the record being edited: renaming a key must not detach the // servers pointing at it, which is what happened when the two were one // value. final pki = PrivateKeyInfo( id: this.pki?.id ?? ShortId.generate(), name: name, - key: decrypted, + key: key, ); + // The bytes may have changed under an id that has not, so whatever was + // opened for it no longer describes what is stored. + PrivateKeyUnlock.forget(pki.id); final originPki = this.pki; if (originPki != null) { await _notifier.update(originPki, pki); diff --git a/lib/view/page/private_key/generate.dart b/lib/view/page/private_key/generate.dart new file mode 100644 index 0000000000..ed3af616f5 --- /dev/null +++ b/lib/view/page/private_key/generate.dart @@ -0,0 +1,218 @@ +import 'package:fl_lib/fl_lib.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:server_box/core/extension/context/locale.dart'; +import 'package:server_box/core/utils/ssh_keygen.dart'; +import 'package:server_box/data/model/server/private_key_info.dart'; +import 'package:server_box/data/provider/private_key.dart'; +import 'package:server_box/data/store/entity_store.dart'; + +/// Making a key pair here rather than somewhere else and importing it. +/// +/// Two halves, one after the other on the same page: what to make, and then +/// the public key to put on the server. The second half is why this is a page +/// and not a dialog that saves and closes — the public key is the whole point +/// of having generated one, and a screen that vanished on save would leave the +/// person with a key they cannot use yet. +class PrivateKeyGeneratePage extends ConsumerStatefulWidget { + const PrivateKeyGeneratePage({super.key}); + + @override + ConsumerState createState() => + _PrivateKeyGeneratePageState(); + + static const route = AppRouteNoArg( + page: PrivateKeyGeneratePage.new, + path: '/private_key/generate', + ); +} + +class _PrivateKeyGeneratePageState + extends ConsumerState { + final _nameController = TextEditingController(); + final _commentController = TextEditingController(); + final _pwdController = TextEditingController(); + + var _algorithm = SshKeyAlgorithm.ed25519; + var _working = false; + + /// The line to put on the server, once there is one. Its presence is what + /// decides which half of the page is showing. + String? _publicLine; + + @override + void dispose() { + _nameController.dispose(); + _commentController.dispose(); + _pwdController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: CustomAppBar(title: Text(l10n.sshKeyGenerate)), + body: _publicLine == null ? _buildForm() : _buildResult(), + floatingActionButton: _publicLine == null + ? FloatingActionButton( + tooltip: l10n.sshKeyGenerate, + onPressed: _working ? null : _onGenerate, + child: _working + ? SizedLoading.small + : const Icon(Icons.vpn_key), + ) + : null, + ); + } + + Widget _buildForm() { + return PageColumns( + children: [ + Input( + autoFocus: true, + controller: _nameController, + type: TextInputType.text, + label: libL10n.name, + icon: Icons.info, + suggestion: true, + ), + RadioGroup( + groupValue: _algorithm, + onChanged: (value) { + // Guarded here rather than by a null `onChanged`: RadioGroup takes + // a non-nullable callback, and disabling the tiles while a key is + // being generated is what this is for. + if (_working || value == null) return; + setState(() => _algorithm = value); + }, + child: Column( + children: [ + for (final algorithm in SshKeyAlgorithm.values) + RadioListTile( + value: algorithm, + enabled: !_working, + title: Text(_algorithmLabel(algorithm)), + subtitle: Text( + _algorithmSubtitle(algorithm), + style: UIs.textGrey, + ), + ), + ], + ), + ).cardx, + Input( + controller: _commentController, + type: TextInputType.text, + label: l10n.sshKeyComment, + icon: Icons.comment, + hint: 'serverbox', + suggestion: false, + ), + Input( + controller: _pwdController, + type: TextInputType.text, + obscureText: true, + label: libL10n.pwd, + icon: Icons.password, + suggestion: false, + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Text(l10n.sshKeyPassphraseTip, style: UIs.textGrey), + ), + ], + ); + } + + Widget _buildResult() { + return PageColumns( + children: [ + ListTile( + leading: const Icon(Icons.public), + title: Text(l10n.sshKeyPublicKey), + subtitle: Text(l10n.sshKeyPublicKeyTip, style: UIs.textGrey), + trailing: IconButton( + tooltip: libL10n.copy, + icon: const Icon(Icons.copy), + onPressed: () async { + await Clipboard.setData(ClipboardData(text: _publicLine!)); + Toast.success(libL10n.success); + }, + ), + ).cardx, + Padding( + padding: const EdgeInsets.all(12), + child: SelectableText( + _publicLine!, + style: const TextStyle(fontFamily: 'monospace', fontSize: 12), + ), + ).cardx, + Padding( + padding: const EdgeInsets.all(12), + child: FilledButton( + onPressed: () => context.pop(), + child: Text(libL10n.ok), + ), + ), + ], + ); + } + + String _algorithmLabel(SshKeyAlgorithm algorithm) => switch (algorithm) { + SshKeyAlgorithm.ed25519 => 'Ed25519', + SshKeyAlgorithm.ecdsaP256 => 'ECDSA (P-256)', + SshKeyAlgorithm.rsa2048 => 'RSA 2048', + SshKeyAlgorithm.rsa4096 => 'RSA 4096', + }; + + /// Why someone would pick this one. Untranslated on purpose: they are + /// algorithm names and the one English word among them is the default. + String _algorithmSubtitle(SshKeyAlgorithm algorithm) => switch (algorithm) { + SshKeyAlgorithm.ed25519 => 'Recommended', + SshKeyAlgorithm.ecdsaP256 => 'ecdsa-sha2-nistp256', + SshKeyAlgorithm.rsa2048 || SshKeyAlgorithm.rsa4096 => 'ssh-rsa', + }; + + Future _onGenerate() async { + final name = _nameController.text.trim(); + if (name.isEmpty) { + Toast.show(libL10n.empty); + return; + } + FocusScope.of(context).unfocus(); + setState(() => _working = true); + try { + final comment = _commentController.text.trim(); + final key = await generateSshKey( + algorithm: _algorithm, + // The name is what the person will recognise it by, and an OpenSSH + // comment with nothing in it tells whoever reads `authorized_keys` + // later nothing about where the key came from. + comment: comment.isEmpty ? name : comment, + passphrase: _pwdController.text.isEmpty ? null : _pwdController.text, + ); + await ref + .read(privateKeyProvider.notifier) + .add( + PrivateKeyInfo( + id: ShortId.generate(), + name: name, + key: key.privatePem, + ), + ); + if (!mounted) return; + setState(() => _publicLine = key.publicLine); + } on DuplicateNameException catch (e) { + // The name is unique in the schema, so this is where a collision is + // found. The page stays open on the name that has to change — and the + // key that was generated is dropped, which costs nothing to make again. + Toast.error(l10n.nameAlreadyExistsFmt(e.name)); + } catch (e) { + Toast.error(e.toString()); + rethrow; + } finally { + if (mounted) setState(() => _working = false); + } + } +} diff --git a/lib/view/page/private_key/list.dart b/lib/view/page/private_key/list.dart index f328d4a2f6..aaa90838a3 100644 --- a/lib/view/page/private_key/list.dart +++ b/lib/view/page/private_key/list.dart @@ -9,6 +9,7 @@ import 'package:server_box/data/model/server/private_key_info.dart'; import 'package:server_box/data/provider/private_key.dart'; import 'package:server_box/data/res/store.dart'; import 'package:server_box/view/page/private_key/edit.dart'; +import 'package:server_box/view/page/private_key/generate.dart'; class PrivateKeysListPage extends ConsumerStatefulWidget { const PrivateKeysListPage({super.key}); @@ -29,12 +30,45 @@ class _PrivateKeyListState extends ConsumerState return Scaffold( body: SafeArea(child: _buildBody()), floatingActionButton: FloatingActionButton( + onPressed: _onTapAdd, child: const Icon(Icons.add), - onPressed: () => PrivateKeyEditPage.route.go(context), ), ); } + /// Two ways to end up with a key here, asked before either page opens. + /// + /// The dialog answers with what to do and closes itself; this navigates. A + /// button that pushed the page from inside the dialog would be reaching for + /// the root navigator the dialog is on, not the one holding this page. + Future _onTapAdd() async { + final generate = await context.showRoundDialog( + title: libL10n.add, + childBuilder: (dialogContext) => Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: const Icon(Icons.vpn_key), + title: Text(l10n.sshKeyGenerate), + onTap: () => dialogContext.popDialog(true), + ), + ListTile( + leading: const Icon(Icons.file_open), + title: Text(libL10n.import), + onTap: () => dialogContext.popDialog(false), + ), + ], + ), + actions: const [], + ); + if (generate == null || !mounted) return; + if (generate) { + PrivateKeyGeneratePage.route.go(context); + } else { + PrivateKeyEditPage.route.go(context); + } + } + Widget _buildBody() { final privateKeyState = ref.watch(privateKeyProvider); final pkis = privateKeyState.keys; diff --git a/test/ssh_key_unlock_test.dart b/test/ssh_key_unlock_test.dart new file mode 100644 index 0000000000..b4191c367d --- /dev/null +++ b/test/ssh_key_unlock_test.dart @@ -0,0 +1,196 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:server_box/core/utils/ssh_key_unlock.dart'; +import 'package:server_box/core/utils/ssh_keygen.dart'; +import 'package:server_box/data/model/app/error.dart'; + +/// Opening a key that is stored encrypted. +/// +/// The prompt is a parameter, so what is exercised here is the policy: when it +/// asks, when it does not ask again, and what a refusal does. None of that is +/// reachable through a dialog in a test, and all of it decides whether a +/// connection happens. +void main() { + late String lockedPem; + late String plainPem; + const passphrase = 'let me in'; + + setUpAll(() { + lockedPem = generateSshKeyPair([ + SshKeyAlgorithm.ed25519.name, + 'test', + passphrase, + ])[0]; + plainPem = generateSshKeyPair([ + SshKeyAlgorithm.ed25519.name, + 'test', + '', + ])[0]; + }); + + setUp(() { + PrivateKeyUnlock.forgetAll(); + PrivateKeyUnlock.promptOverrideForTesting = null; + }); + + tearDown(() { + PrivateKeyUnlock.forgetAll(); + PrivateKeyUnlock.promptOverrideForTesting = null; + }); + + test('a key that needs no passphrase is never asked about', () async { + var asked = 0; + PrivateKeyUnlock.promptOverrideForTesting = ({required keyName, required retry}) async { + asked++; + return passphrase; + }; + + expect(PrivateKeyUnlock.isLocked(plainPem), isFalse); + expect( + await PrivateKeyUnlock.open(plainPem, cacheKey: 'k', keyName: 'k'), + plainPem, + reason: 'it should come back untouched, not re-encoded', + ); + expect(asked, 0); + }); + + test('a locked key is asked about once and remembered', () async { + var asked = 0; + PrivateKeyUnlock.promptOverrideForTesting = ({required keyName, required retry}) async { + asked++; + return passphrase; + }; + + expect(PrivateKeyUnlock.isLocked(lockedPem), isTrue); + final opened = await PrivateKeyUnlock.open( + lockedPem, + cacheKey: 'k', + keyName: 'work laptop', + ); + expect(PrivateKeyUnlock.isLocked(opened), isFalse); + + await PrivateKeyUnlock.open(lockedPem, cacheKey: 'k', keyName: 'x'); + expect(asked, 1, reason: 'once per key per run, not once per connection'); + }); + + test('the key is named when asking', () async { + String? seen; + PrivateKeyUnlock.promptOverrideForTesting = ({required keyName, required retry}) async { + seen = keyName; + return passphrase; + }; + await PrivateKeyUnlock.open( + lockedPem, + cacheKey: 'k', + keyName: 'work laptop', + ); + expect(seen, 'work laptop'); + }); + + test('two connections at once produce one prompt', () async { + var asked = 0; + PrivateKeyUnlock.promptOverrideForTesting = ({required keyName, required retry}) async { + asked++; + // Long enough that the second caller arrives while this is pending, + // which is the case being tested — a stack of identical dialogs. + await Future.delayed(const Duration(milliseconds: 20)); + return passphrase; + }; + + final results = await Future.wait([ + PrivateKeyUnlock.open(lockedPem, cacheKey: 'k', keyName: 'k'), + PrivateKeyUnlock.open(lockedPem, cacheKey: 'k', keyName: 'k'), + ]); + expect(asked, 1); + expect(results[0], results[1]); + }); + + test('a wrong passphrase is asked again, and says so', () async { + final retries = []; + PrivateKeyUnlock.promptOverrideForTesting = ({required keyName, required retry}) async { + retries.add(retry); + return retries.length == 1 ? 'wrong' : passphrase; + }; + + final opened = await PrivateKeyUnlock.open( + lockedPem, + cacheKey: 'k', + keyName: 'k', + ); + expect(PrivateKeyUnlock.isLocked(opened), isFalse); + expect(retries, [false, true], reason: 'the second ask has to say why'); + }); + + test('a wrong passphrase every time gives up rather than looping', () async { + var asked = 0; + PrivateKeyUnlock.promptOverrideForTesting = ({required keyName, required retry}) async { + asked++; + return 'wrong'; + }; + + await expectLater( + PrivateKeyUnlock.open(lockedPem, cacheKey: 'k', keyName: 'k'), + throwsA(isA()), + ); + expect(asked, PrivateKeyUnlock.maxAttempts); + expect(PrivateKeyUnlock.isOpened('k'), isFalse); + }); + + test('declining fails the connection rather than trying without a key', + () async { + PrivateKeyUnlock.promptOverrideForTesting = ({required keyName, required retry}) async => null; + + await expectLater( + PrivateKeyUnlock.open(lockedPem, cacheKey: 'k', keyName: 'k'), + throwsA(isA()), + ); + expect(PrivateKeyUnlock.isOpened('k'), isFalse); + }); + + group('openedOrNull', () { + test('answers null for a locked key nobody has opened', () { + // What the transfer path reads: it cannot ask, so it has to be able to + // tell "not opened" from "needs no opening". + expect( + PrivateKeyUnlock.openedOrNull(lockedPem, cacheKey: 'k'), + isNull, + ); + expect( + PrivateKeyUnlock.openedOrNull(plainPem, cacheKey: 'k'), + plainPem, + ); + }); + + test('answers the opened key once it has been opened', () async { + PrivateKeyUnlock.promptOverrideForTesting = + ({required keyName, required retry}) async => passphrase; + final opened = await PrivateKeyUnlock.open( + lockedPem, + cacheKey: 'k', + keyName: 'k', + ); + expect(PrivateKeyUnlock.openedOrNull(lockedPem, cacheKey: 'k'), opened); + }); + }); + + test('forget makes the next connection ask again', () async { + var asked = 0; + PrivateKeyUnlock.promptOverrideForTesting = ({required keyName, required retry}) async { + asked++; + return passphrase; + }; + + await PrivateKeyUnlock.open(lockedPem, cacheKey: 'k', keyName: 'k'); + // What editing or deleting the key does: the passphrase held was for the + // bytes that were there when it was given. + PrivateKeyUnlock.forget('k'); + await PrivateKeyUnlock.open(lockedPem, cacheKey: 'k', keyName: 'k'); + expect(asked, 2); + }); + + test('unreadable input is not locked, so it fails where it is parsed', () { + // `isLocked` decides whether to put up a dialog. A key that is not a key + // must not turn into a passphrase prompt. + expect(PrivateKeyUnlock.isLocked('not a pem at all'), isFalse); + expect(PrivateKeyUnlock.isLocked(''), isFalse); + }); +} From 8ea10e4959bfff6ba58159f222d4d57e68916b7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?lollipopkit=F0=9F=8F=B3=EF=B8=8F=E2=80=8D=E2=9A=A7?= =?UTF-8?q?=EF=B8=8F?= <10864310+lollipopkit@users.noreply.github.com> Date: Sun, 23 Aug 2026 23:28:30 +0800 Subject: [PATCH 03/11] feat(key): show each key's fingerprint and comment in the list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The subtitle said `OPENSSH` — the PEM container, which every modern key is in and which says nothing about which key it is. It now shows the SHA256 fingerprint and the OpenSSH comment, so a key here can be matched against what `ssh-keygen -l` and the server's own tooling print. Neither needs the passphrase for the fingerprint: in an `openssh-key-v1` container the public key sits outside the encrypted blob, and only the comment is inside it. So a locked key is still identifiable, and shows no comment rather than a locked-looking placeholder. Derived per build rather than stored. It is a hash of a few hundred bytes over a list of a handful of keys, and a copy in the record would be one more thing that can disagree with the key it describes. The fingerprint format is checked against `ssh-keygen -l` for every algorithm: the digest is base64 with its padding stripped, and one that did not match what the server side prints would be worse than showing none. --- lib/core/utils/ssh_keygen.dart | 75 +++++++++++++++++++++++++++++ lib/view/page/private_key/list.dart | 22 ++++++++- test/ssh_keygen_test.dart | 68 ++++++++++++++++++++++++++ 3 files changed, 164 insertions(+), 1 deletion(-) diff --git a/lib/core/utils/ssh_keygen.dart b/lib/core/utils/ssh_keygen.dart index 76850e31c5..e98db6c0a7 100644 --- a/lib/core/utils/ssh_keygen.dart +++ b/lib/core/utils/ssh_keygen.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'dart:math'; +import 'package:crypto/crypto.dart'; import 'package:dartssh2/dartssh2.dart'; import 'package:flutter/foundation.dart'; import 'package:pinenacl/ed25519.dart' as ed25519; @@ -181,3 +182,77 @@ SecureRandom _seededRandom() { ), ); } + +/// What a stored private key says about itself. +@immutable +class SshKeyDigest { + const SshKeyDigest({this.keyType, this.fingerprint, this.comment}); + + /// `ssh-ed25519`, `ssh-rsa`, … Taken from the public key rather than the PEM + /// header, which only names the container it is in — every modern key says + /// `OPENSSH` there whatever it holds. + final String? keyType; + + /// `SHA256:…`, in the form `ssh-keygen -l` prints: the digest base64'd with + /// its padding removed. + final String? fingerprint; + + /// Null when the key is encrypted, and for the older PEM forms that have no + /// such field. The comment lives inside the part that gets encrypted, while + /// the public key does not — which is why a locked key can still be + /// fingerprinted. + final String? comment; + + bool get isEmpty => keyType == null && fingerprint == null && comment == null; +} + +/// Reads [pem] for what can be shown about it in a list. +/// +/// Never throws and never asks for a passphrase: this is for a subtitle, and a +/// key that cannot be read is one whose subtitle is empty, not an error. +SshKeyDigest describeSshKey(String pem) { + try { + final decoded = SSHPem.decode(pem); + if (decoded.type == 'OPENSSH PRIVATE KEY') { + final pairs = OpenSSHKeyPairs.decode(decoded.content); + final blob = pairs.publicKeys.isEmpty ? null : pairs.publicKeys.first; + // Read without opening anything: `publicKeys` sits outside the encrypted + // blob, so this works for a key nobody has unlocked. + return SshKeyDigest( + keyType: blob == null ? null : SSHHostKey.getType(blob), + fingerprint: blob == null ? null : sshKeyFingerprint(blob), + comment: pairs.isEncrypted ? null : _commentOf(pairs.getPrivateKeys()), + ); + } + // The older forms keep no public key of their own, so there is nothing to + // read without opening the key — which is not something a list may do. + if (SSHKeyPair.isEncryptedPem(pem)) return const SshKeyDigest(); + final blob = SSHKeyPair.fromPem(pem).first.toPublicKey().encode(); + return SshKeyDigest( + keyType: SSHHostKey.getType(blob), + fingerprint: sshKeyFingerprint(blob), + ); + } catch (_) { + return const SshKeyDigest(); + } +} + +/// The fingerprint of an encoded public key, as `ssh-keygen -l` prints it. +String sshKeyFingerprint(Uint8List publicKeyBlob) { + final digest = sha256.convert(publicKeyBlob).bytes; + // Unpadded, which is what OpenSSH prints — a trailing `=` would make the + // string not match what the server's own tooling shows. + final encoded = base64.encode(digest).replaceAll('=', ''); + return 'SHA256:$encoded'; +} + +String? _commentOf(List pairs) { + if (pairs.isEmpty) return null; + final comment = switch (pairs.first) { + OpenSSHEd25519KeyPair(:final comment) => comment, + OpenSSHRsaKeyPair(:final comment) => comment, + OpenSSHEcdsaKeyPair(:final comment) => comment, + _ => null, + }; + return comment == null || comment.trim().isEmpty ? null : comment.trim(); +} diff --git a/lib/view/page/private_key/list.dart b/lib/view/page/private_key/list.dart index aaa90838a3..420e85664e 100644 --- a/lib/view/page/private_key/list.dart +++ b/lib/view/page/private_key/list.dart @@ -5,6 +5,7 @@ import 'package:fl_lib/fl_lib.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:server_box/core/extension/context/locale.dart'; +import 'package:server_box/core/utils/ssh_keygen.dart'; import 'package:server_box/data/model/server/private_key_info.dart'; import 'package:server_box/data/provider/private_key.dart'; import 'package:server_box/data/res/store.dart'; @@ -82,9 +83,28 @@ class _PrivateKeyListState extends ConsumerState } Widget _buildKeyItem(PrivateKeyInfo item) { + // Read per build rather than stored: the fingerprint is a function of the + // key, and a copy of it in the record would be one more thing that can + // disagree with what is actually there. It is a hash of a few hundred + // bytes, over a list of a handful of keys. + final digest = describeSshKey(item.key); + // The comment is absent for a key that is encrypted — it is inside the + // part that gets encrypted, unlike the public key the fingerprint comes + // from. `type` is the fallback for a key that reads as neither. + final lines = [ + ?digest.fingerprint, + ?digest.comment, + if (digest.isEmpty) item.type ?? libL10n.unknown, + ]; return ListTile( title: Text(item.name), - subtitle: Text(item.type ?? libL10n.unknown, style: UIs.textGrey), + subtitle: Text( + lines.join('\n'), + style: UIs.textGrey, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + isThreeLine: lines.length > 1, onTap: () => PrivateKeyEditPage.route.go( context, args: PrivateKeyEditPageArgs(pki: item), diff --git a/test/ssh_keygen_test.dart b/test/ssh_keygen_test.dart index c77680bbf2..ecac6e6f04 100644 --- a/test/ssh_keygen_test.dart +++ b/test/ssh_keygen_test.dart @@ -147,6 +147,74 @@ void main() { isNot(pair.toPem(passphrase: passphrase)), ); }); + + group('describeSshKey', () { + test('the fingerprint is the one ssh-keygen prints', () { + for (final algorithm in SshKeyAlgorithm.values) { + final key = generate(algorithm, comment: 'phone'); + final dir = Directory.systemTemp.createTempSync('sb-fp-'); + addTearDown(() => dir.deleteSync(recursive: true)); + final file = File('${dir.path}/id')..writeAsStringSync(key.privatePem); + Process.runSync('chmod', ['600', file.path]); + + final result = Process.runSync(sshKeygen!, ['-l', '-f', file.path]); + expect(result.exitCode, 0, reason: '${result.stderr}'); + // `-l` prints ` SHA256:… ()`. + final printed = (result.stdout as String).trim().split(' ')[1]; + expect( + describeSshKey(key.privatePem).fingerprint, + printed, + reason: '${algorithm.name}: a fingerprint that does not match what ' + 'the server side prints is worse than none', + ); + } + }, skip: sshKeygen == null ? 'ssh-keygen not on PATH' : null); + + test('reads the type and comment of an open key', () { + final key = generate(SshKeyAlgorithm.ed25519, comment: 'phone'); + final digest = describeSshKey(key.privatePem); + expect(digest.keyType, 'ssh-ed25519'); + expect(digest.comment, 'phone'); + expect(digest.isEmpty, isFalse); + }); + + test('a locked key still has a fingerprint, and no comment', () { + // The public key sits outside the encrypted blob and the comment does + // not, so a list can identify a key nobody has unlocked. + final key = generate( + SshKeyAlgorithm.ed25519, + comment: 'phone', + pass: passphrase, + ); + final digest = describeSshKey(key.privatePem); + expect(digest.keyType, 'ssh-ed25519'); + expect(digest.fingerprint, startsWith('SHA256:')); + expect(digest.comment, isNull); + }); + + test('a locked key fingerprints the same as the open one', () { + final plain = generate(SshKeyAlgorithm.ed25519, comment: 'phone'); + final pair = SSHKeyPair.fromPem(plain.privatePem).single + as OpenSSHKeyPair; + final locked = pair.toPem(passphrase: passphrase); + expect( + describeSshKey(locked).fingerprint, + describeSshKey(plain.privatePem).fingerprint, + ); + }); + + test('an empty comment is absent rather than blank', () { + final key = generate(SshKeyAlgorithm.ed25519, comment: ' '); + expect(describeSshKey(key.privatePem).comment, isNull); + }); + + test('anything unreadable says nothing rather than throwing', () { + // This runs while building a list row. + for (final input in ['', 'not a pem', '-----BEGIN X-----\nzz\n-----END X-----']) { + expect(describeSshKey(input).isEmpty, isTrue, reason: input); + } + }); + }); } String? _whichSshKeygen() { From a950c7ff6e97432f2b50f793553b269254bd7c3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?lollipopkit=F0=9F=8F=B3=EF=B8=8F=E2=80=8D=E2=9A=A7?= =?UTF-8?q?=EF=B8=8F?= <10864310+lollipopkit@users.noreply.github.com> Date: Sun, 23 Aug 2026 23:42:20 +0800 Subject: [PATCH 04/11] feat(key): collapse the algorithm list, and edit the comment as a field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things on the key pages. The algorithm list starts closed, showing what it is set to. There is a right answer for almost everyone and it is the default; the four choices are for the case where a server refuses that one, not four rows of algorithm names between the name field and everything else. Picking one closes it again — the choice is made, and leaving it open covers the rest of the form. The comment becomes an editable field, stored in its own column rather than written into the key. Both are real places for it: the public key line carries it as trailing text, and the private key file carries its own copy inside the part that gets encrypted — which is the one `ssh-keygen -c` rewrites. Editing that one means opening the key, so a passphrase prompt and a rewrite of key material, to change a label. The public key blob is identical either way. Null in the column means "whatever the key itself says", so every key already stored goes on showing what it arrived with, and an encrypted key can have its label edited without being opened. The list and the public key dialog both prefer the stored one and fall back to the key's own. Schema 7 → 8. The step only adds the column: it does not read or rewrite the key, because a migration that touched that column would be one that could lose a key. Its test asserts exactly that, and that a comment already there survives a re-run. --- lib/data/model/server/private_key_info.dart | 29 ++++- lib/data/model/server/private_key_info.g.dart | 2 + lib/data/store/db.dart | 12 ++ lib/data/store/db.g.dart | 94 +++++++++++++- lib/data/store/migrations/all.dart | 2 + .../migrations/m007_private_key_comment.dart | 37 ++++++ lib/data/store/private_key.dart | 9 +- lib/data/store/schema.dart | 2 +- lib/view/page/private_key/edit.dart | 28 ++++- lib/view/page/private_key/generate.dart | 67 ++++++---- lib/view/page/private_key/list.dart | 10 +- test/m007_private_key_comment_test.dart | 77 ++++++++++++ test/ssh_keygen_page_test.dart | 119 ++++++++++++++++++ 13 files changed, 445 insertions(+), 43 deletions(-) create mode 100644 lib/data/store/migrations/m007_private_key_comment.dart create mode 100644 test/m007_private_key_comment_test.dart create mode 100644 test/ssh_keygen_page_test.dart diff --git a/lib/data/model/server/private_key_info.dart b/lib/data/model/server/private_key_info.dart index fd5f26bdd9..7131d87602 100644 --- a/lib/data/model/server/private_key_info.dart +++ b/lib/data/model/server/private_key_info.dart @@ -18,10 +18,18 @@ class PrivateKeyInfo { @JsonKey(name: 'private_key') final String key; + /// What to put at the end of the public key line, when the user has said. + /// + /// Null means "whatever the key itself says" — see `describeSshKey`. Editing + /// it here rather than in the key is what keeps changing a label from + /// needing the passphrase and a rewrite of key material. + final String? comment; + const PrivateKeyInfo({ required this.id, required this.name, required this.key, + this.comment, }); /// [name] falls back to [id] for a record written before they were separate: @@ -34,12 +42,21 @@ class PrivateKeyInfo { Map toJson() => _$PrivateKeyInfoToJson(this); - PrivateKeyInfo copyWith({String? id, String? name, String? key}) => - PrivateKeyInfo( - id: id ?? this.id, - name: name ?? this.name, - key: key ?? this.key, - ); + PrivateKeyInfo copyWith({ + String? id, + String? name, + String? key, + // Positional-ish: `null` means "leave it", and clearing is what + // `clearComment` is for — a nullable field cannot say both with one + // parameter. + String? comment, + bool clearComment = false, + }) => PrivateKeyInfo( + id: id ?? this.id, + name: name ?? this.name, + key: key ?? this.key, + comment: clearComment ? null : (comment ?? this.comment), + ); String? get type { final lines = key.split('\n'); diff --git a/lib/data/model/server/private_key_info.g.dart b/lib/data/model/server/private_key_info.g.dart index 20a10cc3d6..2f2f82848b 100644 --- a/lib/data/model/server/private_key_info.g.dart +++ b/lib/data/model/server/private_key_info.g.dart @@ -11,6 +11,7 @@ PrivateKeyInfo _$PrivateKeyInfoFromJson(Map json) => id: json['id'] as String, name: json['name'] as String, key: json['private_key'] as String, + comment: json['comment'] as String?, ); Map _$PrivateKeyInfoToJson(PrivateKeyInfo instance) => @@ -18,4 +19,5 @@ Map _$PrivateKeyInfoToJson(PrivateKeyInfo instance) => 'id': instance.id, 'name': instance.name, 'private_key': instance.key, + 'comment': instance.comment, }; diff --git a/lib/data/store/db.dart b/lib/data/store/db.dart index e7e03d185a..132312c915 100644 --- a/lib/data/store/db.dart +++ b/lib/data/store/db.dart @@ -38,6 +38,18 @@ class PrivateKeys extends Table with SyncMeta { TextColumn get name => text().unique()(); TextColumn get key => text()(); + /// The OpenSSH comment to put at the end of the public key line. + /// + /// Held here rather than rewritten into the key: the key file carries its own + /// copy, inside the part that gets encrypted, so changing that one means + /// opening the key and writing it out again — a passphrase prompt and a + /// rewrite of key material, to edit a label. + /// + /// Null for a key stored before this column, and for one whose comment has + /// never been edited. The key's own comment is read in that case, which is + /// what keeps an imported key showing what it arrived with. + TextColumn get comment => text().nullable()(); + @override Set get primaryKey => {id}; } diff --git a/lib/data/store/db.g.dart b/lib/data/store/db.g.dart index e703dfe8b8..968d49165e 100644 --- a/lib/data/store/db.g.dart +++ b/lib/data/store/db.g.dart @@ -59,8 +59,26 @@ class $PrivateKeysTable extends PrivateKeys type: DriftSqlType.string, requiredDuringInsert: true, ); + static const VerificationMeta _commentMeta = const VerificationMeta( + 'comment', + ); @override - List get $columns => [updatedAt, rev, id, name, key]; + late final GeneratedColumn comment = GeneratedColumn( + 'comment', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + @override + List get $columns => [ + updatedAt, + rev, + id, + name, + key, + comment, + ]; @override String get aliasedName => _alias ?? actualTableName; @override @@ -106,6 +124,12 @@ class $PrivateKeysTable extends PrivateKeys } else if (isInserting) { context.missing(_keyMeta); } + if (data.containsKey('comment')) { + context.handle( + _commentMeta, + comment.isAcceptableOrUnknown(data['comment']!, _commentMeta), + ); + } return context; } @@ -135,6 +159,10 @@ class $PrivateKeysTable extends PrivateKeys DriftSqlType.string, data['${effectivePrefix}key'], )!, + comment: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}comment'], + ), ); } @@ -153,12 +181,25 @@ class PrivateKeyRow extends DataClass implements Insertable { final String id; final String name; final String key; + + /// The OpenSSH comment to put at the end of the public key line. + /// + /// Held here rather than rewritten into the key: the key file carries its own + /// copy, inside the part that gets encrypted, so changing that one means + /// opening the key and writing it out again — a passphrase prompt and a + /// rewrite of key material, to edit a label. + /// + /// Null for a key stored before this column, and for one whose comment has + /// never been edited. The key's own comment is read in that case, which is + /// what keeps an imported key showing what it arrived with. + final String? comment; const PrivateKeyRow({ required this.updatedAt, required this.rev, required this.id, required this.name, required this.key, + this.comment, }); @override Map toColumns(bool nullToAbsent) { @@ -168,6 +209,9 @@ class PrivateKeyRow extends DataClass implements Insertable { map['id'] = Variable(id); map['name'] = Variable(name); map['key'] = Variable(key); + if (!nullToAbsent || comment != null) { + map['comment'] = Variable(comment); + } return map; } @@ -178,6 +222,9 @@ class PrivateKeyRow extends DataClass implements Insertable { id: Value(id), name: Value(name), key: Value(key), + comment: comment == null && nullToAbsent + ? const Value.absent() + : Value(comment), ); } @@ -192,6 +239,7 @@ class PrivateKeyRow extends DataClass implements Insertable { id: serializer.fromJson(json['id']), name: serializer.fromJson(json['name']), key: serializer.fromJson(json['key']), + comment: serializer.fromJson(json['comment']), ); } @override @@ -203,6 +251,7 @@ class PrivateKeyRow extends DataClass implements Insertable { 'id': serializer.toJson(id), 'name': serializer.toJson(name), 'key': serializer.toJson(key), + 'comment': serializer.toJson(comment), }; } @@ -212,12 +261,14 @@ class PrivateKeyRow extends DataClass implements Insertable { String? id, String? name, String? key, + Value comment = const Value.absent(), }) => PrivateKeyRow( updatedAt: updatedAt ?? this.updatedAt, rev: rev ?? this.rev, id: id ?? this.id, name: name ?? this.name, key: key ?? this.key, + comment: comment.present ? comment.value : this.comment, ); PrivateKeyRow copyWithCompanion(PrivateKeysCompanion data) { return PrivateKeyRow( @@ -226,6 +277,7 @@ class PrivateKeyRow extends DataClass implements Insertable { id: data.id.present ? data.id.value : this.id, name: data.name.present ? data.name.value : this.name, key: data.key.present ? data.key.value : this.key, + comment: data.comment.present ? data.comment.value : this.comment, ); } @@ -236,13 +288,14 @@ class PrivateKeyRow extends DataClass implements Insertable { ..write('rev: $rev, ') ..write('id: $id, ') ..write('name: $name, ') - ..write('key: $key') + ..write('key: $key, ') + ..write('comment: $comment') ..write(')')) .toString(); } @override - int get hashCode => Object.hash(updatedAt, rev, id, name, key); + int get hashCode => Object.hash(updatedAt, rev, id, name, key, comment); @override bool operator ==(Object other) => identical(this, other) || @@ -251,7 +304,8 @@ class PrivateKeyRow extends DataClass implements Insertable { other.rev == this.rev && other.id == this.id && other.name == this.name && - other.key == this.key); + other.key == this.key && + other.comment == this.comment); } class PrivateKeysCompanion extends UpdateCompanion { @@ -260,12 +314,14 @@ class PrivateKeysCompanion extends UpdateCompanion { final Value id; final Value name; final Value key; + final Value comment; const PrivateKeysCompanion({ this.updatedAt = const Value.absent(), this.rev = const Value.absent(), this.id = const Value.absent(), this.name = const Value.absent(), this.key = const Value.absent(), + this.comment = const Value.absent(), }); PrivateKeysCompanion.insert({ this.updatedAt = const Value.absent(), @@ -273,6 +329,7 @@ class PrivateKeysCompanion extends UpdateCompanion { required String id, required String name, required String key, + this.comment = const Value.absent(), }) : id = Value(id), name = Value(name), key = Value(key); @@ -282,6 +339,7 @@ class PrivateKeysCompanion extends UpdateCompanion { Expression? id, Expression? name, Expression? key, + Expression? comment, }) { return RawValuesInsertable({ if (updatedAt != null) 'updated_at': updatedAt, @@ -289,6 +347,7 @@ class PrivateKeysCompanion extends UpdateCompanion { if (id != null) 'id': id, if (name != null) 'name': name, if (key != null) 'key': key, + if (comment != null) 'comment': comment, }); } @@ -298,6 +357,7 @@ class PrivateKeysCompanion extends UpdateCompanion { Value? id, Value? name, Value? key, + Value? comment, }) { return PrivateKeysCompanion( updatedAt: updatedAt ?? this.updatedAt, @@ -305,6 +365,7 @@ class PrivateKeysCompanion extends UpdateCompanion { id: id ?? this.id, name: name ?? this.name, key: key ?? this.key, + comment: comment ?? this.comment, ); } @@ -326,6 +387,9 @@ class PrivateKeysCompanion extends UpdateCompanion { if (key.present) { map['key'] = Variable(key.value); } + if (comment.present) { + map['comment'] = Variable(comment.value); + } return map; } @@ -336,7 +400,8 @@ class PrivateKeysCompanion extends UpdateCompanion { ..write('rev: $rev, ') ..write('id: $id, ') ..write('name: $name, ') - ..write('key: $key') + ..write('key: $key, ') + ..write('comment: $comment') ..write(')')) .toString(); } @@ -7502,6 +7567,7 @@ typedef $$PrivateKeysTableCreateCompanionBuilder = required String id, required String name, required String key, + Value comment, }); typedef $$PrivateKeysTableUpdateCompanionBuilder = PrivateKeysCompanion Function({ @@ -7510,6 +7576,7 @@ typedef $$PrivateKeysTableUpdateCompanionBuilder = Value id, Value name, Value key, + Value comment, }); final class $$PrivateKeysTableReferences @@ -7570,6 +7637,11 @@ class $$PrivateKeysTableFilterComposer builder: (column) => ColumnFilters(column), ); + ColumnFilters get comment => $composableBuilder( + column: $table.comment, + builder: (column) => ColumnFilters(column), + ); + Expression serversRefs( Expression Function($$ServersTableFilterComposer f) f, ) { @@ -7629,6 +7701,11 @@ class $$PrivateKeysTableOrderingComposer column: $table.key, builder: (column) => ColumnOrderings(column), ); + + ColumnOrderings get comment => $composableBuilder( + column: $table.comment, + builder: (column) => ColumnOrderings(column), + ); } class $$PrivateKeysTableAnnotationComposer @@ -7655,6 +7732,9 @@ class $$PrivateKeysTableAnnotationComposer GeneratedColumn get key => $composableBuilder(column: $table.key, builder: (column) => column); + GeneratedColumn get comment => + $composableBuilder(column: $table.comment, builder: (column) => column); + Expression serversRefs( Expression Function($$ServersTableAnnotationComposer a) f, ) { @@ -7714,12 +7794,14 @@ class $$PrivateKeysTableTableManager Value id = const Value.absent(), Value name = const Value.absent(), Value key = const Value.absent(), + Value comment = const Value.absent(), }) => PrivateKeysCompanion( updatedAt: updatedAt, rev: rev, id: id, name: name, key: key, + comment: comment, ), createCompanionCallback: ({ @@ -7728,12 +7810,14 @@ class $$PrivateKeysTableTableManager required String id, required String name, required String key, + Value comment = const Value.absent(), }) => PrivateKeysCompanion.insert( updatedAt: updatedAt, rev: rev, id: id, name: name, key: key, + comment: comment, ), withReferenceMapper: (p0) => p0 .map( diff --git a/lib/data/store/migrations/all.dart b/lib/data/store/migrations/all.dart index 5bbafa381a..6048125cbd 100644 --- a/lib/data/store/migrations/all.dart +++ b/lib/data/store/migrations/all.dart @@ -1,6 +1,7 @@ import 'package:server_box/data/store/migrations/m004_kv_to_tables.dart'; import 'package:server_box/data/store/migrations/m005_monitor_insecure_http.dart'; import 'package:server_box/data/store/migrations/m006_bmc_columns.dart'; +import 'package:server_box/data/store/migrations/m007_private_key_comment.dart'; import 'package:server_box/data/store/schema.dart'; /// Every migration, ordered, in the one place that names them. @@ -14,4 +15,5 @@ const kSchemaMigrations = [ KvToTablesMigration(), MonitorInsecureHttpMigration(), BmcColumnsMigration(), + PrivateKeyCommentMigration(), ]; diff --git a/lib/data/store/migrations/m007_private_key_comment.dart b/lib/data/store/migrations/m007_private_key_comment.dart new file mode 100644 index 0000000000..759ba1db87 --- /dev/null +++ b/lib/data/store/migrations/m007_private_key_comment.dart @@ -0,0 +1,37 @@ +import 'package:fl_lib/fl_lib.dart'; +import 'package:server_box/data/store/schema.dart'; + +/// Adds `private_key.comment`: the OpenSSH comment shown at the end of the +/// public key line. +/// +/// A column rather than a rewrite of the key. Every key file carries its own +/// comment, inside the part that gets encrypted, so editing that one means +/// opening the key — a passphrase prompt and a rewrite of key material, to +/// change a label. Null here means "whatever the key itself says", which is +/// what every existing row keeps saying without being touched. +/// +/// Written by hand rather than left to Drift, which owns the DDL but only for +/// a database being *created*: an install already at v7 has a `private_key` +/// table Drift will not revisit, and `createTables` is `IF NOT EXISTS` +/// throughout. The two must agree — `m007_private_key_comment_test.dart` is +/// what checks it, since `tables_schema_test.dart` only ever sees a freshly +/// created schema and never runs this step. +class PrivateKeyCommentMigration implements SchemaMigration { + const PrivateKeyCommentMigration(); + + @override + int get from => 7; + + @override + Future apply() async { + final db = SqliteDb.instance; + final columns = db + .select('PRAGMA table_info(private_key);') + .map((row) => row['name'] as String) + .toSet(); + // Guarded, so the step is safe to run again after a process stops partway: + // the version is recorded only once every statement has run. + if (columns.contains('comment')) return; + db.execute('ALTER TABLE private_key ADD COLUMN comment TEXT;'); + } +} diff --git a/lib/data/store/private_key.dart b/lib/data/store/private_key.dart index 07cf8ece16..e12641db87 100644 --- a/lib/data/store/private_key.dart +++ b/lib/data/store/private_key.dart @@ -30,7 +30,7 @@ class PrivateKeyStore extends EntityStore { @override List readAll() => db - .select('SELECT id, name, key FROM private_key ORDER BY name;') + .select('SELECT id, name, key, comment FROM private_key ORDER BY name;') .map(_fromRow) .toList(); @@ -38,11 +38,14 @@ class PrivateKeyStore extends EntityStore { id: row['id'] as String, name: row['name'] as String, key: row['key'] as String, + comment: row['comment'] as String?, ); @override - void write(PrivateKeyInfo item) => - upsert(const ['id', 'name', 'key'], [item.id, item.name, item.key]); + void write(PrivateKeyInfo item) => upsert( + const ['id', 'name', 'key', 'comment'], + [item.id, item.name, item.key, item.comment], + ); @override String? nameOf(PrivateKeyInfo item) => item.name; diff --git a/lib/data/store/schema.dart b/lib/data/store/schema.dart index f56c737b5d..c0108bfa2d 100644 --- a/lib/data/store/schema.dart +++ b/lib/data/store/schema.dart @@ -63,7 +63,7 @@ abstract final class SchemaVersion { /// and per-row sync metadata /// v6: per-monitor explicit permission for plaintext HTTP on trusted networks /// v7: the BMC side channel's columns on `server` - static const current = 7; + static const current = 8; /// Persisted locally, never included in a backup: it describes *this /// device's* storage, and restoring another device's number would make the diff --git a/lib/view/page/private_key/edit.dart b/lib/view/page/private_key/edit.dart index 07d1215d7a..2a0af8cf53 100644 --- a/lib/view/page/private_key/edit.dart +++ b/lib/view/page/private_key/edit.dart @@ -42,6 +42,7 @@ class _PrivateKeyEditPageState extends ConsumerState { final _nameController = TextEditingController(); final _keyController = TextEditingController(); final _pwdController = TextEditingController(); + final _commentController = TextEditingController(); final _nameNode = FocusNode(); final _keyNode = FocusNode(); final _pwdNode = FocusNode(); @@ -60,6 +61,7 @@ class _PrivateKeyEditPageState extends ConsumerState { _nameController.dispose(); _keyController.dispose(); _pwdController.dispose(); + _commentController.dispose(); _nameNode.dispose(); _keyNode.dispose(); _pwdNode.dispose(); @@ -73,6 +75,11 @@ class _PrivateKeyEditPageState extends ConsumerState { if (pki != null) { _nameController.text = pki.name; _keyController.text = pki.key; + // The stored one if the label has been edited, otherwise whatever the + // key arrived with — which is absent for an encrypted key, since the + // key's own comment is inside the part that gets encrypted. + _commentController.text = + pki.comment ?? describeSshKey(pki.key).comment ?? ''; } else { Clipboard.getData(_format).then((value) { if (value == null) return; @@ -147,7 +154,11 @@ class _PrivateKeyEditPageState extends ConsumerState { cacheKey: pki.id, keyName: pki.name, ); - line = publicKeyLine(SSHKeyPair.fromPem(opened).first, pki.name); + line = publicKeyLine( + SSHKeyPair.fromPem(opened).first, + // What the list shows, and what the server will see beside the key. + pki.comment ?? describeSshKey(pki.key).comment ?? pki.name, + ); } catch (e) { Toast.error(e.toString()); return; @@ -309,8 +320,19 @@ class _PrivateKeyEditPageState extends ConsumerState { label: libL10n.pwd, icon: Icons.password, suggestion: false, + ), + Input( + controller: _commentController, + type: TextInputType.text, + label: l10n.sshKeyComment, + icon: Icons.comment, + suggestion: false, onSubmitted: (_) => _onTapSave(), ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Text(l10n.sshKeyPublicKeyTip, style: UIs.textGrey), + ), SizedBox(height: MediaQuery.of(context).size.height * 0.1), ValBuilder( listenable: _loading, @@ -346,10 +368,14 @@ class _PrivateKeyEditPageState extends ConsumerState { // The id of the record being edited: renaming a key must not detach the // servers pointing at it, which is what happened when the two were one // value. + final comment = _commentController.text.trim(); final pki = PrivateKeyInfo( id: this.pki?.id ?? ShortId.generate(), name: name, key: key, + // Null rather than empty, so an untouched field goes on meaning + // "whatever the key itself says" instead of "no comment". + comment: comment.isEmpty ? null : comment, ); // The bytes may have changed under an id that has not, so whatever was // opened for it no longer describes what is stored. diff --git a/lib/view/page/private_key/generate.dart b/lib/view/page/private_key/generate.dart index ed3af616f5..965b175c61 100644 --- a/lib/view/page/private_key/generate.dart +++ b/lib/view/page/private_key/generate.dart @@ -34,6 +34,9 @@ class _PrivateKeyGeneratePageState final _commentController = TextEditingController(); final _pwdController = TextEditingController(); + /// Closes the algorithm list once a choice has been made. + final _algorithmTile = ExpansibleController(); + var _algorithm = SshKeyAlgorithm.ed25519; var _working = false; @@ -77,29 +80,47 @@ class _PrivateKeyGeneratePageState icon: Icons.info, suggestion: true, ), - RadioGroup( - groupValue: _algorithm, - onChanged: (value) { - // Guarded here rather than by a null `onChanged`: RadioGroup takes - // a non-nullable callback, and disabling the tiles while a key is - // being generated is what this is for. - if (_working || value == null) return; - setState(() => _algorithm = value); - }, - child: Column( - children: [ - for (final algorithm in SshKeyAlgorithm.values) - RadioListTile( - value: algorithm, - enabled: !_working, - title: Text(_algorithmLabel(algorithm)), - subtitle: Text( - _algorithmSubtitle(algorithm), - style: UIs.textGrey, - ), - ), - ], - ), + // Closed to begin with, showing what it is set to. There is a right + // answer here for almost everyone and it is the default; opening this + // is for the case where a server refuses it, not something to read on + // the way past. + ExpansionTile( + controller: _algorithmTile, + leading: const Icon(Icons.key), + title: Text(l10n.sshKeyAlgorithm), + subtitle: Text(_algorithmLabel(_algorithm), style: UIs.textGrey), + shape: const RoundedRectangleBorder(), + collapsedShape: const RoundedRectangleBorder(), + children: [ + RadioGroup( + groupValue: _algorithm, + onChanged: (value) { + // Guarded here rather than by a null `onChanged`: RadioGroup + // takes a non-nullable callback, and disabling the tiles while + // a key is being generated is what this is for. + if (_working || value == null) return; + setState(() => _algorithm = value); + // The choice is made, so the list has done its job — leaving + // it open would cover the rest of the form with four rows + // nobody is reading any more. + _algorithmTile.collapse(); + }, + child: Column( + children: [ + for (final algorithm in SshKeyAlgorithm.values) + RadioListTile( + value: algorithm, + enabled: !_working, + title: Text(_algorithmLabel(algorithm)), + subtitle: Text( + _algorithmSubtitle(algorithm), + style: UIs.textGrey, + ), + ), + ], + ), + ), + ], ).cardx, Input( controller: _commentController, diff --git a/lib/view/page/private_key/list.dart b/lib/view/page/private_key/list.dart index 420e85664e..9317b3c9b4 100644 --- a/lib/view/page/private_key/list.dart +++ b/lib/view/page/private_key/list.dart @@ -88,12 +88,14 @@ class _PrivateKeyListState extends ConsumerState // disagree with what is actually there. It is a hash of a few hundred // bytes, over a list of a handful of keys. final digest = describeSshKey(item.key); - // The comment is absent for a key that is encrypted — it is inside the - // part that gets encrypted, unlike the public key the fingerprint comes - // from. `type` is the fallback for a key that reads as neither. + // The stored comment wins, and the key's own is the fallback — which is + // what a key imported or generated before anyone edited its label has. + // That one is absent for an encrypted key, since it sits inside the part + // that gets encrypted while the public key does not. `type` is left for a + // key that reads as neither. final lines = [ ?digest.fingerprint, - ?digest.comment, + ?(item.comment ?? digest.comment), if (digest.isEmpty) item.type ?? libL10n.unknown, ]; return ListTile( diff --git a/test/m007_private_key_comment_test.dart b/test/m007_private_key_comment_test.dart new file mode 100644 index 0000000000..812403ec01 --- /dev/null +++ b/test/m007_private_key_comment_test.dart @@ -0,0 +1,77 @@ +import 'package:fl_lib/fl_lib.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:server_box/data/store/migrations/m007_private_key_comment.dart'; + +/// The step that gives `private_key` somewhere to put a label. +/// +/// It gets one pass over a user's records and is not repeatable, so the thing +/// worth asserting is that the keys already there come out of it unchanged — +/// a migration that touched the key column would be a migration that could +/// lose a key. +void main() { + setUp(SqliteDb.openInMemory); + tearDown(SqliteDb.close); + + /// The v7 shape: no comment column. + void createV7PrivateKey() { + SqliteDb.instance.execute( + 'CREATE TABLE private_key (' + 'id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE, key TEXT NOT NULL' + ') WITHOUT ROWID;', + ); + } + + List columns() => SqliteDb.instance + .select('PRAGMA table_info(private_key);') + .map((column) => column['name'] as String) + .toList(); + + test('adds the column and leaves the keys alone', () async { + createV7PrivateKey(); + SqliteDb.instance.execute( + "INSERT INTO private_key VALUES ('k-1', 'laptop', '-----BEGIN X-----');", + ); + + await const PrivateKeyCommentMigration().apply(); + + expect(columns(), contains('comment')); + final row = SqliteDb.instance.select('SELECT * FROM private_key;').single; + expect(row['id'], 'k-1'); + expect(row['name'], 'laptop'); + expect(row['key'], '-----BEGIN X-----'); + // Null, not the name and not an empty string: null is what means "whatever + // the key itself says", and an empty string would read as "no comment" and + // strip the label off every key that had one. + expect(row['comment'], isNull); + }); + + test('runs again without complaining', () async { + // The version is recorded only once every statement has run, so a process + // stopped partway means the whole step runs again. + createV7PrivateKey(); + await const PrivateKeyCommentMigration().apply(); + await const PrivateKeyCommentMigration().apply(); + expect(columns().where((c) => c == 'comment'), hasLength(1)); + }); + + test('a table that already has the column is left as it is', () async { + SqliteDb.instance.execute( + 'CREATE TABLE private_key (' + 'id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE, key TEXT NOT NULL, ' + 'comment TEXT' + ') WITHOUT ROWID;', + ); + SqliteDb.instance.execute( + "INSERT INTO private_key VALUES ('k-1', 'laptop', 'pem', 'me@host');", + ); + + await const PrivateKeyCommentMigration().apply(); + + expect( + SqliteDb.instance.select('SELECT comment FROM private_key;').single + ['comment'], + 'me@host', + reason: 'a comment already stored must survive the step', + ); + }); +} diff --git a/test/ssh_keygen_page_test.dart b/test/ssh_keygen_page_test.dart new file mode 100644 index 0000000000..5218a4682d --- /dev/null +++ b/test/ssh_keygen_page_test.dart @@ -0,0 +1,119 @@ +import 'dart:io'; + +import 'package:fl_lib/fl_lib.dart'; +import 'package:fl_lib/generated/l10n/lib_l10n.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:server_box/data/res/store.dart'; +import 'package:server_box/data/store/private_key.dart'; +import 'package:server_box/data/store/setting.dart'; +import 'package:server_box/generated/l10n/l10n.dart'; +import 'package:server_box/view/page/private_key/generate.dart'; + +import 'helpers/test_db.dart'; + +/// The algorithm list is closed to begin with. +/// +/// There is a right answer for almost everyone and it is the default, so the +/// four choices are something to open when a server refuses that one — not +/// four rows of key algorithms between the name field and everything else. +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late Directory tempDir; + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('server-box-keygen-page-'); + await openTestDb(); + getIt.registerSingleton(SettingStore.forTest()); + getIt.registerSingleton(PrivateKeyStore.forTest()); + }); + + tearDown(() async { + await getIt.reset(); + await SqliteDb.close(); + await tempDir.delete(recursive: true); + }); + + Future pump(WidgetTester tester) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(500, 900); + addTearDown(tester.view.reset); + + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + localizationsDelegates: const [ + LibLocalizations.delegate, + ...AppLocalizations.localizationsDelegates, + ], + supportedLocales: AppLocalizations.supportedLocales, + builder: ResponsivePoints.builder, + home: const PrivateKeyGeneratePage(), + ), + ), + ); + // Counted out rather than settled: the name field autofocuses, and + // `pumpAndSettle` on a tree with a text field in it waits its full ten + // minutes before giving up. + for (var i = 0; i < 10; i++) { + await tester.pump(const Duration(milliseconds: 50)); + } + addTearDown(() => tester.pumpWidget(const SizedBox.shrink())); + } + + testWidgets('the choices are closed, and the default is named', ( + tester, + ) async { + await pump(tester); + + // The tile says what it is set to without being opened. + expect(find.text('Ed25519'), findsOneWidget); + // And the rest are not in the tree at all. + expect(find.text('RSA 4096'), findsNothing); + expect(find.text('ECDSA (P-256)'), findsNothing); + expect(find.byType(RadioListTile), findsNothing); + }); + + testWidgets('opening it shows every algorithm', (tester) async { + await pump(tester); + + await tester.tap(find.text(AppLocalizations.of(tester.element( + find.byType(PrivateKeyGeneratePage), + ))!.sshKeyAlgorithm)); + for (var i = 0; i < 10; i++) { + await tester.pump(const Duration(milliseconds: 50)); + } + + expect(find.text('RSA 2048'), findsOneWidget); + expect(find.text('RSA 4096'), findsOneWidget); + expect(find.text('ECDSA (P-256)'), findsOneWidget); + // Twice now: once as the tile's subtitle, once as a choice. + expect(find.text('Ed25519'), findsNWidgets(2)); + }); + + testWidgets('choosing one closes it again and updates the subtitle', ( + tester, + ) async { + await pump(tester); + final title = AppLocalizations.of( + tester.element(find.byType(PrivateKeyGeneratePage)), + )!.sshKeyAlgorithm; + + await tester.tap(find.text(title)); + for (var i = 0; i < 10; i++) { + await tester.pump(const Duration(milliseconds: 50)); + } + await tester.tap(find.text('RSA 4096')); + for (var i = 0; i < 20; i++) { + await tester.pump(const Duration(milliseconds: 50)); + } + + // Closed, so the one left is the tile's own subtitle — the choice was + // made and the list has nothing more to say. + expect(find.text('RSA 4096'), findsOneWidget); + expect(find.text('Ed25519'), findsNothing); + expect(find.byType(RadioListTile), findsNothing); + }); +} From 8d6bea07265536180e34d7727b4b16281b67d567 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?lollipopkit=F0=9F=8F=B3=EF=B8=8F=E2=80=8D=E2=9A=A7?= =?UTF-8?q?=EF=B8=8F?= <10864310+lollipopkit@users.noreply.github.com> Date: Mon, 24 Aug 2026 00:01:48 +0800 Subject: [PATCH 05/11] fix(key): review findings on the keygen and unlock paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two that could authenticate with the wrong key, silently: - A restore or a sync replaces every stored key, and the unlocked-key cache was left holding the ones from before. Every connection for the rest of the run would then use the key that was just replaced, with no error anywhere. Both paths now forget what they opened. - `forget` cleared the cache but not the ask in flight, so a dialog already on screen when the key was edited wrote its answer back afterwards. Generation counters, because dropping the in-flight entry only stops new callers joining it — the one already running still returns. The rest: - Importing stopped validating the key. The old code got that from always decrypting; guarding the call on "is it encrypted" lost it, because `isLocked` answers false for anything it cannot parse rather than throwing. A PKCS#8 or truncated PEM was saved without a word and failed later as a connection error naming nothing. Parsed again either way. - Generating with a passphrase lost the comment: it went into the key, which is encrypted, so nothing could read it back. Stored alongside now, so the list and the public key line agree with what was copied. - The public key dialog read the comment from the locked bytes rather than the ones it had just decrypted, and fell back to the key's name. - The import page verified the passphrase and threw it away, then asked for it again on the first connection. - Declining the prompt was not remembered, so the poller raised it again every cycle, per server sharing the key. - An empty passphrase spent one of three attempts and a full bcrypt round on a value that cannot be right. - `describeSshKey` ran per row per frame and, for an unencrypted key, decoded the private blob — six mpints into BigInts for RSA-4096. Memoised on the PEM. - `alterUser` was dropped in the key-auth branch, so a server reached through its `alterUrl` authenticated as the primary host's user. Predates this work (5457d7c6); one word, fixed here rather than left. - The v7 shape in the migration test omitted `updated_at` and `rev`, so the step was never exercised against the table any release wrote, and a positional INSERT bound to the wrong columns. - `openedOrNull`, `SshKeyAlgorithm.isSlow` and `copyWith`'s `clearComment` had no callers; `sshKeyGenerating` was translated into 15 locales and shown nowhere. The first three are gone, the string is now on the page — RSA-4096 takes seconds on a phone and a spinning button says nothing. - The v8 line in the schema log, and `unlockKeys` moved out of the field block. --- lib/core/utils/server.dart | 6 +- lib/core/utils/ssh_key_unlock.dart | 103 +++++++++++++----- lib/core/utils/ssh_keygen.dart | 17 ++- lib/data/model/app/bak/backup.dart | 7 ++ lib/data/model/app/bak/backup2.dart | 5 + lib/data/model/file/file_ref.dart | 3 +- lib/data/model/server/private_key_info.dart | 6 +- lib/data/store/schema.dart | 2 + lib/view/page/private_key/edit.dart | 27 +++-- lib/view/page/private_key/generate.dart | 24 ++++- test/m007_private_key_comment_test.dart | 27 +++-- test/ssh_key_unlock_test.dart | 114 +++++++++++++++----- 12 files changed, 260 insertions(+), 81 deletions(-) diff --git a/lib/core/utils/server.dart b/lib/core/utils/server.dart index cefa044fe1..292c9d4c33 100644 --- a/lib/core/utils/server.dart +++ b/lib/core/utils/server.dart @@ -345,7 +345,11 @@ Future _authenticatedClient({ ); return SSHClient( socket, - username: ssh.user, + // The same fallback user the password branch above uses. Key auth read + // `ssh.user` regardless, so a server reached through its `alterUrl` — which + // is where `alterUser` comes from — authenticated as the primary host's + // user and failed with a permission error naming neither. + username: alterUser ?? ssh.user, // Must use [compute] here, instead of [Computer.shared.start] identities: await compute(loadIdentity, privateKey), onPasswordRequest: ssh.pwd?.isNotEmpty == true ? () => ssh.pwd : null, diff --git a/lib/core/utils/ssh_key_unlock.dart b/lib/core/utils/ssh_key_unlock.dart index c9c1e736be..a2a27662e2 100644 --- a/lib/core/utils/ssh_key_unlock.dart +++ b/lib/core/utils/ssh_key_unlock.dart @@ -34,6 +34,21 @@ abstract final class PrivateKeyUnlock { /// one at the same moment produce one dialog rather than a stack of them. static final _inFlight = >{}; + /// Keys whose prompt was refused, or answered wrongly until it gave up. + /// + /// Without this, a refusal is forgotten the moment it happens and the next + /// reconnect asks again — and the status poller reconnects on a timer, so + /// declining once means a dialog per poll per server sharing the key. + /// Cleared by [forget], which is what editing the key does. + static final _declined = {}; + + /// Bumped by [forget]. An ask already on screen when the key is replaced + /// answers for bytes that are no longer stored, and dropping it from + /// [_inFlight] only stops *new* callers joining it — the one already running + /// still returns, and would otherwise put the old key in the cache for every + /// connection after it. + static final _generation = {}; + /// How many times a wrong passphrase may be given before the attempt is /// abandoned. Not a security limit — the person can start again — it is what /// stops a loop with no way out when the dialog cannot be shown. @@ -71,6 +86,10 @@ abstract final class PrivateKeyUnlock { final already = _opened[cacheKey]; if (already != null) return already; + // Asked and refused already. Reported the same way, without putting the + // same dialog up again. + if (_declined.contains(cacheKey)) throw _locked(keyName); + final inFlight = _inFlight[cacheKey]; if (inFlight != null) return inFlight; @@ -83,29 +102,50 @@ abstract final class PrivateKeyUnlock { } } - /// The opened form of [pem] if there is one, and [pem] itself when it needs - /// no opening. - /// - /// Null means "locked, and nobody has opened it". For the callers that build - /// credentials for another isolate from a synchronous context and so cannot - /// ask — they report that rather than handing over a key that will fail - /// somewhere with no screen to say so. - static String? openedOrNull(String pem, {required String cacheKey}) { - if (!isLocked(pem)) return pem; - return _opened[cacheKey]; - } - /// Forgets an opened key, which the next connection will ask for again. /// /// Called when the key changes or goes away: the passphrase held here is for /// the bytes that were there when it was given. - static void forget(String cacheKey) => _opened.remove(cacheKey); + static void forget(String cacheKey) { + _opened.remove(cacheKey); + _declined.remove(cacheKey); + // The ask still running was for the bytes that have just been replaced, so + // its answer must not land in the cache afterwards. + _inFlight.remove(cacheKey); + _generation[cacheKey] = (_generation[cacheKey] ?? 0) + 1; + } - static void forgetAll() => _opened.clear(); + static void forgetAll() { + // Bumped, not cleared: clearing would reset every generation to zero and + // let an ask that is still running match again — which is the whole thing + // this counter exists to stop. A restore replaces every key at once, so + // every one of them has a dialog that may be up. + for (final key in {..._opened.keys, ..._inFlight.keys, ..._generation.keys}) { + _generation[key] = (_generation[key] ?? 0) + 1; + } + _opened.clear(); + _declined.clear(); + _inFlight.clear(); + } + + /// Records a key already known to be open, so the next connection does not + /// ask for a passphrase that was just typed. + /// + /// The import page verifies the passphrase it was given; throwing that away + /// and asking again seconds later is the same question twice. + static void remember(String cacheKey, String openedPem) { + _declined.remove(cacheKey); + _opened[cacheKey] = openedPem; + } @visibleForTesting static bool isOpened(String cacheKey) => _opened.containsKey(cacheKey); + static SSHErr _locked(String keyName) => SSHErr( + type: SSHErrType.noPrivateKey, + message: l10n.sshKeyLockedFmt(keyName), + ); + static Future _ask( String pem, { required String cacheKey, @@ -113,14 +153,23 @@ abstract final class PrivateKeyUnlock { }) async { final prompt = promptOverrideForTesting ?? _showDialog; - for (var attempt = 0; attempt < maxAttempts; attempt++) { - final passphrase = await prompt(keyName: keyName, retry: attempt > 0); + final generation = _generation[cacheKey] ?? 0; + var guesses = 0; + var rounds = 0; + // Two counters: an empty field is not a guess — `open` is only reached for + // a key that needs a passphrase, so the empty string cannot be the right + // one and spending an attempt and a full bcrypt round on it would be + // punishing a slip. The outer bound is what stops a dialog that keeps + // answering empty from looping for ever. + while (guesses < maxAttempts && rounds < maxAttempts * 3) { + rounds++; + final passphrase = await prompt(keyName: keyName, retry: guesses > 0); if (passphrase == null) { - throw SSHErr( - type: SSHErrType.noPrivateKey, - message: l10n.sshKeyLockedFmt(keyName), - ); + _declined.add(cacheKey); + throw _locked(keyName); } + if (passphrase.isEmpty) continue; + guesses++; try { // On another isolate: bcrypt_pbkdf is deliberately slow, which is the @@ -130,7 +179,13 @@ abstract final class PrivateKeyUnlock { // is not in the transfer isolate — which reaches this file through // `genClient` — nor under `flutter test`. final opened = await compute(decryptPem, [pem, passphrase]); - _opened[cacheKey] = opened; + // Cached only if the key is still the one this was asked about. The + // caller gets it either way — it passed those bytes in — but a later + // connection reads whatever is stored now, and must not be handed the + // key that was replaced while this dialog was up. + if ((_generation[cacheKey] ?? 0) == generation) { + _opened[cacheKey] = opened; + } return opened; } on SSHKeyDecryptError { // Round again, saying so. Any other failure is not about the @@ -139,10 +194,8 @@ abstract final class PrivateKeyUnlock { } } - throw SSHErr( - type: SSHErrType.noPrivateKey, - message: l10n.sshKeyLockedFmt(keyName), - ); + _declined.add(cacheKey); + throw _locked(keyName); } static Future _showDialog({ diff --git a/lib/core/utils/ssh_keygen.dart b/lib/core/utils/ssh_keygen.dart index e98db6c0a7..b528ddcd45 100644 --- a/lib/core/utils/ssh_keygen.dart +++ b/lib/core/utils/ssh_keygen.dart @@ -29,10 +29,6 @@ enum SshKeyAlgorithm { ecdsaP256 => 'ecdsa-sha2-nistp256', rsa2048 || rsa4096 => 'ssh-rsa', }; - - /// Roughly how long generating one takes, which is the only reason a person - /// would want to know: RSA searches for primes and the others do not. - bool get isSlow => this == rsa2048 || this == rsa4096; } /// A key pair that has just been made, in the two forms it is needed in. @@ -206,11 +202,22 @@ class SshKeyDigest { bool get isEmpty => keyType == null && fingerprint == null && comment == null; } +/// Remembers what each key said, keyed by the key itself. +/// +/// This is called while building a list row, so once per key per frame. It is +/// not only a hash: for a key that is not encrypted it decodes the private +/// blob, which for RSA-4096 means reading six mpints into BigInts. Bounded by +/// the number of keys, and a changed key is a different string. +final _describeCache = {}; + /// Reads [pem] for what can be shown about it in a list. /// /// Never throws and never asks for a passphrase: this is for a subtitle, and a /// key that cannot be read is one whose subtitle is empty, not an error. -SshKeyDigest describeSshKey(String pem) { +SshKeyDigest describeSshKey(String pem) => + _describeCache[pem] ??= _describeSshKey(pem); + +SshKeyDigest _describeSshKey(String pem) { try { final decoded = SSHPem.decode(pem); if (decoded.type == 'OPENSSH PRIVATE KEY') { diff --git a/lib/data/model/app/bak/backup.dart b/lib/data/model/app/bak/backup.dart index c4de60ce97..7482e4124f 100644 --- a/lib/data/model/app/bak/backup.dart +++ b/lib/data/model/app/bak/backup.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'package:fl_lib/fl_lib.dart'; import 'package:json_annotation/json_annotation.dart'; import 'package:logging/logging.dart'; +import 'package:server_box/core/utils/ssh_key_unlock.dart'; import 'package:server_box/data/model/server/private_key_info.dart'; import 'package:server_box/data/model/server/server_private_info.dart'; import 'package:server_box/data/model/server/snippet.dart'; @@ -66,6 +67,12 @@ class Backup implements Mergeable { // for the entire file, so there is nothing to compare a single record // against. Ordered by what references what — a server names a private key, // and a snippet names a server. + // Every stored key is about to be replaced by whatever the file holds, so + // nothing opened this run describes what is in the database any more. A + // stale entry here is not a stale display — it is a connection that goes on + // authenticating with the key the restore just removed. + PrivateKeyUnlock.forgetAll(); + SqliteStore.transact(() { Stores.key.replaceAll(keys); Stores.server.replaceAll(spis); diff --git a/lib/data/model/app/bak/backup2.dart b/lib/data/model/app/bak/backup2.dart index b8375c61e9..b5da9e1415 100644 --- a/lib/data/model/app/bak/backup2.dart +++ b/lib/data/model/app/bak/backup2.dart @@ -4,6 +4,7 @@ import 'dart:io'; import 'package:fl_lib/fl_lib.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:logging/logging.dart'; +import 'package:server_box/core/utils/ssh_key_unlock.dart'; import 'package:server_box/data/model/server/bmc_cfg.dart'; import 'package:server_box/data/model/server/bmc_credential.dart'; import 'package:server_box/data/model/server/custom.dart'; @@ -83,6 +84,10 @@ abstract class BackupV2 with _$BackupV2 implements Mergeable { // is a child of one. Merging a store before the one it points at would drop // every record whose foreign key has not arrived yet. final keysChanged = Stores.key.merge(keys, force: force); + // A key whose bytes arrived from the other side is not the key that was + // opened here. Left in the cache, the next connection would authenticate + // with the one this merge replaced and say nothing about it. + if (keysChanged) PrivateKeyUnlock.forgetAll(); final credsChanged = Stores.bmcCredential.merge(bmcCredentials, force: force); final serversChanged = Stores.server.merge( _serversWithRestoredIds(), diff --git a/lib/data/model/file/file_ref.dart b/lib/data/model/file/file_ref.dart index fa2e51cec2..23ec3a9f8a 100644 --- a/lib/data/model/file/file_ref.dart +++ b/lib/data/model/file/file_ref.dart @@ -224,6 +224,8 @@ class SshTransferCreds { Map? jumpSpisById; Map? privateKeysByKeyId; + Map? knownHostFingerprints; + /// Opens any key in this bundle that is stored encrypted. /// /// Not in the constructor, for two reasons that point the same way: asking @@ -252,5 +254,4 @@ class SshTransferCreds { final jumpRef = jumpSpi?.ssh?.keyRef; if (jumpRef != null) jumpPrivateKey = keys[jumpRef] ?? jumpPrivateKey; } - Map? knownHostFingerprints; } diff --git a/lib/data/model/server/private_key_info.dart b/lib/data/model/server/private_key_info.dart index 7131d87602..44818c2b9e 100644 --- a/lib/data/model/server/private_key_info.dart +++ b/lib/data/model/server/private_key_info.dart @@ -46,16 +46,12 @@ class PrivateKeyInfo { String? id, String? name, String? key, - // Positional-ish: `null` means "leave it", and clearing is what - // `clearComment` is for — a nullable field cannot say both with one - // parameter. String? comment, - bool clearComment = false, }) => PrivateKeyInfo( id: id ?? this.id, name: name ?? this.name, key: key ?? this.key, - comment: clearComment ? null : (comment ?? this.comment), + comment: comment ?? this.comment, ); String? get type { diff --git a/lib/data/store/schema.dart b/lib/data/store/schema.dart index c0108bfa2d..642bf3bd95 100644 --- a/lib/data/store/schema.dart +++ b/lib/data/store/schema.dart @@ -63,6 +63,8 @@ abstract final class SchemaVersion { /// and per-row sync metadata /// v6: per-monitor explicit permission for plaintext HTTP on trusted networks /// v7: the BMC side channel's columns on `server` + /// v8: `private_key.comment`, so a key's label can be edited without + /// opening the key to rewrite the copy inside it static const current = 8; /// Persisted locally, never included in a backup: it describes *this diff --git a/lib/view/page/private_key/edit.dart b/lib/view/page/private_key/edit.dart index 2a0af8cf53..c23cdaf854 100644 --- a/lib/view/page/private_key/edit.dart +++ b/lib/view/page/private_key/edit.dart @@ -1,8 +1,8 @@ import 'dart:io'; -import 'package:computer/computer.dart'; import 'package:dartssh2/dartssh2.dart'; import 'package:fl_lib/fl_lib.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -156,8 +156,10 @@ class _PrivateKeyEditPageState extends ConsumerState { ); line = publicKeyLine( SSHKeyPair.fromPem(opened).first, - // What the list shows, and what the server will see beside the key. - pki.comment ?? describeSshKey(pki.key).comment ?? pki.name, + // Read from `opened`, not from the stored bytes: for an encrypted key + // the comment is inside the part that was just decrypted, and asking + // the locked form yields nothing. + pki.comment ?? describeSshKey(opened).comment ?? pki.name, ); } catch (e) { Toast.error(e.toString()); @@ -359,12 +361,18 @@ class _PrivateKeyEditPageState extends ConsumerState { // passphrase is what protects it, and stripping it here left every // imported key lying in the database in the clear. // + // Parsed either way, which is what rejects a key that is not one. The + // old code got that for free from always decrypting; guarding the call + // on "is it encrypted" lost it, because `isLocked` answers false for + // anything it cannot read rather than throwing. + // // A passphrase typed alongside it is checked rather than applied: a typo // found now says so on this page, where it can be fixed, instead of at // the next connection as a key that will not open. - if (pwd.isNotEmpty && PrivateKeyUnlock.isLocked(key)) { - await Computer.shared.start(decryptPem, [key, pwd]); - } + // + // `compute`, not `Computer.shared`, for the same reason the unlocker + // uses it: one that has to be turned on cannot be called from a test. + final opened = await compute(decryptPem, [key, pwd]); // The id of the record being edited: renaming a key must not detach the // servers pointing at it, which is what happened when the two were one // value. @@ -378,8 +386,13 @@ class _PrivateKeyEditPageState extends ConsumerState { comment: comment.isEmpty ? null : comment, ); // The bytes may have changed under an id that has not, so whatever was - // opened for it no longer describes what is stored. + // opened for it no longer describes what is stored — and then the + // passphrase just verified is put back, rather than asking for it again + // seconds later on the first connection. PrivateKeyUnlock.forget(pki.id); + if (pwd.isNotEmpty && opened != key) { + PrivateKeyUnlock.remember(pki.id, opened); + } final originPki = this.pki; if (originPki != null) { await _notifier.update(originPki, pki); diff --git a/lib/view/page/private_key/generate.dart b/lib/view/page/private_key/generate.dart index 965b175c61..8396e270dc 100644 --- a/lib/view/page/private_key/generate.dart +++ b/lib/view/page/private_key/generate.dart @@ -142,6 +142,13 @@ class _PrivateKeyGeneratePageState padding: const EdgeInsets.symmetric(horizontal: 12), child: Text(l10n.sshKeyPassphraseTip, style: UIs.textGrey), ), + // RSA searches for primes and takes seconds on a phone. A spinning + // button with nothing beside it reads as a button that did not work. + if (_working) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Text(l10n.sshKeyGenerating, style: UIs.textGrey), + ), ], ); } @@ -204,13 +211,14 @@ class _PrivateKeyGeneratePageState FocusScope.of(context).unfocus(); setState(() => _working = true); try { - final comment = _commentController.text.trim(); + final typed = _commentController.text.trim(); + // The name is what the person will recognise it by, and an OpenSSH + // comment with nothing in it tells whoever reads `authorized_keys` later + // nothing about where the key came from. + final comment = typed.isEmpty ? name : typed; final key = await generateSshKey( algorithm: _algorithm, - // The name is what the person will recognise it by, and an OpenSSH - // comment with nothing in it tells whoever reads `authorized_keys` - // later nothing about where the key came from. - comment: comment.isEmpty ? name : comment, + comment: comment, passphrase: _pwdController.text.isEmpty ? null : _pwdController.text, ); await ref @@ -220,6 +228,12 @@ class _PrivateKeyGeneratePageState id: ShortId.generate(), name: name, key: key.privatePem, + // Stored as well as written into the key. For a key with a + // passphrase the copy inside it cannot be read back — it is in + // the part that gets encrypted — so without this the list would + // show no comment and the public key line offered later would + // not be the one that was just copied. + comment: comment, ), ); if (!mounted) return; diff --git a/test/m007_private_key_comment_test.dart b/test/m007_private_key_comment_test.dart index 812403ec01..2579b62efa 100644 --- a/test/m007_private_key_comment_test.dart +++ b/test/m007_private_key_comment_test.dart @@ -12,11 +12,19 @@ void main() { setUp(SqliteDb.openInMemory); tearDown(SqliteDb.close); - /// The v7 shape: no comment column. + /// The v7 shape. + /// + /// Column for column what a v7 release wrote, `updated_at` and `rev` + /// included: `PrivateKeys` mixes in `SyncMeta`, so those two come first in + /// the real table. A hand-written shape missing them would let this pass + /// while saying nothing about the state sync depends on — and a positional + /// INSERT below would bind to the wrong columns. void createV7PrivateKey() { SqliteDb.instance.execute( 'CREATE TABLE private_key (' - 'id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE, key TEXT NOT NULL' + 'updated_at INTEGER NOT NULL DEFAULT 0, rev INTEGER NOT NULL DEFAULT 0, ' + 'id TEXT NOT NULL PRIMARY KEY, name TEXT NOT NULL UNIQUE, ' + 'key TEXT NOT NULL' ') WITHOUT ROWID;', ); } @@ -29,7 +37,8 @@ void main() { test('adds the column and leaves the keys alone', () async { createV7PrivateKey(); SqliteDb.instance.execute( - "INSERT INTO private_key VALUES ('k-1', 'laptop', '-----BEGIN X-----');", + 'INSERT INTO private_key (updated_at, rev, id, name, key) ' + "VALUES (1700000000, 3, 'k-1', 'laptop', '-----BEGIN X-----');", ); await const PrivateKeyCommentMigration().apply(); @@ -39,6 +48,10 @@ void main() { expect(row['id'], 'k-1'); expect(row['name'], 'laptop'); expect(row['key'], '-----BEGIN X-----'); + // The columns sync reads. An ALTER TABLE leaves them alone, and a step + // that reset either would make every key look freshly edited to a peer. + expect(row['updated_at'], 1700000000); + expect(row['rev'], 3); // Null, not the name and not an empty string: null is what means "whatever // the key itself says", and an empty string would read as "no comment" and // strip the label off every key that had one. @@ -57,12 +70,14 @@ void main() { test('a table that already has the column is left as it is', () async { SqliteDb.instance.execute( 'CREATE TABLE private_key (' - 'id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE, key TEXT NOT NULL, ' - 'comment TEXT' + 'updated_at INTEGER NOT NULL DEFAULT 0, rev INTEGER NOT NULL DEFAULT 0, ' + 'id TEXT NOT NULL PRIMARY KEY, name TEXT NOT NULL UNIQUE, ' + 'key TEXT NOT NULL, comment TEXT' ') WITHOUT ROWID;', ); SqliteDb.instance.execute( - "INSERT INTO private_key VALUES ('k-1', 'laptop', 'pem', 'me@host');", + 'INSERT INTO private_key (updated_at, rev, id, name, key, comment) ' + "VALUES (0, 0, 'k-1', 'laptop', 'pem', 'me@host');", ); await const PrivateKeyCommentMigration().apply(); diff --git a/test/ssh_key_unlock_test.dart b/test/ssh_key_unlock_test.dart index b4191c367d..3968509f6a 100644 --- a/test/ssh_key_unlock_test.dart +++ b/test/ssh_key_unlock_test.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter_test/flutter_test.dart'; import 'package:server_box/core/utils/ssh_key_unlock.dart'; import 'package:server_box/core/utils/ssh_keygen.dart'; @@ -146,32 +148,6 @@ void main() { expect(PrivateKeyUnlock.isOpened('k'), isFalse); }); - group('openedOrNull', () { - test('answers null for a locked key nobody has opened', () { - // What the transfer path reads: it cannot ask, so it has to be able to - // tell "not opened" from "needs no opening". - expect( - PrivateKeyUnlock.openedOrNull(lockedPem, cacheKey: 'k'), - isNull, - ); - expect( - PrivateKeyUnlock.openedOrNull(plainPem, cacheKey: 'k'), - plainPem, - ); - }); - - test('answers the opened key once it has been opened', () async { - PrivateKeyUnlock.promptOverrideForTesting = - ({required keyName, required retry}) async => passphrase; - final opened = await PrivateKeyUnlock.open( - lockedPem, - cacheKey: 'k', - keyName: 'k', - ); - expect(PrivateKeyUnlock.openedOrNull(lockedPem, cacheKey: 'k'), opened); - }); - }); - test('forget makes the next connection ask again', () async { var asked = 0; PrivateKeyUnlock.promptOverrideForTesting = ({required keyName, required retry}) async { @@ -187,6 +163,92 @@ void main() { expect(asked, 2); }); + test('a refusal is remembered, so the poller does not ask again', () async { + var asked = 0; + PrivateKeyUnlock.promptOverrideForTesting = + ({required keyName, required retry}) async { + asked++; + return null; + }; + + for (var i = 0; i < 3; i++) { + await expectLater( + PrivateKeyUnlock.open(lockedPem, cacheKey: 'k', keyName: 'k'), + throwsA(isA()), + ); + } + // Reconnects are on a timer. Asking once per poll, per server sharing the + // key, is a dialog nobody can get out of. + expect(asked, 1); + + // Editing the key is what offers it again. + PrivateKeyUnlock.forget('k'); + await expectLater( + PrivateKeyUnlock.open(lockedPem, cacheKey: 'k', keyName: 'k'), + throwsA(isA()), + ); + expect(asked, 2); + }); + + test('an empty passphrase is not a guess', () async { + final answers = ['', '', passphrase]; + var asked = 0; + PrivateKeyUnlock.promptOverrideForTesting = + ({required keyName, required retry}) async { + // Never a retry: nothing was guessed yet, so saying "wrong + // passphrase" would be reporting a failure that did not happen. + expect(retry, isFalse); + return answers[asked++]; + }; + + final opened = await PrivateKeyUnlock.open( + lockedPem, + cacheKey: 'k', + keyName: 'k', + ); + expect(PrivateKeyUnlock.isLocked(opened), isFalse); + expect(asked, 3, reason: 'the two empty answers cost no attempts'); + }); + + test('editing a key mid-prompt does not let the old answer land', () async { + // The dialog is up when the key is replaced. Its answer describes bytes + // that are no longer stored, and caching it would authenticate every later + // connection with the key that was just replaced. + final released = Completer(); + PrivateKeyUnlock.promptOverrideForTesting = + ({required keyName, required retry}) async { + await released.future; + return passphrase; + }; + + final pending = PrivateKeyUnlock.open( + lockedPem, + cacheKey: 'k', + keyName: 'k', + ); + PrivateKeyUnlock.forget('k'); + released.complete(); + await pending; + + expect(PrivateKeyUnlock.isOpened('k'), isFalse); + }); + + test('remember seeds what the import page already verified', () async { + var asked = 0; + PrivateKeyUnlock.promptOverrideForTesting = + ({required keyName, required retry}) async { + asked++; + return passphrase; + }; + + PrivateKeyUnlock.remember('k', plainPem); + expect( + await PrivateKeyUnlock.open(lockedPem, cacheKey: 'k', keyName: 'k'), + plainPem, + ); + expect(asked, 0, reason: 'the passphrase was typed seconds ago'); + }); + test('unreadable input is not locked, so it fails where it is parsed', () { // `isLocked` decides whether to put up a dialog. A key that is not a key // must not turn into a passphrase prompt. From bbc40547204fbd102e493ed8d3f050b4415f6af6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?lollipopkit=F0=9F=8F=B3=EF=B8=8F=E2=80=8D=E2=9A=A7?= =?UTF-8?q?=EF=B8=8F?= <10864310+lollipopkit@users.noreply.github.com> Date: Mon, 24 Aug 2026 00:10:18 +0800 Subject: [PATCH 06/11] fix(ssh): stop the host key fingerprint labels drifting back to MD5 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #1333 reported the host key prompt showing hex that decodes to `SHA256:…` — the ASCII of the fingerprint string, hex-encoded, under a label reading "MD5 hex". Both halves were fixed in #1318 and are in v1.0.1491, which is still pre-release; the reporter is on v1.0.1466, which is `Latest`. What is left is what would let it come back. `sshHostKeyFingerprintMd5Hex` still said Md5Hex in its key name while its text said SHA256 in all fifteen locales. A translator works from the key name, so the next one through could have "corrected" the text to match — which is the bug, reintroduced by someone doing their job. Renamed to `sshHostKeyFingerprint`. `sshHostKeyFingerprintMd5Base64` had no caller in lib and was still translated fifteen times. Dropped. `HostKeyPromptInfo`'s four `@Deprecated` shims — `fingerprintHex`, `fingerprintBase64` and `previousFingerprintHex` — had no production caller either; the only code still passing them was two tests, which now pass an OpenSSH fingerprint like everything else. `TransferHostKeyAccepted .fingerprintHex` keeps colon-hex in its name and an OpenSSH fingerprint in its value; renamed, with a line saying what it holds. Also adds the two reporters to `GithubIds.participants`. --- lib/core/utils/server.dart | 21 ++++----------------- lib/data/model/file/transfer_worker.dart | 14 +++++++++----- lib/data/res/github_id.dart | 4 +++- lib/generated/l10n/l10n.dart | 10 ++-------- lib/generated/l10n/l10n_de.dart | 7 +------ lib/generated/l10n/l10n_en.dart | 7 +------ lib/generated/l10n/l10n_es.dart | 7 +------ lib/generated/l10n/l10n_fr.dart | 7 +------ lib/generated/l10n/l10n_id.dart | 7 +------ lib/generated/l10n/l10n_it.dart | 7 +------ lib/generated/l10n/l10n_ja.dart | 7 +------ lib/generated/l10n/l10n_ko.dart | 7 +------ lib/generated/l10n/l10n_nl.dart | 7 +------ lib/generated/l10n/l10n_pt.dart | 7 +------ lib/generated/l10n/l10n_ru.dart | 7 +------ lib/generated/l10n/l10n_tr.dart | 7 +------ lib/generated/l10n/l10n_uk.dart | 7 +------ lib/generated/l10n/l10n_zh.dart | 14 ++------------ lib/l10n/app_de.arb | 3 +-- lib/l10n/app_en.arb | 3 +-- lib/l10n/app_es.arb | 3 +-- lib/l10n/app_fr.arb | 3 +-- lib/l10n/app_id.arb | 3 +-- lib/l10n/app_it.arb | 3 +-- lib/l10n/app_ja.arb | 3 +-- lib/l10n/app_ko.arb | 3 +-- lib/l10n/app_nl.arb | 3 +-- lib/l10n/app_pt.arb | 3 +-- lib/l10n/app_ru.arb | 3 +-- lib/l10n/app_tr.arb | 3 +-- lib/l10n/app_uk.arb | 3 +-- lib/l10n/app_zh.arb | 3 +-- lib/l10n/app_zh_tw.arb | 3 +-- test/host_key_prompt_test.dart | 8 ++++---- test/ssh_auth_test.dart | 3 +-- 35 files changed, 53 insertions(+), 157 deletions(-) diff --git a/lib/core/utils/server.dart b/lib/core/utils/server.dart index 292c9d4c33..8d0f4c10a4 100644 --- a/lib/core/utils/server.dart +++ b/lib/core/utils/server.dart @@ -405,14 +405,10 @@ class HostKeyPromptInfo { HostKeyPromptInfo({ required this.spi, required this.keyType, - String? fingerprint, - @Deprecated('Use fingerprint') String? fingerprintHex, - @Deprecated('Use fingerprint') String? fingerprintBase64, + required this.fingerprint, required this.isMismatch, - String? previousFingerprint, - @Deprecated('Use previousFingerprint') String? previousFingerprintHex, - }) : fingerprint = fingerprint ?? fingerprintHex ?? fingerprintBase64 ?? '', - previousFingerprint = previousFingerprint ?? previousFingerprintHex; + this.previousFingerprint, + }); final Spi spi; final String keyType; @@ -420,15 +416,6 @@ class HostKeyPromptInfo { final String fingerprint; final bool isMismatch; final String? previousFingerprint; - - @Deprecated('Use fingerprint') - String get fingerprintHex => fingerprint; - - @Deprecated('Use fingerprint') - String get fingerprintBase64 => fingerprint; - - @Deprecated('Use previousFingerprint') - String? get previousFingerprintHex => previousFingerprint; } /// What `onVerifyHostKey` decides, and what it writes down when it decides it. @@ -681,7 +668,7 @@ Future _showHostKeyDialog( SelectableText('${libL10n.server}: ${info.spi.name}'), SelectableText('${libL10n.addr}: $hostLine'), SelectableText('${l10n.sshHostKeyType}: ${info.keyType}'), - SelectableText(l10n.sshHostKeyFingerprintMd5Hex(info.fingerprint)), + SelectableText(l10n.sshHostKeyFingerprint(info.fingerprint)), if (info.previousFingerprint != null) ...[ const SizedBox(height: 12), SelectableText( diff --git a/lib/data/model/file/transfer_worker.dart b/lib/data/model/file/transfer_worker.dart index f15c82650f..12691743ec 100644 --- a/lib/data/model/file/transfer_worker.dart +++ b/lib/data/model/file/transfer_worker.dart @@ -83,11 +83,15 @@ class TransferStaging { class TransferHostKeyAccepted { final String storageKey; - final String fingerprintHex; + + /// OpenSSH-style, `SHA256:` — the same string + /// `HostKeyPromptInfo.fingerprint` carries. Was `fingerprintHex` back when + /// it held colon-separated hex, and the name outlived the format. + final String fingerprint; const TransferHostKeyAccepted({ required this.storageKey, - required this.fingerprintHex, + required this.fingerprint, }); } @@ -117,11 +121,11 @@ Future _connectSsh( onKeyboardInteractive: (server, request) => _requestKeyboardInteractive(mainSendPort, server, request), onHostKeyPrompt: (info) => _requestHostKey(mainSendPort, info), - onHostKeyAccepted: (storageKey, fingerprintHex) { + onHostKeyAccepted: (storageKey, fingerprint) { mainSendPort.send( TransferHostKeyAccepted( storageKey: storageKey, - fingerprintHex: fingerprintHex, + fingerprint: fingerprint, ), ); }, @@ -246,7 +250,7 @@ class FileTransferWorker { case final TransferHostKeyAccepted accepted: await persistHostKeyFingerprint( accepted.storageKey, - accepted.fingerprintHex, + accepted.fingerprint, ); return; default: diff --git a/lib/data/res/github_id.dart b/lib/data/res/github_id.dart index 0f03ba1ff6..00a3a2277c 100644 --- a/lib/data/res/github_id.dart +++ b/lib/data/res/github_id.dart @@ -194,7 +194,9 @@ abstract final class GithubIds { 'PeterSpiegler', '13584452567', 'TimeRainStarSky', - 'ArindamBhatta' + 'ArindamBhatta', + 'LiuShu3', + 'rayangl' }; } diff --git a/lib/generated/l10n/l10n.dart b/lib/generated/l10n/l10n.dart index ee2f4e0c6c..200ff0636d 100644 --- a/lib/generated/l10n/l10n.dart +++ b/lib/generated/l10n/l10n.dart @@ -2054,17 +2054,11 @@ abstract class AppLocalizations { /// **'The SSH host key changed for {serverName}. Only continue if you trust this server.'** String sshHostKeyChangedDesc(Object serverName); - /// No description provided for @sshHostKeyFingerprintMd5Base64. - /// - /// In en, this message translates to: - /// **'Fingerprint (MD5 base64): {fingerprint}'** - String sshHostKeyFingerprintMd5Base64(Object fingerprint); - - /// No description provided for @sshHostKeyFingerprintMd5Hex. + /// No description provided for @sshHostKeyFingerprint. /// /// In en, this message translates to: /// **'Fingerprint (SHA256): {fingerprint}'** - String sshHostKeyFingerprintMd5Hex(Object fingerprint); + String sshHostKeyFingerprint(Object fingerprint); /// Label for the SSH host key type displayed in the host key verification dialog. /// diff --git a/lib/generated/l10n/l10n_de.dart b/lib/generated/l10n/l10n_de.dart index 6315f5015e..ed36050920 100644 --- a/lib/generated/l10n/l10n_de.dart +++ b/lib/generated/l10n/l10n_de.dart @@ -1138,12 +1138,7 @@ class AppLocalizationsDe extends AppLocalizations { } @override - String sshHostKeyFingerprintMd5Base64(Object fingerprint) { - return 'Fingerabdruck (MD5 Base64): $fingerprint'; - } - - @override - String sshHostKeyFingerprintMd5Hex(Object fingerprint) { + String sshHostKeyFingerprint(Object fingerprint) { return 'Fingerabdruck (SHA256): $fingerprint'; } diff --git a/lib/generated/l10n/l10n_en.dart b/lib/generated/l10n/l10n_en.dart index b543608607..5dc8159afe 100644 --- a/lib/generated/l10n/l10n_en.dart +++ b/lib/generated/l10n/l10n_en.dart @@ -1123,12 +1123,7 @@ class AppLocalizationsEn extends AppLocalizations { } @override - String sshHostKeyFingerprintMd5Base64(Object fingerprint) { - return 'Fingerprint (MD5 base64): $fingerprint'; - } - - @override - String sshHostKeyFingerprintMd5Hex(Object fingerprint) { + String sshHostKeyFingerprint(Object fingerprint) { return 'Fingerprint (SHA256): $fingerprint'; } diff --git a/lib/generated/l10n/l10n_es.dart b/lib/generated/l10n/l10n_es.dart index f3e8ec7a29..24c5516c6d 100644 --- a/lib/generated/l10n/l10n_es.dart +++ b/lib/generated/l10n/l10n_es.dart @@ -1147,12 +1147,7 @@ class AppLocalizationsEs extends AppLocalizations { } @override - String sshHostKeyFingerprintMd5Base64(Object fingerprint) { - return 'Huella (MD5 Base64): $fingerprint'; - } - - @override - String sshHostKeyFingerprintMd5Hex(Object fingerprint) { + String sshHostKeyFingerprint(Object fingerprint) { return 'Huella (SHA256): $fingerprint'; } diff --git a/lib/generated/l10n/l10n_fr.dart b/lib/generated/l10n/l10n_fr.dart index 5b81c01600..58bb3bcf71 100644 --- a/lib/generated/l10n/l10n_fr.dart +++ b/lib/generated/l10n/l10n_fr.dart @@ -1146,12 +1146,7 @@ class AppLocalizationsFr extends AppLocalizations { } @override - String sshHostKeyFingerprintMd5Base64(Object fingerprint) { - return 'Empreinte (MD5 Base64) : $fingerprint'; - } - - @override - String sshHostKeyFingerprintMd5Hex(Object fingerprint) { + String sshHostKeyFingerprint(Object fingerprint) { return 'Empreinte (SHA256) : $fingerprint'; } diff --git a/lib/generated/l10n/l10n_id.dart b/lib/generated/l10n/l10n_id.dart index 1b1ef7e83e..dfbc7e7084 100644 --- a/lib/generated/l10n/l10n_id.dart +++ b/lib/generated/l10n/l10n_id.dart @@ -1131,12 +1131,7 @@ class AppLocalizationsId extends AppLocalizations { } @override - String sshHostKeyFingerprintMd5Base64(Object fingerprint) { - return 'Sidik jari (MD5 Base64): $fingerprint'; - } - - @override - String sshHostKeyFingerprintMd5Hex(Object fingerprint) { + String sshHostKeyFingerprint(Object fingerprint) { return 'Sidik jari (SHA256): $fingerprint'; } diff --git a/lib/generated/l10n/l10n_it.dart b/lib/generated/l10n/l10n_it.dart index 5cb9b57191..74bce9e455 100644 --- a/lib/generated/l10n/l10n_it.dart +++ b/lib/generated/l10n/l10n_it.dart @@ -1139,12 +1139,7 @@ class AppLocalizationsIt extends AppLocalizations { } @override - String sshHostKeyFingerprintMd5Base64(Object fingerprint) { - return 'Impronta digitale (MD5 base64): $fingerprint'; - } - - @override - String sshHostKeyFingerprintMd5Hex(Object fingerprint) { + String sshHostKeyFingerprint(Object fingerprint) { return 'Impronta digitale (SHA256): $fingerprint'; } diff --git a/lib/generated/l10n/l10n_ja.dart b/lib/generated/l10n/l10n_ja.dart index e5c8effc06..57c3fecce3 100644 --- a/lib/generated/l10n/l10n_ja.dart +++ b/lib/generated/l10n/l10n_ja.dart @@ -1071,12 +1071,7 @@ class AppLocalizationsJa extends AppLocalizations { } @override - String sshHostKeyFingerprintMd5Base64(Object fingerprint) { - return 'フィンガープリント (MD5 Base64): $fingerprint'; - } - - @override - String sshHostKeyFingerprintMd5Hex(Object fingerprint) { + String sshHostKeyFingerprint(Object fingerprint) { return 'フィンガープリント (SHA256): $fingerprint'; } diff --git a/lib/generated/l10n/l10n_ko.dart b/lib/generated/l10n/l10n_ko.dart index ff8e70628b..d2833d496c 100644 --- a/lib/generated/l10n/l10n_ko.dart +++ b/lib/generated/l10n/l10n_ko.dart @@ -1071,12 +1071,7 @@ class AppLocalizationsKo extends AppLocalizations { } @override - String sshHostKeyFingerprintMd5Base64(Object fingerprint) { - return '지문 (MD5 base64): $fingerprint'; - } - - @override - String sshHostKeyFingerprintMd5Hex(Object fingerprint) { + String sshHostKeyFingerprint(Object fingerprint) { return '지문 (SHA256): $fingerprint'; } diff --git a/lib/generated/l10n/l10n_nl.dart b/lib/generated/l10n/l10n_nl.dart index 6689e16a71..0498073a31 100644 --- a/lib/generated/l10n/l10n_nl.dart +++ b/lib/generated/l10n/l10n_nl.dart @@ -1135,12 +1135,7 @@ class AppLocalizationsNl extends AppLocalizations { } @override - String sshHostKeyFingerprintMd5Base64(Object fingerprint) { - return 'Vingerafdruk (MD5 Base64): $fingerprint'; - } - - @override - String sshHostKeyFingerprintMd5Hex(Object fingerprint) { + String sshHostKeyFingerprint(Object fingerprint) { return 'Vingerafdruk (SHA256): $fingerprint'; } diff --git a/lib/generated/l10n/l10n_pt.dart b/lib/generated/l10n/l10n_pt.dart index 96fb45db11..2c29019b47 100644 --- a/lib/generated/l10n/l10n_pt.dart +++ b/lib/generated/l10n/l10n_pt.dart @@ -1132,12 +1132,7 @@ class AppLocalizationsPt extends AppLocalizations { } @override - String sshHostKeyFingerprintMd5Base64(Object fingerprint) { - return 'Impressão digital (MD5 Base64): $fingerprint'; - } - - @override - String sshHostKeyFingerprintMd5Hex(Object fingerprint) { + String sshHostKeyFingerprint(Object fingerprint) { return 'Impressão digital (SHA256): $fingerprint'; } diff --git a/lib/generated/l10n/l10n_ru.dart b/lib/generated/l10n/l10n_ru.dart index 5b5f3c5d12..10536e576e 100644 --- a/lib/generated/l10n/l10n_ru.dart +++ b/lib/generated/l10n/l10n_ru.dart @@ -1139,12 +1139,7 @@ class AppLocalizationsRu extends AppLocalizations { } @override - String sshHostKeyFingerprintMd5Base64(Object fingerprint) { - return 'Отпечаток (MD5 Base64): $fingerprint'; - } - - @override - String sshHostKeyFingerprintMd5Hex(Object fingerprint) { + String sshHostKeyFingerprint(Object fingerprint) { return 'Отпечаток (SHA256): $fingerprint'; } diff --git a/lib/generated/l10n/l10n_tr.dart b/lib/generated/l10n/l10n_tr.dart index ff0a970179..e56dca3d1f 100644 --- a/lib/generated/l10n/l10n_tr.dart +++ b/lib/generated/l10n/l10n_tr.dart @@ -1132,12 +1132,7 @@ class AppLocalizationsTr extends AppLocalizations { } @override - String sshHostKeyFingerprintMd5Base64(Object fingerprint) { - return 'Parmak izi (MD5 Base64): $fingerprint'; - } - - @override - String sshHostKeyFingerprintMd5Hex(Object fingerprint) { + String sshHostKeyFingerprint(Object fingerprint) { return 'Parmak izi (SHA256): $fingerprint'; } diff --git a/lib/generated/l10n/l10n_uk.dart b/lib/generated/l10n/l10n_uk.dart index 340ebacdbd..2bc914488e 100644 --- a/lib/generated/l10n/l10n_uk.dart +++ b/lib/generated/l10n/l10n_uk.dart @@ -1136,12 +1136,7 @@ class AppLocalizationsUk extends AppLocalizations { } @override - String sshHostKeyFingerprintMd5Base64(Object fingerprint) { - return 'Відбиток (MD5 Base64): $fingerprint'; - } - - @override - String sshHostKeyFingerprintMd5Hex(Object fingerprint) { + String sshHostKeyFingerprint(Object fingerprint) { return 'Відбиток (SHA256): $fingerprint'; } diff --git a/lib/generated/l10n/l10n_zh.dart b/lib/generated/l10n/l10n_zh.dart index e8c1be6c8d..0e2e1a61aa 100644 --- a/lib/generated/l10n/l10n_zh.dart +++ b/lib/generated/l10n/l10n_zh.dart @@ -1051,12 +1051,7 @@ class AppLocalizationsZh extends AppLocalizations { } @override - String sshHostKeyFingerprintMd5Base64(Object fingerprint) { - return '指纹(MD5 Base64):$fingerprint'; - } - - @override - String sshHostKeyFingerprintMd5Hex(Object fingerprint) { + String sshHostKeyFingerprint(Object fingerprint) { return '指纹(SHA256):$fingerprint'; } @@ -2507,12 +2502,7 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { } @override - String sshHostKeyFingerprintMd5Base64(Object fingerprint) { - return '指紋(MD5 Base64):$fingerprint'; - } - - @override - String sshHostKeyFingerprintMd5Hex(Object fingerprint) { + String sshHostKeyFingerprint(Object fingerprint) { return '指紋(SHA256):$fingerprint'; } diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 4c7602e8e1..056583bedb 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -240,8 +240,7 @@ "sshConfigImportTip": "Bei der ersten Server-Erstellung zum Lesen von ~/.ssh/config auffordern", "sshConfigImported": "{count} Server aus SSH-Konfiguration importiert", "sshHostKeyChangedDesc": "Der SSH-Hostschlüssel für {serverName} hat sich geändert. Fahren Sie nur fort, wenn Sie diesem Server vertrauen.", - "sshHostKeyFingerprintMd5Base64": "Fingerabdruck (MD5 Base64): {fingerprint}", - "sshHostKeyFingerprintMd5Hex": "Fingerabdruck (SHA256): {fingerprint}", + "sshHostKeyFingerprint": "Fingerabdruck (SHA256): {fingerprint}", "sshHostKeyType": "SSH-Hostschlüsseltyp", "sshKnownHostKeys": "Bekannte Hosts", "sshKnownHostKeysTip": "Die Host-Schlüssel, die diese App akzeptiert hat", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 9e254eee68..538e319642 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -414,8 +414,7 @@ "sshConfigImportTip": "Prompt to read ~/.ssh/config on first server creation", "sshConfigImported": "Imported {count} servers from SSH config", "sshHostKeyChangedDesc": "The SSH host key changed for {serverName}. Only continue if you trust this server.", - "sshHostKeyFingerprintMd5Base64": "Fingerprint (MD5 base64): {fingerprint}", - "sshHostKeyFingerprintMd5Hex": "Fingerprint (SHA256): {fingerprint}", + "sshHostKeyFingerprint": "Fingerprint (SHA256): {fingerprint}", "sshHostKeyType": "SSH host key type", "sshKnownHostKeys": "Known hosts", "sshKnownHostKeysTip": "The host keys this app has accepted", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index a8af1a5cba..be7bd7e7b2 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -240,8 +240,7 @@ "sshConfigImportTip": "Sugerencia para leer ~/.ssh/config al crear el primer servidor", "sshConfigImported": "Se importaron {count} servidores desde la configuración SSH", "sshHostKeyChangedDesc": "La clave de host SSH de {serverName} ha cambiado. Continúa solo si confías en este servidor.", - "sshHostKeyFingerprintMd5Base64": "Huella (MD5 Base64): {fingerprint}", - "sshHostKeyFingerprintMd5Hex": "Huella (SHA256): {fingerprint}", + "sshHostKeyFingerprint": "Huella (SHA256): {fingerprint}", "sshHostKeyType": "Tipo de clave de host SSH", "sshKnownHostKeys": "Hosts conocidos", "sshKnownHostKeysTip": "Las claves de host que esta app ha aceptado", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 9d9be6ebdf..ae631523cb 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -240,8 +240,7 @@ "sshConfigImportTip": "Proposer de lire ~/.ssh/config lors de la première création de serveur", "sshConfigImported": "{count} serveurs importés depuis la configuration SSH", "sshHostKeyChangedDesc": "La clé d'hôte SSH de {serverName} a changé. Ne continuez que si vous faites confiance à ce serveur.", - "sshHostKeyFingerprintMd5Base64": "Empreinte (MD5 Base64) : {fingerprint}", - "sshHostKeyFingerprintMd5Hex": "Empreinte (SHA256) : {fingerprint}", + "sshHostKeyFingerprint": "Empreinte (SHA256) : {fingerprint}", "sshHostKeyType": "Type de clé d'hôte SSH", "sshKnownHostKeys": "Hôtes connus", "sshKnownHostKeysTip": "Les clés d’hôte que cette app a acceptées", diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 229d5233cc..c48ee7c256 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -240,8 +240,7 @@ "sshConfigImportTip": "Prompt untuk membaca ~/.ssh/config saat pembuatan server pertama", "sshConfigImported": "Berhasil mengimpor {count} server dari konfigurasi SSH", "sshHostKeyChangedDesc": "Kunci host SSH untuk {serverName} telah berubah. Lanjutkan hanya jika Anda mempercayai server ini.", - "sshHostKeyFingerprintMd5Base64": "Sidik jari (MD5 Base64): {fingerprint}", - "sshHostKeyFingerprintMd5Hex": "Sidik jari (SHA256): {fingerprint}", + "sshHostKeyFingerprint": "Sidik jari (SHA256): {fingerprint}", "sshHostKeyType": "Jenis kunci host SSH", "sshKnownHostKeys": "Host dikenal", "sshKnownHostKeysTip": "Kunci host yang sudah diterima aplikasi ini", diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index 191d3028ff..d4288819bd 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -241,8 +241,7 @@ "sshConfigImportTip": "Chiedi di leggere ~/.ssh/config alla prima creazione del server", "sshConfigImported": "Importati {count} server dalla configurazione SSH", "sshHostKeyChangedDesc": "La chiave host SSH è cambiata per {serverName}. Continua solo se ti fidi di questo server.", - "sshHostKeyFingerprintMd5Base64": "Impronta digitale (MD5 base64): {fingerprint}", - "sshHostKeyFingerprintMd5Hex": "Impronta digitale (SHA256): {fingerprint}", + "sshHostKeyFingerprint": "Impronta digitale (SHA256): {fingerprint}", "sshHostKeyType": "Tipo chiave host SSH", "sshKnownHostKeys": "Host conosciuti", "sshKnownHostKeysTip": "Le chiavi host che questa app ha accettato", diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 714cddae77..95273af3ce 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -240,8 +240,7 @@ "sshConfigImportTip": "初回サーバー作成時に~/.ssh/configの読み取りを促す", "sshConfigImported": "SSH設定から{count}個のサーバーをインポートしました", "sshHostKeyChangedDesc": "{serverName} の SSH ホスト鍵が変更されました。このサーバーを信頼できる場合のみ続行してください。", - "sshHostKeyFingerprintMd5Base64": "フィンガープリント (MD5 Base64): {fingerprint}", - "sshHostKeyFingerprintMd5Hex": "フィンガープリント (SHA256): {fingerprint}", + "sshHostKeyFingerprint": "フィンガープリント (SHA256): {fingerprint}", "sshHostKeyType": "SSH ホストキーの種類", "sshKnownHostKeys": "既知のホスト", "sshKnownHostKeysTip": "このアプリが受け入れたホスト鍵", diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 7408022b2d..bad9878880 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -286,8 +286,7 @@ "sshConfigImportTip": "첫 서버 생성 시 ~/.ssh/config 읽기 안내", "sshConfigImported": "SSH 설정에서 서버 {count}개를 가져왔습니다", "sshHostKeyChangedDesc": "{serverName}의 SSH 호스트 키가 변경되었습니다. 이 서버를 신뢰하는 경우에만 계속 진행하세요.", - "sshHostKeyFingerprintMd5Base64": "지문 (MD5 base64): {fingerprint}", - "sshHostKeyFingerprintMd5Hex": "지문 (SHA256): {fingerprint}", + "sshHostKeyFingerprint": "지문 (SHA256): {fingerprint}", "sshHostKeyType": "SSH 호스트 키 유형", "sshKnownHostKeys": "알려진 호스트", "sshKnownHostKeysTip": "이 앱이 수락한 호스트 키", diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb index 26030a72bd..c43f190b8b 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -240,8 +240,7 @@ "sshConfigImportTip": "Prompt om ~/.ssh/config te lezen bij het aanmaken van de eerste server", "sshConfigImported": "{count} servers geïmporteerd uit SSH-configuratie", "sshHostKeyChangedDesc": "De SSH-hostsleutel voor {serverName} is gewijzigd. Ga alleen verder als u deze server vertrouwt.", - "sshHostKeyFingerprintMd5Base64": "Vingerafdruk (MD5 Base64): {fingerprint}", - "sshHostKeyFingerprintMd5Hex": "Vingerafdruk (SHA256): {fingerprint}", + "sshHostKeyFingerprint": "Vingerafdruk (SHA256): {fingerprint}", "sshHostKeyType": "Type SSH-hostsleutel", "sshKnownHostKeys": "Bekende hosts", "sshKnownHostKeysTip": "De hostsleutels die deze app heeft geaccepteerd", diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index 5470bad7da..631e8eeb2e 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -240,8 +240,7 @@ "sshConfigImportTip": "Sugestão para ler ~/.ssh/config na criação do primeiro servidor", "sshConfigImported": "Importados {count} servidores da configuração SSH", "sshHostKeyChangedDesc": "A chave de host SSH de {serverName} foi alterada. Continue apenas se confiar neste servidor.", - "sshHostKeyFingerprintMd5Base64": "Impressão digital (MD5 Base64): {fingerprint}", - "sshHostKeyFingerprintMd5Hex": "Impressão digital (SHA256): {fingerprint}", + "sshHostKeyFingerprint": "Impressão digital (SHA256): {fingerprint}", "sshHostKeyType": "Tipo de chave de host SSH", "sshKnownHostKeys": "Anfitriões conhecidos", "sshKnownHostKeysTip": "As chaves de host que esta app aceitou", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 7a08dd492f..c84c80fb77 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -240,8 +240,7 @@ "sshConfigImportTip": "Предложение прочитать ~/.ssh/config при создании первого сервера", "sshConfigImported": "Импортировано {count} серверов из SSH-конфигурации", "sshHostKeyChangedDesc": "SSH-ключ хоста для {serverName} изменился. Продолжайте только если доверяете этому серверу.", - "sshHostKeyFingerprintMd5Base64": "Отпечаток (MD5 Base64): {fingerprint}", - "sshHostKeyFingerprintMd5Hex": "Отпечаток (SHA256): {fingerprint}", + "sshHostKeyFingerprint": "Отпечаток (SHA256): {fingerprint}", "sshHostKeyType": "Тип ключа хоста SSH", "sshKnownHostKeys": "Известные хосты", "sshKnownHostKeysTip": "Ключи хостов, принятые этим приложением", diff --git a/lib/l10n/app_tr.arb b/lib/l10n/app_tr.arb index 1696b27dc3..59fbc26e36 100644 --- a/lib/l10n/app_tr.arb +++ b/lib/l10n/app_tr.arb @@ -240,8 +240,7 @@ "sshConfigImportTip": "İlk sunucu oluşturulurken ~/.ssh/config okuma istemi", "sshConfigImported": "SSH yapılandırmasından {count} sunucu içe aktarıldı", "sshHostKeyChangedDesc": "{serverName} için SSH ana bilgisayar anahtarı değişti. Yalnızca bu sunucuya güveniyorsanız devam edin.", - "sshHostKeyFingerprintMd5Base64": "Parmak izi (MD5 Base64): {fingerprint}", - "sshHostKeyFingerprintMd5Hex": "Parmak izi (SHA256): {fingerprint}", + "sshHostKeyFingerprint": "Parmak izi (SHA256): {fingerprint}", "sshHostKeyType": "SSH ana bilgisayar anahtarı türü", "sshKnownHostKeys": "Bilinen ana makineler", "sshKnownHostKeysTip": "Bu uygulamanın kabul ettiği host anahtarları", diff --git a/lib/l10n/app_uk.arb b/lib/l10n/app_uk.arb index 1d1733fe41..c8a488e902 100644 --- a/lib/l10n/app_uk.arb +++ b/lib/l10n/app_uk.arb @@ -241,8 +241,7 @@ "sshConfigImportTip": "Пропозиція прочитати ~/.ssh/config при створенні першого сервера", "sshConfigImported": "Імпортовано {count} серверів з SSH-конфігурації", "sshHostKeyChangedDesc": "SSH-ключ хоста для {serverName} змінено. Продовжуйте лише якщо довіряєте цьому серверу.", - "sshHostKeyFingerprintMd5Base64": "Відбиток (MD5 Base64): {fingerprint}", - "sshHostKeyFingerprintMd5Hex": "Відбиток (SHA256): {fingerprint}", + "sshHostKeyFingerprint": "Відбиток (SHA256): {fingerprint}", "sshHostKeyType": "Тип ключа хоста SSH", "sshKnownHostKeys": "Відомі хости", "sshKnownHostKeysTip": "Ключі хостів, які прийняв цей застосунок", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index af040377b9..bcf2adb710 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -342,8 +342,7 @@ "sshConfigImportTip": "首次创建服务器时提示读取 ~/.ssh/config", "sshConfigImported": "从 SSH 配置导入了 {count} 个服务器", "sshHostKeyChangedDesc": "服务器 {serverName} 的 SSH 主机密钥已更改,仅在信任该服务器时继续。", - "sshHostKeyFingerprintMd5Base64": "指纹(MD5 Base64):{fingerprint}", - "sshHostKeyFingerprintMd5Hex": "指纹(SHA256):{fingerprint}", + "sshHostKeyFingerprint": "指纹(SHA256):{fingerprint}", "sshHostKeyType": "SSH 主机密钥类型", "sshKnownHostKeys": "已信任的主机", "sshKnownHostKeysTip": "本 app 已接受的主机密钥", diff --git a/lib/l10n/app_zh_tw.arb b/lib/l10n/app_zh_tw.arb index c2112b3ac0..1a1cff4a63 100644 --- a/lib/l10n/app_zh_tw.arb +++ b/lib/l10n/app_zh_tw.arb @@ -341,8 +341,7 @@ "sshConfigImportTip": "在建立第一個伺服器時提示讀取 ~/.ssh/config", "sshConfigImported": "已從SSH設定匯入{count}個伺服器", "sshHostKeyChangedDesc": "伺服器 {serverName} 的 SSH 主機金鑰已變更,僅在信任該伺服器時繼續。", - "sshHostKeyFingerprintMd5Base64": "指紋(MD5 Base64):{fingerprint}", - "sshHostKeyFingerprintMd5Hex": "指紋(SHA256):{fingerprint}", + "sshHostKeyFingerprint": "指紋(SHA256):{fingerprint}", "sshHostKeyType": "SSH 主機金鑰類型", "sshKnownHostKeys": "已信任的主機", "sshKnownHostKeysTip": "本 app 已接受的主機金鑰", diff --git a/test/host_key_prompt_test.dart b/test/host_key_prompt_test.dart index 85485888f2..cdc5a4c708 100644 --- a/test/host_key_prompt_test.dart +++ b/test/host_key_prompt_test.dart @@ -8,13 +8,13 @@ import 'helpers/spi_fixture.dart'; HostKeyPromptInfo _info({ String id = 'srv-1', String keyType = 'ssh-ed25519', - String fingerprintHex = 'aa:bb:cc', + // OpenSSH form, which is the only one this carries now. + String fingerprint = 'SHA256:q7vMq7vMq7vMq7vMq7vMq7vMq7vMq7vMq7vMq7vMq7s', }) { return HostKeyPromptInfo( spi: spiFixture(name: 'srv', id: id, ip: '192.0.2.1', user: 'tester'), keyType: keyType, - fingerprintHex: fingerprintHex, - fingerprintBase64: 'q7vM', + fingerprint: fingerprint, isMismatch: false, ); } @@ -84,7 +84,7 @@ void main() { return stale.future; }); final second = promptHostKeyExclusively( - _info(fingerprintHex: 'dd:ee:ff'), + _info(fingerprint: 'SHA256:BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB'), () { shown.add('dd:ee:ff'); return Future.value(false); diff --git a/test/ssh_auth_test.dart b/test/ssh_auth_test.dart index 844705217f..d49e7ca600 100644 --- a/test/ssh_auth_test.dart +++ b/test/ssh_auth_test.dart @@ -82,8 +82,7 @@ void main() { info: HostKeyPromptInfo( spi: _spi, keyType: 'ssh-ed25519', - fingerprintHex: '00:11', - fingerprintBase64: 'ABCD', + fingerprint: 'SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', isMismatch: false, ), ), From 4b2c7b9a641d390a8e85d3d876e01cbbaff07659 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?lollipopkit=F0=9F=8F=B3=EF=B8=8F=E2=80=8D=E2=9A=A7?= =?UTF-8?q?=EF=B8=8F?= <10864310+lollipopkit@users.noreply.github.com> Date: Mon, 24 Aug 2026 00:18:21 +0800 Subject: [PATCH 07/11] fix(ssh): show the host key fingerprint as ssh-keygen prints it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The line read `Fingerprint (SHA256): SHA256:xWcF/…` — the algorithm named twice, once by a label and once by the value, which already carries it. It now reads `SHA256:xWcF/…`, character for character what `ssh-keygen -l` prints on the server, so the two can be compared without stripping a label off first. `sshHostKeyFingerprint` had no other caller and is gone from all fifteen locales. On a mismatch the offered fingerprint is now the bare line and the old one stays labelled `Stored fingerprint:` — which is what tells them apart, since neither says on its own which is which. --- lib/core/utils/server.dart | 7 ++++++- lib/generated/l10n/l10n.dart | 6 ------ lib/generated/l10n/l10n_de.dart | 5 ----- lib/generated/l10n/l10n_en.dart | 5 ----- lib/generated/l10n/l10n_es.dart | 5 ----- lib/generated/l10n/l10n_fr.dart | 5 ----- lib/generated/l10n/l10n_id.dart | 5 ----- lib/generated/l10n/l10n_it.dart | 5 ----- lib/generated/l10n/l10n_ja.dart | 5 ----- lib/generated/l10n/l10n_ko.dart | 5 ----- lib/generated/l10n/l10n_nl.dart | 5 ----- lib/generated/l10n/l10n_pt.dart | 5 ----- lib/generated/l10n/l10n_ru.dart | 5 ----- lib/generated/l10n/l10n_tr.dart | 5 ----- lib/generated/l10n/l10n_uk.dart | 5 ----- lib/generated/l10n/l10n_zh.dart | 10 ---------- lib/l10n/app_de.arb | 1 - lib/l10n/app_en.arb | 1 - lib/l10n/app_es.arb | 1 - lib/l10n/app_fr.arb | 1 - lib/l10n/app_id.arb | 1 - lib/l10n/app_it.arb | 1 - lib/l10n/app_ja.arb | 1 - lib/l10n/app_ko.arb | 1 - lib/l10n/app_nl.arb | 1 - lib/l10n/app_pt.arb | 1 - lib/l10n/app_ru.arb | 1 - lib/l10n/app_tr.arb | 1 - lib/l10n/app_uk.arb | 1 - lib/l10n/app_zh.arb | 1 - lib/l10n/app_zh_tw.arb | 1 - 31 files changed, 6 insertions(+), 97 deletions(-) diff --git a/lib/core/utils/server.dart b/lib/core/utils/server.dart index 8d0f4c10a4..75ad32b7e5 100644 --- a/lib/core/utils/server.dart +++ b/lib/core/utils/server.dart @@ -668,7 +668,12 @@ Future _showHostKeyDialog( SelectableText('${libL10n.server}: ${info.spi.name}'), SelectableText('${libL10n.addr}: $hostLine'), SelectableText('${l10n.sshHostKeyType}: ${info.keyType}'), - SelectableText(l10n.sshHostKeyFingerprint(info.fingerprint)), + // Verbatim, which is the whole point of it: this is character for + // character what `ssh-keygen -l` prints on the server, so it can be + // compared against that without anyone having to strip a label off + // first. It names its own algorithm, so a `(SHA256)` around it said + // SHA256 twice. + SelectableText(info.fingerprint), if (info.previousFingerprint != null) ...[ const SizedBox(height: 12), SelectableText( diff --git a/lib/generated/l10n/l10n.dart b/lib/generated/l10n/l10n.dart index 200ff0636d..2a8158d139 100644 --- a/lib/generated/l10n/l10n.dart +++ b/lib/generated/l10n/l10n.dart @@ -2054,12 +2054,6 @@ abstract class AppLocalizations { /// **'The SSH host key changed for {serverName}. Only continue if you trust this server.'** String sshHostKeyChangedDesc(Object serverName); - /// No description provided for @sshHostKeyFingerprint. - /// - /// In en, this message translates to: - /// **'Fingerprint (SHA256): {fingerprint}'** - String sshHostKeyFingerprint(Object fingerprint); - /// Label for the SSH host key type displayed in the host key verification dialog. /// /// In en, this message translates to: diff --git a/lib/generated/l10n/l10n_de.dart b/lib/generated/l10n/l10n_de.dart index ed36050920..250b359363 100644 --- a/lib/generated/l10n/l10n_de.dart +++ b/lib/generated/l10n/l10n_de.dart @@ -1137,11 +1137,6 @@ class AppLocalizationsDe extends AppLocalizations { return 'Der SSH-Hostschlüssel für $serverName hat sich geändert. Fahren Sie nur fort, wenn Sie diesem Server vertrauen.'; } - @override - String sshHostKeyFingerprint(Object fingerprint) { - return 'Fingerabdruck (SHA256): $fingerprint'; - } - @override String get sshHostKeyType => 'SSH-Hostschlüsseltyp'; diff --git a/lib/generated/l10n/l10n_en.dart b/lib/generated/l10n/l10n_en.dart index 5dc8159afe..78dcfd8829 100644 --- a/lib/generated/l10n/l10n_en.dart +++ b/lib/generated/l10n/l10n_en.dart @@ -1122,11 +1122,6 @@ class AppLocalizationsEn extends AppLocalizations { return 'The SSH host key changed for $serverName. Only continue if you trust this server.'; } - @override - String sshHostKeyFingerprint(Object fingerprint) { - return 'Fingerprint (SHA256): $fingerprint'; - } - @override String get sshHostKeyType => 'SSH host key type'; diff --git a/lib/generated/l10n/l10n_es.dart b/lib/generated/l10n/l10n_es.dart index 24c5516c6d..fc7a77c64b 100644 --- a/lib/generated/l10n/l10n_es.dart +++ b/lib/generated/l10n/l10n_es.dart @@ -1146,11 +1146,6 @@ class AppLocalizationsEs extends AppLocalizations { return 'La clave de host SSH de $serverName ha cambiado. Continúa solo si confías en este servidor.'; } - @override - String sshHostKeyFingerprint(Object fingerprint) { - return 'Huella (SHA256): $fingerprint'; - } - @override String get sshHostKeyType => 'Tipo de clave de host SSH'; diff --git a/lib/generated/l10n/l10n_fr.dart b/lib/generated/l10n/l10n_fr.dart index 58bb3bcf71..9e8766e0a6 100644 --- a/lib/generated/l10n/l10n_fr.dart +++ b/lib/generated/l10n/l10n_fr.dart @@ -1145,11 +1145,6 @@ class AppLocalizationsFr extends AppLocalizations { return 'La clé d\'hôte SSH de $serverName a changé. Ne continuez que si vous faites confiance à ce serveur.'; } - @override - String sshHostKeyFingerprint(Object fingerprint) { - return 'Empreinte (SHA256) : $fingerprint'; - } - @override String get sshHostKeyType => 'Type de clé d\'hôte SSH'; diff --git a/lib/generated/l10n/l10n_id.dart b/lib/generated/l10n/l10n_id.dart index dfbc7e7084..8b0394f5ab 100644 --- a/lib/generated/l10n/l10n_id.dart +++ b/lib/generated/l10n/l10n_id.dart @@ -1130,11 +1130,6 @@ class AppLocalizationsId extends AppLocalizations { return 'Kunci host SSH untuk $serverName telah berubah. Lanjutkan hanya jika Anda mempercayai server ini.'; } - @override - String sshHostKeyFingerprint(Object fingerprint) { - return 'Sidik jari (SHA256): $fingerprint'; - } - @override String get sshHostKeyType => 'Jenis kunci host SSH'; diff --git a/lib/generated/l10n/l10n_it.dart b/lib/generated/l10n/l10n_it.dart index 74bce9e455..81f1d8c591 100644 --- a/lib/generated/l10n/l10n_it.dart +++ b/lib/generated/l10n/l10n_it.dart @@ -1138,11 +1138,6 @@ class AppLocalizationsIt extends AppLocalizations { return 'La chiave host SSH è cambiata per $serverName. Continua solo se ti fidi di questo server.'; } - @override - String sshHostKeyFingerprint(Object fingerprint) { - return 'Impronta digitale (SHA256): $fingerprint'; - } - @override String get sshHostKeyType => 'Tipo chiave host SSH'; diff --git a/lib/generated/l10n/l10n_ja.dart b/lib/generated/l10n/l10n_ja.dart index 57c3fecce3..44319b7b51 100644 --- a/lib/generated/l10n/l10n_ja.dart +++ b/lib/generated/l10n/l10n_ja.dart @@ -1070,11 +1070,6 @@ class AppLocalizationsJa extends AppLocalizations { return '$serverName の SSH ホスト鍵が変更されました。このサーバーを信頼できる場合のみ続行してください。'; } - @override - String sshHostKeyFingerprint(Object fingerprint) { - return 'フィンガープリント (SHA256): $fingerprint'; - } - @override String get sshHostKeyType => 'SSH ホストキーの種類'; diff --git a/lib/generated/l10n/l10n_ko.dart b/lib/generated/l10n/l10n_ko.dart index d2833d496c..91dd95ed7d 100644 --- a/lib/generated/l10n/l10n_ko.dart +++ b/lib/generated/l10n/l10n_ko.dart @@ -1070,11 +1070,6 @@ class AppLocalizationsKo extends AppLocalizations { return '$serverName의 SSH 호스트 키가 변경되었습니다. 이 서버를 신뢰하는 경우에만 계속 진행하세요.'; } - @override - String sshHostKeyFingerprint(Object fingerprint) { - return '지문 (SHA256): $fingerprint'; - } - @override String get sshHostKeyType => 'SSH 호스트 키 유형'; diff --git a/lib/generated/l10n/l10n_nl.dart b/lib/generated/l10n/l10n_nl.dart index 0498073a31..18b1094c9b 100644 --- a/lib/generated/l10n/l10n_nl.dart +++ b/lib/generated/l10n/l10n_nl.dart @@ -1134,11 +1134,6 @@ class AppLocalizationsNl extends AppLocalizations { return 'De SSH-hostsleutel voor $serverName is gewijzigd. Ga alleen verder als u deze server vertrouwt.'; } - @override - String sshHostKeyFingerprint(Object fingerprint) { - return 'Vingerafdruk (SHA256): $fingerprint'; - } - @override String get sshHostKeyType => 'Type SSH-hostsleutel'; diff --git a/lib/generated/l10n/l10n_pt.dart b/lib/generated/l10n/l10n_pt.dart index 2c29019b47..6f3972c70d 100644 --- a/lib/generated/l10n/l10n_pt.dart +++ b/lib/generated/l10n/l10n_pt.dart @@ -1131,11 +1131,6 @@ class AppLocalizationsPt extends AppLocalizations { return 'A chave de host SSH de $serverName foi alterada. Continue apenas se confiar neste servidor.'; } - @override - String sshHostKeyFingerprint(Object fingerprint) { - return 'Impressão digital (SHA256): $fingerprint'; - } - @override String get sshHostKeyType => 'Tipo de chave de host SSH'; diff --git a/lib/generated/l10n/l10n_ru.dart b/lib/generated/l10n/l10n_ru.dart index 10536e576e..c2fca039b8 100644 --- a/lib/generated/l10n/l10n_ru.dart +++ b/lib/generated/l10n/l10n_ru.dart @@ -1138,11 +1138,6 @@ class AppLocalizationsRu extends AppLocalizations { return 'SSH-ключ хоста для $serverName изменился. Продолжайте только если доверяете этому серверу.'; } - @override - String sshHostKeyFingerprint(Object fingerprint) { - return 'Отпечаток (SHA256): $fingerprint'; - } - @override String get sshHostKeyType => 'Тип ключа хоста SSH'; diff --git a/lib/generated/l10n/l10n_tr.dart b/lib/generated/l10n/l10n_tr.dart index e56dca3d1f..8d7a95905c 100644 --- a/lib/generated/l10n/l10n_tr.dart +++ b/lib/generated/l10n/l10n_tr.dart @@ -1131,11 +1131,6 @@ class AppLocalizationsTr extends AppLocalizations { return '$serverName için SSH ana bilgisayar anahtarı değişti. Yalnızca bu sunucuya güveniyorsanız devam edin.'; } - @override - String sshHostKeyFingerprint(Object fingerprint) { - return 'Parmak izi (SHA256): $fingerprint'; - } - @override String get sshHostKeyType => 'SSH ana bilgisayar anahtarı türü'; diff --git a/lib/generated/l10n/l10n_uk.dart b/lib/generated/l10n/l10n_uk.dart index 2bc914488e..067f488048 100644 --- a/lib/generated/l10n/l10n_uk.dart +++ b/lib/generated/l10n/l10n_uk.dart @@ -1135,11 +1135,6 @@ class AppLocalizationsUk extends AppLocalizations { return 'SSH-ключ хоста для $serverName змінено. Продовжуйте лише якщо довіряєте цьому серверу.'; } - @override - String sshHostKeyFingerprint(Object fingerprint) { - return 'Відбиток (SHA256): $fingerprint'; - } - @override String get sshHostKeyType => 'Тип ключа хоста SSH'; diff --git a/lib/generated/l10n/l10n_zh.dart b/lib/generated/l10n/l10n_zh.dart index 0e2e1a61aa..088f033ae1 100644 --- a/lib/generated/l10n/l10n_zh.dart +++ b/lib/generated/l10n/l10n_zh.dart @@ -1050,11 +1050,6 @@ class AppLocalizationsZh extends AppLocalizations { return '服务器 $serverName 的 SSH 主机密钥已更改,仅在信任该服务器时继续。'; } - @override - String sshHostKeyFingerprint(Object fingerprint) { - return '指纹(SHA256):$fingerprint'; - } - @override String get sshHostKeyType => 'SSH 主机密钥类型'; @@ -2501,11 +2496,6 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { return '伺服器 $serverName 的 SSH 主機金鑰已變更,僅在信任該伺服器時繼續。'; } - @override - String sshHostKeyFingerprint(Object fingerprint) { - return '指紋(SHA256):$fingerprint'; - } - @override String get sshHostKeyType => 'SSH 主機金鑰類型'; diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 056583bedb..a4053ad173 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -240,7 +240,6 @@ "sshConfigImportTip": "Bei der ersten Server-Erstellung zum Lesen von ~/.ssh/config auffordern", "sshConfigImported": "{count} Server aus SSH-Konfiguration importiert", "sshHostKeyChangedDesc": "Der SSH-Hostschlüssel für {serverName} hat sich geändert. Fahren Sie nur fort, wenn Sie diesem Server vertrauen.", - "sshHostKeyFingerprint": "Fingerabdruck (SHA256): {fingerprint}", "sshHostKeyType": "SSH-Hostschlüsseltyp", "sshKnownHostKeys": "Bekannte Hosts", "sshKnownHostKeysTip": "Die Host-Schlüssel, die diese App akzeptiert hat", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 538e319642..baf636588b 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -414,7 +414,6 @@ "sshConfigImportTip": "Prompt to read ~/.ssh/config on first server creation", "sshConfigImported": "Imported {count} servers from SSH config", "sshHostKeyChangedDesc": "The SSH host key changed for {serverName}. Only continue if you trust this server.", - "sshHostKeyFingerprint": "Fingerprint (SHA256): {fingerprint}", "sshHostKeyType": "SSH host key type", "sshKnownHostKeys": "Known hosts", "sshKnownHostKeysTip": "The host keys this app has accepted", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index be7bd7e7b2..798863339d 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -240,7 +240,6 @@ "sshConfigImportTip": "Sugerencia para leer ~/.ssh/config al crear el primer servidor", "sshConfigImported": "Se importaron {count} servidores desde la configuración SSH", "sshHostKeyChangedDesc": "La clave de host SSH de {serverName} ha cambiado. Continúa solo si confías en este servidor.", - "sshHostKeyFingerprint": "Huella (SHA256): {fingerprint}", "sshHostKeyType": "Tipo de clave de host SSH", "sshKnownHostKeys": "Hosts conocidos", "sshKnownHostKeysTip": "Las claves de host que esta app ha aceptado", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index ae631523cb..448616b8fc 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -240,7 +240,6 @@ "sshConfigImportTip": "Proposer de lire ~/.ssh/config lors de la première création de serveur", "sshConfigImported": "{count} serveurs importés depuis la configuration SSH", "sshHostKeyChangedDesc": "La clé d'hôte SSH de {serverName} a changé. Ne continuez que si vous faites confiance à ce serveur.", - "sshHostKeyFingerprint": "Empreinte (SHA256) : {fingerprint}", "sshHostKeyType": "Type de clé d'hôte SSH", "sshKnownHostKeys": "Hôtes connus", "sshKnownHostKeysTip": "Les clés d’hôte que cette app a acceptées", diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index c48ee7c256..6f64e004eb 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -240,7 +240,6 @@ "sshConfigImportTip": "Prompt untuk membaca ~/.ssh/config saat pembuatan server pertama", "sshConfigImported": "Berhasil mengimpor {count} server dari konfigurasi SSH", "sshHostKeyChangedDesc": "Kunci host SSH untuk {serverName} telah berubah. Lanjutkan hanya jika Anda mempercayai server ini.", - "sshHostKeyFingerprint": "Sidik jari (SHA256): {fingerprint}", "sshHostKeyType": "Jenis kunci host SSH", "sshKnownHostKeys": "Host dikenal", "sshKnownHostKeysTip": "Kunci host yang sudah diterima aplikasi ini", diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index d4288819bd..1b7c6a1242 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -241,7 +241,6 @@ "sshConfigImportTip": "Chiedi di leggere ~/.ssh/config alla prima creazione del server", "sshConfigImported": "Importati {count} server dalla configurazione SSH", "sshHostKeyChangedDesc": "La chiave host SSH è cambiata per {serverName}. Continua solo se ti fidi di questo server.", - "sshHostKeyFingerprint": "Impronta digitale (SHA256): {fingerprint}", "sshHostKeyType": "Tipo chiave host SSH", "sshKnownHostKeys": "Host conosciuti", "sshKnownHostKeysTip": "Le chiavi host che questa app ha accettato", diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 95273af3ce..b6d83b562e 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -240,7 +240,6 @@ "sshConfigImportTip": "初回サーバー作成時に~/.ssh/configの読み取りを促す", "sshConfigImported": "SSH設定から{count}個のサーバーをインポートしました", "sshHostKeyChangedDesc": "{serverName} の SSH ホスト鍵が変更されました。このサーバーを信頼できる場合のみ続行してください。", - "sshHostKeyFingerprint": "フィンガープリント (SHA256): {fingerprint}", "sshHostKeyType": "SSH ホストキーの種類", "sshKnownHostKeys": "既知のホスト", "sshKnownHostKeysTip": "このアプリが受け入れたホスト鍵", diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index bad9878880..0fe385f408 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -286,7 +286,6 @@ "sshConfigImportTip": "첫 서버 생성 시 ~/.ssh/config 읽기 안내", "sshConfigImported": "SSH 설정에서 서버 {count}개를 가져왔습니다", "sshHostKeyChangedDesc": "{serverName}의 SSH 호스트 키가 변경되었습니다. 이 서버를 신뢰하는 경우에만 계속 진행하세요.", - "sshHostKeyFingerprint": "지문 (SHA256): {fingerprint}", "sshHostKeyType": "SSH 호스트 키 유형", "sshKnownHostKeys": "알려진 호스트", "sshKnownHostKeysTip": "이 앱이 수락한 호스트 키", diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb index c43f190b8b..69d9cfe849 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -240,7 +240,6 @@ "sshConfigImportTip": "Prompt om ~/.ssh/config te lezen bij het aanmaken van de eerste server", "sshConfigImported": "{count} servers geïmporteerd uit SSH-configuratie", "sshHostKeyChangedDesc": "De SSH-hostsleutel voor {serverName} is gewijzigd. Ga alleen verder als u deze server vertrouwt.", - "sshHostKeyFingerprint": "Vingerafdruk (SHA256): {fingerprint}", "sshHostKeyType": "Type SSH-hostsleutel", "sshKnownHostKeys": "Bekende hosts", "sshKnownHostKeysTip": "De hostsleutels die deze app heeft geaccepteerd", diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index 631e8eeb2e..99e34579fb 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -240,7 +240,6 @@ "sshConfigImportTip": "Sugestão para ler ~/.ssh/config na criação do primeiro servidor", "sshConfigImported": "Importados {count} servidores da configuração SSH", "sshHostKeyChangedDesc": "A chave de host SSH de {serverName} foi alterada. Continue apenas se confiar neste servidor.", - "sshHostKeyFingerprint": "Impressão digital (SHA256): {fingerprint}", "sshHostKeyType": "Tipo de chave de host SSH", "sshKnownHostKeys": "Anfitriões conhecidos", "sshKnownHostKeysTip": "As chaves de host que esta app aceitou", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index c84c80fb77..f13d5676d4 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -240,7 +240,6 @@ "sshConfigImportTip": "Предложение прочитать ~/.ssh/config при создании первого сервера", "sshConfigImported": "Импортировано {count} серверов из SSH-конфигурации", "sshHostKeyChangedDesc": "SSH-ключ хоста для {serverName} изменился. Продолжайте только если доверяете этому серверу.", - "sshHostKeyFingerprint": "Отпечаток (SHA256): {fingerprint}", "sshHostKeyType": "Тип ключа хоста SSH", "sshKnownHostKeys": "Известные хосты", "sshKnownHostKeysTip": "Ключи хостов, принятые этим приложением", diff --git a/lib/l10n/app_tr.arb b/lib/l10n/app_tr.arb index 59fbc26e36..3243b0bf62 100644 --- a/lib/l10n/app_tr.arb +++ b/lib/l10n/app_tr.arb @@ -240,7 +240,6 @@ "sshConfigImportTip": "İlk sunucu oluşturulurken ~/.ssh/config okuma istemi", "sshConfigImported": "SSH yapılandırmasından {count} sunucu içe aktarıldı", "sshHostKeyChangedDesc": "{serverName} için SSH ana bilgisayar anahtarı değişti. Yalnızca bu sunucuya güveniyorsanız devam edin.", - "sshHostKeyFingerprint": "Parmak izi (SHA256): {fingerprint}", "sshHostKeyType": "SSH ana bilgisayar anahtarı türü", "sshKnownHostKeys": "Bilinen ana makineler", "sshKnownHostKeysTip": "Bu uygulamanın kabul ettiği host anahtarları", diff --git a/lib/l10n/app_uk.arb b/lib/l10n/app_uk.arb index c8a488e902..2a8b875ed3 100644 --- a/lib/l10n/app_uk.arb +++ b/lib/l10n/app_uk.arb @@ -241,7 +241,6 @@ "sshConfigImportTip": "Пропозиція прочитати ~/.ssh/config при створенні першого сервера", "sshConfigImported": "Імпортовано {count} серверів з SSH-конфігурації", "sshHostKeyChangedDesc": "SSH-ключ хоста для {serverName} змінено. Продовжуйте лише якщо довіряєте цьому серверу.", - "sshHostKeyFingerprint": "Відбиток (SHA256): {fingerprint}", "sshHostKeyType": "Тип ключа хоста SSH", "sshKnownHostKeys": "Відомі хости", "sshKnownHostKeysTip": "Ключі хостів, які прийняв цей застосунок", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index bcf2adb710..fbdafc81cd 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -342,7 +342,6 @@ "sshConfigImportTip": "首次创建服务器时提示读取 ~/.ssh/config", "sshConfigImported": "从 SSH 配置导入了 {count} 个服务器", "sshHostKeyChangedDesc": "服务器 {serverName} 的 SSH 主机密钥已更改,仅在信任该服务器时继续。", - "sshHostKeyFingerprint": "指纹(SHA256):{fingerprint}", "sshHostKeyType": "SSH 主机密钥类型", "sshKnownHostKeys": "已信任的主机", "sshKnownHostKeysTip": "本 app 已接受的主机密钥", diff --git a/lib/l10n/app_zh_tw.arb b/lib/l10n/app_zh_tw.arb index 1a1cff4a63..0d1d70edaf 100644 --- a/lib/l10n/app_zh_tw.arb +++ b/lib/l10n/app_zh_tw.arb @@ -341,7 +341,6 @@ "sshConfigImportTip": "在建立第一個伺服器時提示讀取 ~/.ssh/config", "sshConfigImported": "已從SSH設定匯入{count}個伺服器", "sshHostKeyChangedDesc": "伺服器 {serverName} 的 SSH 主機金鑰已變更,僅在信任該伺服器時繼續。", - "sshHostKeyFingerprint": "指紋(SHA256):{fingerprint}", "sshHostKeyType": "SSH 主機金鑰類型", "sshKnownHostKeys": "已信任的主機", "sshKnownHostKeysTip": "本 app 已接受的主機金鑰", From 9f6f11727adec70dfac8f47244c53904ef288b8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?lollipopkit=F0=9F=8F=B3=EF=B8=8F=E2=80=8D=E2=9A=A7?= =?UTF-8?q?=EF=B8=8F?= <10864310+lollipopkit@users.noreply.github.com> Date: Mon, 24 Aug 2026 00:40:53 +0800 Subject: [PATCH 08/11] fix(key): review findings on the keygen pages and the digest cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `_describeCache` was keyed by the PEM itself, which kept every private key the list had ever rendered reachable for the rest of the run — after the record was edited, and after it was deleted. Keyed by a digest now, and bounded, since a key edited repeatedly is a new entry each time. - `PrivateKeyInfo.copyWith` could not clear `comment`: the field is nullable and `null` meant "leave alone", so a caller asking to clear it would silently keep the old one. The `_unset` sentinel `AgentSessionState.copyWith` already uses, for the same reason. - The comment field on the edit page carried the public key's tip underneath it — "Append this line to ~/.ssh/authorized_keys", which is not what a comment field does. Removed; the tip stays where it belongs, in the public key dialog. - The generate page never disposed its `ExpansibleController`. - `_onTapAdd` moved into the actions extension, which is where the rest of this page's actions are. And one in the tests, which is the reason to care about the rest: the collapse assertions looked for `RadioListTile` while the page builds `RadioListTile`, so `findsNothing` passed without ever looking at the tiles. Both assertions were vacuous. Corrected, and they pass — the behaviour was right, it just was not being checked. dartssh2 moves to `ebbe517`: its CI runs `dart format --set-exit-if-changed` over the whole package and the new test file was the one it would have failed. --- lib/core/utils/ssh_keygen.dart | 30 +++++++--- lib/data/model/server/private_key_info.dart | 10 +++- lib/view/page/private_key/edit.dart | 4 -- lib/view/page/private_key/generate.dart | 1 + lib/view/page/private_key/list.dart | 66 ++++++++++----------- packages/dartssh2 | 2 +- test/ssh_keygen_page_test.dart | 5 +- 7 files changed, 69 insertions(+), 49 deletions(-) diff --git a/lib/core/utils/ssh_keygen.dart b/lib/core/utils/ssh_keygen.dart index b528ddcd45..04b16dad00 100644 --- a/lib/core/utils/ssh_keygen.dart +++ b/lib/core/utils/ssh_keygen.dart @@ -202,20 +202,36 @@ class SshKeyDigest { bool get isEmpty => keyType == null && fingerprint == null && comment == null; } -/// Remembers what each key said, keyed by the key itself. +/// Remembers what each key said. /// -/// This is called while building a list row, so once per key per frame. It is -/// not only a hash: for a key that is not encrypted it decodes the private -/// blob, which for RSA-4096 means reading six mpints into BigInts. Bounded by -/// the number of keys, and a changed key is a different string. +/// This is called while building a list row, so once per key per frame, and it +/// is not only a hash: for a key that is not encrypted it decodes the private +/// blob, which for RSA-4096 means reading six mpints into BigInts. +/// +/// Keyed by a digest of the key rather than by the key. The value holds +/// nothing secret, but a map keyed by the PEM would keep the private key +/// reachable for the rest of the run — after the record was edited, and after +/// it was deleted. final _describeCache = {}; +/// Enough for any plausible number of keys, and a bound rather than none: a +/// key edited repeatedly is a new digest every time. +const _describeCacheLimit = 32; + /// Reads [pem] for what can be shown about it in a list. /// /// Never throws and never asks for a passphrase: this is for a subtitle, and a /// key that cannot be read is one whose subtitle is empty, not an error. -SshKeyDigest describeSshKey(String pem) => - _describeCache[pem] ??= _describeSshKey(pem); +SshKeyDigest describeSshKey(String pem) { + final id = base64.encode(sha256.convert(utf8.encode(pem)).bytes); + final cached = _describeCache[id]; + if (cached != null) return cached; + if (_describeCache.length >= _describeCacheLimit) { + // Insertion order, so this is the one seen longest ago. + _describeCache.remove(_describeCache.keys.first); + } + return _describeCache[id] = _describeSshKey(pem); +} SshKeyDigest _describeSshKey(String pem) { try { diff --git a/lib/data/model/server/private_key_info.dart b/lib/data/model/server/private_key_info.dart index 44818c2b9e..107169a1e1 100644 --- a/lib/data/model/server/private_key_info.dart +++ b/lib/data/model/server/private_key_info.dart @@ -2,6 +2,8 @@ import 'package:json_annotation/json_annotation.dart'; part 'private_key_info.g.dart'; +const _unset = Object(); + @JsonSerializable() class PrivateKeyInfo { /// Generated, and what `SshCredential.keyId` points at. @@ -42,16 +44,20 @@ class PrivateKeyInfo { Map toJson() => _$PrivateKeyInfoToJson(this); + /// [comment] is nullable and clearing it is a real thing to want, so `null` + /// has to mean "clear" rather than "leave alone" — the same sentinel + /// `AgentSessionState.copyWith` uses, for the same reason. The other three + /// are non-nullable and `null` can only mean "leave alone". PrivateKeyInfo copyWith({ String? id, String? name, String? key, - String? comment, + Object? comment = _unset, }) => PrivateKeyInfo( id: id ?? this.id, name: name ?? this.name, key: key ?? this.key, - comment: comment ?? this.comment, + comment: identical(comment, _unset) ? this.comment : comment as String?, ); String? get type { diff --git a/lib/view/page/private_key/edit.dart b/lib/view/page/private_key/edit.dart index c23cdaf854..abf8cf9b2b 100644 --- a/lib/view/page/private_key/edit.dart +++ b/lib/view/page/private_key/edit.dart @@ -331,10 +331,6 @@ class _PrivateKeyEditPageState extends ConsumerState { suggestion: false, onSubmitted: (_) => _onTapSave(), ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 12), - child: Text(l10n.sshKeyPublicKeyTip, style: UIs.textGrey), - ), SizedBox(height: MediaQuery.of(context).size.height * 0.1), ValBuilder( listenable: _loading, diff --git a/lib/view/page/private_key/generate.dart b/lib/view/page/private_key/generate.dart index 8396e270dc..ca4ad47e23 100644 --- a/lib/view/page/private_key/generate.dart +++ b/lib/view/page/private_key/generate.dart @@ -49,6 +49,7 @@ class _PrivateKeyGeneratePageState _nameController.dispose(); _commentController.dispose(); _pwdController.dispose(); + _algorithmTile.dispose(); super.dispose(); } diff --git a/lib/view/page/private_key/list.dart b/lib/view/page/private_key/list.dart index 9317b3c9b4..97dbb21107 100644 --- a/lib/view/page/private_key/list.dart +++ b/lib/view/page/private_key/list.dart @@ -37,39 +37,6 @@ class _PrivateKeyListState extends ConsumerState ); } - /// Two ways to end up with a key here, asked before either page opens. - /// - /// The dialog answers with what to do and closes itself; this navigates. A - /// button that pushed the page from inside the dialog would be reaching for - /// the root navigator the dialog is on, not the one holding this page. - Future _onTapAdd() async { - final generate = await context.showRoundDialog( - title: libL10n.add, - childBuilder: (dialogContext) => Column( - mainAxisSize: MainAxisSize.min, - children: [ - ListTile( - leading: const Icon(Icons.vpn_key), - title: Text(l10n.sshKeyGenerate), - onTap: () => dialogContext.popDialog(true), - ), - ListTile( - leading: const Icon(Icons.file_open), - title: Text(libL10n.import), - onTap: () => dialogContext.popDialog(false), - ), - ], - ), - actions: const [], - ); - if (generate == null || !mounted) return; - if (generate) { - PrivateKeyGeneratePage.route.go(context); - } else { - PrivateKeyEditPage.route.go(context); - } - } - Widget _buildBody() { final privateKeyState = ref.watch(privateKeyProvider); final pkis = privateKeyState.keys; @@ -122,6 +89,39 @@ class _PrivateKeyListState extends ConsumerState } extension on _PrivateKeyListState { + /// Two ways to end up with a key here, asked before either page opens. + /// + /// The dialog answers with what to do and closes itself; this navigates. A + /// button that pushed the page from inside the dialog would be reaching for + /// the root navigator the dialog is on, not the one holding this page. + Future _onTapAdd() async { + final generate = await context.showRoundDialog( + title: libL10n.add, + childBuilder: (dialogContext) => Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: const Icon(Icons.vpn_key), + title: Text(l10n.sshKeyGenerate), + onTap: () => dialogContext.popDialog(true), + ), + ListTile( + leading: const Icon(Icons.file_open), + title: Text(libL10n.import), + onTap: () => dialogContext.popDialog(false), + ), + ], + ), + actions: const [], + ); + if (generate == null || !mounted) return; + if (generate) { + PrivateKeyGeneratePage.route.go(context); + } else { + PrivateKeyEditPage.route.go(context); + } + } + void _autoAddSystemPriavteKey() async { // Only trigger on desktop platform and no private key saved if (isDesktop && Stores.key.keys().isEmpty) { diff --git a/packages/dartssh2 b/packages/dartssh2 index c1040ea5bc..ebbe517e3f 160000 --- a/packages/dartssh2 +++ b/packages/dartssh2 @@ -1 +1 @@ -Subproject commit c1040ea5bc37f9a59f18bc4f8a291a12c9dbe151 +Subproject commit ebbe517e3f14598f87e64f9944035e98b37998b8 diff --git a/test/ssh_keygen_page_test.dart b/test/ssh_keygen_page_test.dart index 5218a4682d..3ec4534589 100644 --- a/test/ssh_keygen_page_test.dart +++ b/test/ssh_keygen_page_test.dart @@ -5,6 +5,7 @@ import 'package:fl_lib/generated/l10n/lib_l10n.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:server_box/core/utils/ssh_keygen.dart'; import 'package:server_box/data/res/store.dart'; import 'package:server_box/data/store/private_key.dart'; import 'package:server_box/data/store/setting.dart'; @@ -73,7 +74,7 @@ void main() { // And the rest are not in the tree at all. expect(find.text('RSA 4096'), findsNothing); expect(find.text('ECDSA (P-256)'), findsNothing); - expect(find.byType(RadioListTile), findsNothing); + expect(find.byType(RadioListTile), findsNothing); }); testWidgets('opening it shows every algorithm', (tester) async { @@ -114,6 +115,6 @@ void main() { // made and the list has nothing more to say. expect(find.text('RSA 4096'), findsOneWidget); expect(find.text('Ed25519'), findsNothing); - expect(find.byType(RadioListTile), findsNothing); + expect(find.byType(RadioListTile), findsNothing); }); } From cb35d8c2deaf36d8dc28766c76ccbb13429db83e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?lollipopkit=F0=9F=8F=B3=EF=B8=8F=E2=80=8D=E2=9A=A7?= =?UTF-8?q?=EF=B8=8F?= <10864310+lollipopkit@users.noreply.github.com> Date: Mon, 24 Aug 2026 13:06:39 +0800 Subject: [PATCH 09/11] fix(key): stop the size guard swallowing its own error, and localize it The oversized-file check in `resolvePrivateKey` threw inside a `catch (_)` that was there for a failed stat, so the file it had just rejected was read into memory on the next line anyway. Rethrow `SSHErr` and leave a genuine stat failure to the read attempt, as intended. Both loaders reported the rejection with a hardcoded English reason wrapped in a localized string. They use `l10n.fileTooLarge`, which already names the file and both sizes, and the 1 MiB cap is a named constant rather than a literal in four places. Also translates the Ed25519 'Recommended' subtitle on the keygen page. --- lib/core/utils/server.dart | 31 +++++++++++++++++----- lib/generated/l10n/l10n.dart | 6 +++++ lib/generated/l10n/l10n_de.dart | 3 +++ lib/generated/l10n/l10n_en.dart | 3 +++ lib/generated/l10n/l10n_es.dart | 3 +++ lib/generated/l10n/l10n_fr.dart | 3 +++ lib/generated/l10n/l10n_id.dart | 3 +++ lib/generated/l10n/l10n_it.dart | 3 +++ lib/generated/l10n/l10n_ja.dart | 3 +++ lib/generated/l10n/l10n_ko.dart | 3 +++ lib/generated/l10n/l10n_nl.dart | 3 +++ lib/generated/l10n/l10n_pt.dart | 3 +++ lib/generated/l10n/l10n_ru.dart | 3 +++ lib/generated/l10n/l10n_tr.dart | 3 +++ lib/generated/l10n/l10n_uk.dart | 3 +++ lib/generated/l10n/l10n_zh.dart | 6 +++++ lib/l10n/app_de.arb | 1 + lib/l10n/app_en.arb | 1 + lib/l10n/app_es.arb | 1 + lib/l10n/app_fr.arb | 1 + lib/l10n/app_id.arb | 1 + lib/l10n/app_it.arb | 1 + lib/l10n/app_ja.arb | 1 + lib/l10n/app_ko.arb | 1 + lib/l10n/app_nl.arb | 1 + lib/l10n/app_pt.arb | 1 + lib/l10n/app_ru.arb | 1 + lib/l10n/app_tr.arb | 1 + lib/l10n/app_uk.arb | 1 + lib/l10n/app_zh.arb | 1 + lib/l10n/app_zh_tw.arb | 1 + lib/view/page/private_key/generate.dart | 7 ++--- test/identity_file_key_test.dart | 34 +++++++++++++++++++++++++ 33 files changed, 129 insertions(+), 9 deletions(-) diff --git a/lib/core/utils/server.dart b/lib/core/utils/server.dart index b0e5ea52d7..9ef42e9773 100644 --- a/lib/core/utils/server.dart +++ b/lib/core/utils/server.dart @@ -67,6 +67,14 @@ String getPrivateKey(String id) { return pki.key; } +/// Cap on a key file read off disk by path. Deliberately far above any real +/// key — an RSA-4096 PEM is a few KB — because this reads a file the user +/// already points OpenSSH at, and the point is only to keep a mistaken path +/// from pulling an arbitrary file into memory on the main isolate. +/// `Miscs.privateKeyMaxSize` is the tighter limit on what gets pasted into the +/// store, which is a different question. +const _keyFileMaxSize = 1024 * 1024; + /// The PEM [ssh] authenticates with, or null when it has no key at all. /// /// Two sources that are not interchangeable, which is the whole point of them @@ -102,14 +110,21 @@ String? resolvePrivateKey(SshCredential ssh) { // Guard against unbounded key files on the main isolate. try { final size = file.statSync().size; - if (size > 1024 * 1024) { + if (size > _keyFileMaxSize) { throw SSHErr( type: SSHErrType.noPrivateKey, - message: l10n.privateKeyFileUnreadable(expanded, 'File too large ($size bytes)'), + message: l10n.fileTooLarge( + expanded, + size.bytes2Str, + _keyFileMaxSize.bytes2Str, + ), ); } - } catch (_) { - // statSync failure is non-fatal; let read attempt decide. + } catch (e) { + // A failed stat is non-fatal — let the read attempt decide. The size + // check above is not: rethrowing is what stops an oversized file going + // on to be read into memory anyway. + if (e is SSHErr) rethrow; } return file.readAsStringSync(); } catch (e) { @@ -137,10 +152,14 @@ Future resolvePrivateKeyAsync(SshCredential ssh) async { try { final file = File(expanded); final stat = await file.stat(); - if (stat.size > 1024 * 1024) { + if (stat.size > _keyFileMaxSize) { throw SSHErr( type: SSHErrType.noPrivateKey, - message: l10n.privateKeyFileUnreadable(expanded, 'File too large (${stat.size} bytes)'), + message: l10n.fileTooLarge( + expanded, + stat.size.bytes2Str, + _keyFileMaxSize.bytes2Str, + ), ); } return await file.readAsString(); diff --git a/lib/generated/l10n/l10n.dart b/lib/generated/l10n/l10n.dart index 2a8158d139..fd2794c84d 100644 --- a/lib/generated/l10n/l10n.dart +++ b/lib/generated/l10n/l10n.dart @@ -1173,6 +1173,12 @@ abstract class AppLocalizations { /// **'Append this line to ~/.ssh/authorized_keys on the server.'** String get sshKeyPublicKeyTip; + /// No description provided for @sshKeyRecommended. + /// + /// In en, this message translates to: + /// **'Recommended'** + String get sshKeyRecommended; + /// No description provided for @sshKeyUnlockTip. /// /// In en, this message translates to: diff --git a/lib/generated/l10n/l10n_de.dart b/lib/generated/l10n/l10n_de.dart index 250b359363..222d1c64c8 100644 --- a/lib/generated/l10n/l10n_de.dart +++ b/lib/generated/l10n/l10n_de.dart @@ -608,6 +608,9 @@ class AppLocalizationsDe extends AppLocalizations { String get sshKeyPublicKeyTip => 'Diese Zeile an ~/.ssh/authorized_keys auf dem Server anhängen.'; + @override + String get sshKeyRecommended => 'Empfohlen'; + @override String sshKeyUnlockTip(String name) { return 'Passphrase für den privaten Schlüssel [$name] eingeben.'; diff --git a/lib/generated/l10n/l10n_en.dart b/lib/generated/l10n/l10n_en.dart index 78dcfd8829..b2c5f35a3d 100644 --- a/lib/generated/l10n/l10n_en.dart +++ b/lib/generated/l10n/l10n_en.dart @@ -599,6 +599,9 @@ class AppLocalizationsEn extends AppLocalizations { String get sshKeyPublicKeyTip => 'Append this line to ~/.ssh/authorized_keys on the server.'; + @override + String get sshKeyRecommended => 'Recommended'; + @override String sshKeyUnlockTip(String name) { return 'Enter the passphrase for the private key [$name].'; diff --git a/lib/generated/l10n/l10n_es.dart b/lib/generated/l10n/l10n_es.dart index fc7a77c64b..db06fa0254 100644 --- a/lib/generated/l10n/l10n_es.dart +++ b/lib/generated/l10n/l10n_es.dart @@ -612,6 +612,9 @@ class AppLocalizationsEs extends AppLocalizations { String get sshKeyPublicKeyTip => 'Añade esta línea a ~/.ssh/authorized_keys en el servidor.'; + @override + String get sshKeyRecommended => 'Recomendado'; + @override String sshKeyUnlockTip(String name) { return 'Introduce la frase de contraseña de la clave privada [$name].'; diff --git a/lib/generated/l10n/l10n_fr.dart b/lib/generated/l10n/l10n_fr.dart index 9e8766e0a6..01fb81427c 100644 --- a/lib/generated/l10n/l10n_fr.dart +++ b/lib/generated/l10n/l10n_fr.dart @@ -614,6 +614,9 @@ class AppLocalizationsFr extends AppLocalizations { String get sshKeyPublicKeyTip => 'Ajoutez cette ligne à ~/.ssh/authorized_keys sur le serveur.'; + @override + String get sshKeyRecommended => 'Recommandé'; + @override String sshKeyUnlockTip(String name) { return 'Saisissez la phrase secrète de la clé privée [$name].'; diff --git a/lib/generated/l10n/l10n_id.dart b/lib/generated/l10n/l10n_id.dart index 8b0394f5ab..fc42e2292d 100644 --- a/lib/generated/l10n/l10n_id.dart +++ b/lib/generated/l10n/l10n_id.dart @@ -605,6 +605,9 @@ class AppLocalizationsId extends AppLocalizations { String get sshKeyPublicKeyTip => 'Tambahkan baris ini ke ~/.ssh/authorized_keys di server.'; + @override + String get sshKeyRecommended => 'Disarankan'; + @override String sshKeyUnlockTip(String name) { return 'Masukkan frasa sandi untuk kunci privat [$name].'; diff --git a/lib/generated/l10n/l10n_it.dart b/lib/generated/l10n/l10n_it.dart index 81f1d8c591..0b5b8ab451 100644 --- a/lib/generated/l10n/l10n_it.dart +++ b/lib/generated/l10n/l10n_it.dart @@ -611,6 +611,9 @@ class AppLocalizationsIt extends AppLocalizations { String get sshKeyPublicKeyTip => 'Aggiungi questa riga a ~/.ssh/authorized_keys sul server.'; + @override + String get sshKeyRecommended => 'Consigliato'; + @override String sshKeyUnlockTip(String name) { return 'Inserisci la passphrase della chiave privata [$name].'; diff --git a/lib/generated/l10n/l10n_ja.dart b/lib/generated/l10n/l10n_ja.dart index 44319b7b51..e3943e5cd5 100644 --- a/lib/generated/l10n/l10n_ja.dart +++ b/lib/generated/l10n/l10n_ja.dart @@ -574,6 +574,9 @@ class AppLocalizationsJa extends AppLocalizations { String get sshKeyPublicKeyTip => 'この行をサーバーの ~/.ssh/authorized_keys に追記してください。'; + @override + String get sshKeyRecommended => '推奨'; + @override String sshKeyUnlockTip(String name) { return '秘密鍵 [$name] のパスフレーズを入力してください。'; diff --git a/lib/generated/l10n/l10n_ko.dart b/lib/generated/l10n/l10n_ko.dart index 91dd95ed7d..087d2abf61 100644 --- a/lib/generated/l10n/l10n_ko.dart +++ b/lib/generated/l10n/l10n_ko.dart @@ -572,6 +572,9 @@ class AppLocalizationsKo extends AppLocalizations { @override String get sshKeyPublicKeyTip => '이 줄을 서버의 ~/.ssh/authorized_keys에 추가하세요.'; + @override + String get sshKeyRecommended => '권장'; + @override String sshKeyUnlockTip(String name) { return '개인 키 [$name]의 암호를 입력하세요.'; diff --git a/lib/generated/l10n/l10n_nl.dart b/lib/generated/l10n/l10n_nl.dart index 18b1094c9b..53f4847b6e 100644 --- a/lib/generated/l10n/l10n_nl.dart +++ b/lib/generated/l10n/l10n_nl.dart @@ -607,6 +607,9 @@ class AppLocalizationsNl extends AppLocalizations { String get sshKeyPublicKeyTip => 'Voeg deze regel toe aan ~/.ssh/authorized_keys op de server.'; + @override + String get sshKeyRecommended => 'Aanbevolen'; + @override String sshKeyUnlockTip(String name) { return 'Voer de wachtwoordzin voor de privésleutel [$name] in.'; diff --git a/lib/generated/l10n/l10n_pt.dart b/lib/generated/l10n/l10n_pt.dart index 6f3972c70d..ec95ca6712 100644 --- a/lib/generated/l10n/l10n_pt.dart +++ b/lib/generated/l10n/l10n_pt.dart @@ -607,6 +607,9 @@ class AppLocalizationsPt extends AppLocalizations { String get sshKeyPublicKeyTip => 'Acrescente esta linha a ~/.ssh/authorized_keys no servidor.'; + @override + String get sshKeyRecommended => 'Recomendado'; + @override String sshKeyUnlockTip(String name) { return 'Introduza a frase-passe da chave privada [$name].'; diff --git a/lib/generated/l10n/l10n_ru.dart b/lib/generated/l10n/l10n_ru.dart index c2fca039b8..f578de8c6b 100644 --- a/lib/generated/l10n/l10n_ru.dart +++ b/lib/generated/l10n/l10n_ru.dart @@ -610,6 +610,9 @@ class AppLocalizationsRu extends AppLocalizations { String get sshKeyPublicKeyTip => 'Добавьте эту строку в ~/.ssh/authorized_keys на сервере.'; + @override + String get sshKeyRecommended => 'Рекомендуется'; + @override String sshKeyUnlockTip(String name) { return 'Введите парольную фразу закрытого ключа [$name].'; diff --git a/lib/generated/l10n/l10n_tr.dart b/lib/generated/l10n/l10n_tr.dart index 8d7a95905c..636ade6ca7 100644 --- a/lib/generated/l10n/l10n_tr.dart +++ b/lib/generated/l10n/l10n_tr.dart @@ -606,6 +606,9 @@ class AppLocalizationsTr extends AppLocalizations { String get sshKeyPublicKeyTip => 'Bu satırı sunucudaki ~/.ssh/authorized_keys dosyasına ekleyin.'; + @override + String get sshKeyRecommended => 'Önerilen'; + @override String sshKeyUnlockTip(String name) { return '[$name] özel anahtarının parolasını girin.'; diff --git a/lib/generated/l10n/l10n_uk.dart b/lib/generated/l10n/l10n_uk.dart index 067f488048..08e8e4bf6d 100644 --- a/lib/generated/l10n/l10n_uk.dart +++ b/lib/generated/l10n/l10n_uk.dart @@ -609,6 +609,9 @@ class AppLocalizationsUk extends AppLocalizations { String get sshKeyPublicKeyTip => 'Додайте цей рядок до ~/.ssh/authorized_keys на сервері.'; + @override + String get sshKeyRecommended => 'Рекомендовано'; + @override String sshKeyUnlockTip(String name) { return 'Введіть парольну фразу закритого ключа [$name].'; diff --git a/lib/generated/l10n/l10n_zh.dart b/lib/generated/l10n/l10n_zh.dart index 088f033ae1..f75a458480 100644 --- a/lib/generated/l10n/l10n_zh.dart +++ b/lib/generated/l10n/l10n_zh.dart @@ -561,6 +561,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get sshKeyPublicKeyTip => '将此行追加到服务器的 ~/.ssh/authorized_keys。'; + @override + String get sshKeyRecommended => '推荐'; + @override String sshKeyUnlockTip(String name) { return '请输入私钥 [$name] 的口令。'; @@ -2006,6 +2009,9 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get sshKeyPublicKeyTip => '將此行附加到伺服器的 ~/.ssh/authorized_keys。'; + @override + String get sshKeyRecommended => '推薦'; + @override String sshKeyUnlockTip(String name) { return '請輸入私密金鑰 [$name] 的通行密碼。'; diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index a4053ad173..06bfd9349a 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -108,6 +108,7 @@ "sshKeyPassphraseWrong": "Falsche Passphrase.", "sshKeyPublicKey": "Öffentlicher Schlüssel", "sshKeyPublicKeyTip": "Diese Zeile an ~/.ssh/authorized_keys auf dem Server anhängen.", + "sshKeyRecommended": "Empfohlen", "sshKeyUnlockTip": "Passphrase für den privaten Schlüssel [{name}] eingeben.", "unused": "Ungenutzt", "dangling": "Verwaist", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index baf636588b..ec331689d1 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -191,6 +191,7 @@ "sshKeyPassphraseWrong": "Wrong passphrase.", "sshKeyPublicKey": "Public key", "sshKeyPublicKeyTip": "Append this line to ~/.ssh/authorized_keys on the server.", + "sshKeyRecommended": "Recommended", "sshKeyUnlockTip": "Enter the passphrase for the private key [{name}].", "@sshKeyUnlockTip": { "placeholders": { diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 798863339d..a3c5fd539c 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -108,6 +108,7 @@ "sshKeyPassphraseWrong": "Frase de contraseña incorrecta.", "sshKeyPublicKey": "Clave pública", "sshKeyPublicKeyTip": "Añade esta línea a ~/.ssh/authorized_keys en el servidor.", + "sshKeyRecommended": "Recomendado", "sshKeyUnlockTip": "Introduce la frase de contraseña de la clave privada [{name}].", "unused": "Sin usar", "dangling": "Colgante", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 448616b8fc..3945e5eeb5 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -108,6 +108,7 @@ "sshKeyPassphraseWrong": "Phrase secrète incorrecte.", "sshKeyPublicKey": "Clé publique", "sshKeyPublicKeyTip": "Ajoutez cette ligne à ~/.ssh/authorized_keys sur le serveur.", + "sshKeyRecommended": "Recommandé", "sshKeyUnlockTip": "Saisissez la phrase secrète de la clé privée [{name}].", "unused": "Inutilisé", "dangling": "Fantôme", diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 6f64e004eb..179b31d677 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -108,6 +108,7 @@ "sshKeyPassphraseWrong": "Frasa sandi salah.", "sshKeyPublicKey": "Kunci publik", "sshKeyPublicKeyTip": "Tambahkan baris ini ke ~/.ssh/authorized_keys di server.", + "sshKeyRecommended": "Disarankan", "sshKeyUnlockTip": "Masukkan frasa sandi untuk kunci privat [{name}].", "unused": "Tidak terpakai", "dangling": "Menggantung", diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index 1b7c6a1242..71e85434e8 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -108,6 +108,7 @@ "sshKeyPassphraseWrong": "Passphrase errata.", "sshKeyPublicKey": "Chiave pubblica", "sshKeyPublicKeyTip": "Aggiungi questa riga a ~/.ssh/authorized_keys sul server.", + "sshKeyRecommended": "Consigliato", "sshKeyUnlockTip": "Inserisci la passphrase della chiave privata [{name}].", "unused": "Inutilizzato", "dangling": "Orfana", diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index b6d83b562e..6c54f81d49 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -108,6 +108,7 @@ "sshKeyPassphraseWrong": "パスフレーズが違います。", "sshKeyPublicKey": "公開鍵", "sshKeyPublicKeyTip": "この行をサーバーの ~/.ssh/authorized_keys に追記してください。", + "sshKeyRecommended": "推奨", "sshKeyUnlockTip": "秘密鍵 [{name}] のパスフレーズを入力してください。", "unused": "未使用", "dangling": "未タグ", diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 0fe385f408..7f01585536 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -121,6 +121,7 @@ "sshKeyPassphraseWrong": "암호가 올바르지 않습니다.", "sshKeyPublicKey": "공개 키", "sshKeyPublicKeyTip": "이 줄을 서버의 ~/.ssh/authorized_keys에 추가하세요.", + "sshKeyRecommended": "권장", "sshKeyUnlockTip": "개인 키 [{name}]의 암호를 입력하세요.", "unused": "미사용", "dangling": "댕글링", diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb index 69d9cfe849..4c86ec90f3 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -108,6 +108,7 @@ "sshKeyPassphraseWrong": "Onjuiste wachtwoordzin.", "sshKeyPublicKey": "Publieke sleutel", "sshKeyPublicKeyTip": "Voeg deze regel toe aan ~/.ssh/authorized_keys op de server.", + "sshKeyRecommended": "Aanbevolen", "sshKeyUnlockTip": "Voer de wachtwoordzin voor de privésleutel [{name}] in.", "unused": "Ongebruikt", "dangling": "Bungelend", diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index 99e34579fb..86ec1fa24d 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -108,6 +108,7 @@ "sshKeyPassphraseWrong": "Frase-passe incorreta.", "sshKeyPublicKey": "Chave pública", "sshKeyPublicKeyTip": "Acrescente esta linha a ~/.ssh/authorized_keys no servidor.", + "sshKeyRecommended": "Recomendado", "sshKeyUnlockTip": "Introduza a frase-passe da chave privada [{name}].", "unused": "Não utilizado", "dangling": "Sem referência", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index f13d5676d4..bbce4b6dd6 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -108,6 +108,7 @@ "sshKeyPassphraseWrong": "Неверная парольная фраза.", "sshKeyPublicKey": "Открытый ключ", "sshKeyPublicKeyTip": "Добавьте эту строку в ~/.ssh/authorized_keys на сервере.", + "sshKeyRecommended": "Рекомендуется", "sshKeyUnlockTip": "Введите парольную фразу закрытого ключа [{name}].", "unused": "Не используется", "dangling": "Висячий", diff --git a/lib/l10n/app_tr.arb b/lib/l10n/app_tr.arb index 3243b0bf62..93dee1b588 100644 --- a/lib/l10n/app_tr.arb +++ b/lib/l10n/app_tr.arb @@ -108,6 +108,7 @@ "sshKeyPassphraseWrong": "Parola yanlış.", "sshKeyPublicKey": "Genel anahtar", "sshKeyPublicKeyTip": "Bu satırı sunucudaki ~/.ssh/authorized_keys dosyasına ekleyin.", + "sshKeyRecommended": "Önerilen", "sshKeyUnlockTip": "[{name}] özel anahtarının parolasını girin.", "unused": "Kullanılmıyor", "dangling": "Askıda", diff --git a/lib/l10n/app_uk.arb b/lib/l10n/app_uk.arb index 2a8b875ed3..238819d03d 100644 --- a/lib/l10n/app_uk.arb +++ b/lib/l10n/app_uk.arb @@ -108,6 +108,7 @@ "sshKeyPassphraseWrong": "Неправильна парольна фраза.", "sshKeyPublicKey": "Відкритий ключ", "sshKeyPublicKeyTip": "Додайте цей рядок до ~/.ssh/authorized_keys на сервері.", + "sshKeyRecommended": "Рекомендовано", "sshKeyUnlockTip": "Введіть парольну фразу закритого ключа [{name}].", "unused": "Не використовується", "dangling": "Висячий", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index fbdafc81cd..7c17af0e44 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -181,6 +181,7 @@ "sshKeyPassphraseWrong": "口令错误。", "sshKeyPublicKey": "公钥", "sshKeyPublicKeyTip": "将此行追加到服务器的 ~/.ssh/authorized_keys。", + "sshKeyRecommended": "推荐", "sshKeyUnlockTip": "请输入私钥 [{name}] 的口令。", "unused": "未使用", "dangling": "悬空", diff --git a/lib/l10n/app_zh_tw.arb b/lib/l10n/app_zh_tw.arb index 0d1d70edaf..77f6a3035d 100644 --- a/lib/l10n/app_zh_tw.arb +++ b/lib/l10n/app_zh_tw.arb @@ -181,6 +181,7 @@ "sshKeyPassphraseWrong": "通行密碼錯誤。", "sshKeyPublicKey": "公開金鑰", "sshKeyPublicKeyTip": "將此行附加到伺服器的 ~/.ssh/authorized_keys。", + "sshKeyRecommended": "推薦", "sshKeyUnlockTip": "請輸入私密金鑰 [{name}] 的通行密碼。", "unused": "未使用", "dangling": "懸空", diff --git a/lib/view/page/private_key/generate.dart b/lib/view/page/private_key/generate.dart index ca4ad47e23..dc6d82fbd1 100644 --- a/lib/view/page/private_key/generate.dart +++ b/lib/view/page/private_key/generate.dart @@ -195,10 +195,11 @@ class _PrivateKeyGeneratePageState SshKeyAlgorithm.rsa4096 => 'RSA 4096', }; - /// Why someone would pick this one. Untranslated on purpose: they are - /// algorithm names and the one English word among them is the default. + /// Why someone would pick this one. The identifiers stay as they are — they + /// are what a server names in its config, not prose — while the line for the + /// default is a sentence and is translated. String _algorithmSubtitle(SshKeyAlgorithm algorithm) => switch (algorithm) { - SshKeyAlgorithm.ed25519 => 'Recommended', + SshKeyAlgorithm.ed25519 => l10n.sshKeyRecommended, SshKeyAlgorithm.ecdsaP256 => 'ecdsa-sha2-nistp256', SshKeyAlgorithm.rsa2048 || SshKeyAlgorithm.rsa4096 => 'ssh-rsa', }; diff --git a/test/identity_file_key_test.dart b/test/identity_file_key_test.dart index aceebbe338..505bb3c300 100644 --- a/test/identity_file_key_test.dart +++ b/test/identity_file_key_test.dart @@ -230,6 +230,40 @@ void main() { test('no key configured is not an error', () { expect(resolvePrivateKey(const SshCredential(ip: 'a')), isNull); }); + + // The size check threw inside a `catch (_)` meant for a failed stat, so + // the file it had just rejected was read into memory on the next line + test('a file past the size cap is refused, not read anyway', () { + final file = File('${tempDir.path}/huge') + ..writeAsStringSync('x' * (1024 * 1024 + 1)); + + final ssh = SshCredential(ip: 'a', keyPath: file.path); + expect( + () => resolvePrivateKey(ssh), + throwsA( + predicate( + (e) => e.toString().contains('${tempDir.path}/huge'), + 'names the file it refused', + ), + ), + ); + }); + + test('and the async loader refuses it too', () async { + final file = File('${tempDir.path}/huge') + ..writeAsStringSync('x' * (1024 * 1024 + 1)); + + final ssh = SshCredential(ip: 'a', keyPath: file.path); + await expectLater( + resolvePrivateKeyAsync(ssh), + throwsA( + predicate( + (e) => e.toString().contains('${tempDir.path}/huge'), + 'names the file it refused', + ), + ), + ); + }); }); group('genClient', () { From 762b16a660e4e9e995ffe490a61c758881e38e88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?lollipopkit=F0=9F=8F=B3=EF=B8=8F=E2=80=8D=E2=9A=A7?= =?UTF-8?q?=EF=B8=8F?= <10864310+lollipopkit@users.noreply.github.com> Date: Mon, 24 Aug 2026 13:12:12 +0800 Subject: [PATCH 10/11] fix --- packages/circle_chart | 2 +- packages/flutter_pty | 2 +- packages/watch_connectivity | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/circle_chart b/packages/circle_chart index 25534368f5..568bdb4d61 160000 --- a/packages/circle_chart +++ b/packages/circle_chart @@ -1 +1 @@ -Subproject commit 25534368f55e4f0e865f01d6c971f0e8176ed5ea +Subproject commit 568bdb4d61e57b4cf742fd170120db19b71f488f diff --git a/packages/flutter_pty b/packages/flutter_pty index 1ba7ed981d..ce3504158e 160000 --- a/packages/flutter_pty +++ b/packages/flutter_pty @@ -1 +1 @@ -Subproject commit 1ba7ed981dec55587e4591993a91f5c0060647d4 +Subproject commit ce3504158e99452ca8aa1373a88d3048cae548ed diff --git a/packages/watch_connectivity b/packages/watch_connectivity index aeb684d6b7..45e5c5257c 160000 --- a/packages/watch_connectivity +++ b/packages/watch_connectivity @@ -1 +1 @@ -Subproject commit aeb684d6b7991b44ba7f4a1cc9c2bd1ca9a34d2a +Subproject commit 45e5c5257c7160951ce724e8e1919b93ddcf5fde From bd55492e55108a59018f3559f0a95ce9b7ae0a53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?lollipopkit=F0=9F=8F=B3=EF=B8=8F=E2=80=8D=E2=9A=A7?= =?UTF-8?q?=EF=B8=8F?= <10864310+lollipopkit@users.noreply.github.com> Date: Mon, 24 Aug 2026 13:59:48 +0800 Subject: [PATCH 11/11] fix: review findings across the key, transfer and host-key paths Key loading - resolvePrivateKey and its async twin read through one open handle with a bounded read, instead of stat-then-read. The two could disagree: between them the path can be replaced or appended to, so the size that was checked was not the size that was read. This also subsumes the earlier bug where the size check threw inside a catch meant for a failed stat. - A PEM that will not parse leaves _authenticatedClient as an SSHErr naming the key, rather than as whatever the parser threw. - The unlock cache had two spellings for one key: connections use SshCredential.keyRef (`id:`), the editor used the bare id. Editing or deleting an encrypted key therefore left a decrypted copy in place and the next connection went on using the replaced key. One helper now, with a test that the two agree. - The keygen page drops a key generated after the page has gone: there is no longer anywhere to show the public half, which is the only reason it is a page. Transfers - Staging names carry a per-isolate token. The counter was isolate-local and every worker starts a fresh one at zero, so two transfers to one destination both picked `.sb-part-0`. - Cancellation deletes the one path the transfer reported. Sweeping by pattern deleted a sibling transfer's file, and for a download swept nothing at all. Every backend that stages where this side can reach now reports where, before writing a byte. - Cancelling during the key-unlock prompt no longer spawns the isolate after the await. - A download idle timeout closes the remote handle and ignores the orphaned read, which was still writing into a file about to be closed and deleted. - Both SFTP replacement fallbacks move the destination aside rather than deleting it. Reading 'rename failed and the destination stats' as 'destination is in the way' was a guess, and a rename refused for permission or quota then had a good file deleted on its behalf. Host keys - forgetHostKey and forgetHostKeyFingerprints join the queue the acceptance writes use. Outside it, a queued acceptance could read the map as it was before a forget and put the revoked fingerprint back. - The id/type split is on the last separator, not the first: a key type never contains `::` and an id restored from a backup can. And forgetting an id no longer reaches into another that extends it. - A jump connection's forwarded socket owns the client that carries it, so the authenticated jump session is closed with the target instead of outliving the process. Elsewhere - ProxyCommand refuses a host or user carrying shell syntax. The expansion is textual and runs under sh -c, and the address can come from an imported ssh_config, a restored backup or a synced peer. - The v1 full restore is all-or-nothing again: replaceAll deletes everything first, so skipping a record it could not write left the user with neither. - A backup record written before ids existed takes the map key as its id instead of decoding to null and dropping the whole store. - m004 keeps the first key under a duplicated name, rather than handing every server that referenced it to whichever duplicate was read last. --- lib/core/utils/local_file_backend.dart | 14 +- lib/core/utils/monitor_file_backend.dart | 9 +- lib/core/utils/proxy_command_socket.dart | 32 ++- lib/core/utils/server.dart | 252 +++++++++++++----- lib/core/utils/sftp_file_backend.dart | 48 +++- lib/data/model/file/copy_tree.dart | 17 +- lib/data/model/file/file_backend.dart | 37 ++- lib/data/model/file/transfer_status.dart | 39 +-- lib/data/model/file/transfer_worker.dart | 61 +++-- lib/data/model/server/ssh_credential.dart | 12 +- lib/data/store/entity_store.dart | 30 ++- .../store/migrations/m004_kv_to_tables.dart | 7 +- lib/view/page/private_key/edit.dart | 10 +- lib/view/page/private_key/generate.dart | 6 + lib/view/page/setting/seq/known_hosts.dart | 8 +- test/file_browser_test.dart | 7 +- test/host_key_forget_test.dart | 24 +- test/identity_file_key_test.dart | 11 + test/proxy_command_sandbox_test.dart | 46 ++++ 19 files changed, 507 insertions(+), 163 deletions(-) diff --git a/lib/core/utils/local_file_backend.dart b/lib/core/utils/local_file_backend.dart index e4cff95457..2b959622d7 100644 --- a/lib/core/utils/local_file_backend.dart +++ b/lib/core/utils/local_file_backend.dart @@ -98,12 +98,18 @@ class LocalFileBackend implements FileBackend { File(_native(path)).openRead(offset); @override - Future write(String path, Stream> data, {int? size}) async { + Future write( + String path, + Stream> data, { + int? size, + void Function(String staging)? onStaging, + }) async { final native = _native(path); // Beside the destination, not in a temp directory: a rename across // filesystems is a copy, and this one has to be the cheap kind for the // atomicity to be worth anything. - final staging = File('$native.${_stagingSuffix()}'); + final staging = File(stagingNameFor(native)); + onStaging?.call(staging.path); try { final sink = staging.openWrite(); try { @@ -134,10 +140,6 @@ class LocalFileBackend implements FileBackend { @override Future close() async {} - static var _staging = 0; - - String _stagingSuffix() => '${kStagingSuffix.substring(1)}${_staging++}'; - /// POSIX in, whatever this platform uses out. /// /// The interface is POSIX-shaped so that a path can be handed from one diff --git a/lib/core/utils/monitor_file_backend.dart b/lib/core/utils/monitor_file_backend.dart index 572f252817..824db2f36f 100644 --- a/lib/core/utils/monitor_file_backend.dart +++ b/lib/core/utils/monitor_file_backend.dart @@ -93,7 +93,14 @@ class MonitorFileBackend implements FileBackend { } @override - Future write(String path, Stream> data, {int? size}) => + Future write( + String path, + Stream> data, { + int? size, + // Never called: the staging happens inside the agent, under a name this + // side is not told and could not delete anyway. + void Function(String staging)? onStaging, + }) => // Atomic on the agent's side: it stages beside the destination and // renames, which is the same contract the other two backends keep and // the reason this one does not have to stage anything itself. diff --git a/lib/core/utils/proxy_command_socket.dart b/lib/core/utils/proxy_command_socket.dart index 74f5dcd40b..5802579890 100644 --- a/lib/core/utils/proxy_command_socket.dart +++ b/lib/core/utils/proxy_command_socket.dart @@ -193,6 +193,34 @@ class ProxyCommandSocket implements SSHSocket { static String debugExplain(String message, {required bool sandboxed}) => _explainFor(message, sandboxed: sandboxed); + /// Everything a hostname, an IPv4 or IPv6 literal, or a POSIX user name is + /// made of, and nothing a shell reads as syntax. `%` is absent on purpose: + /// a value carrying one could introduce a placeholder of its own. + static final _substitutable = RegExp(r'^[A-Za-z0-9._:@\-\[\]\\]*$'); + + /// Refuses a value that `/bin/sh` would not read as one word. + /// + /// The expansion below is textual and the result is handed to `sh -c`, so a + /// host of `h; curl … | sh` is a local command that runs before anything has + /// been authenticated. That the ProxyCommand itself is the user's own is not + /// the answer: the address it expands is not necessarily — it arrives from + /// an imported `~/.ssh/config`, a restored backup or a synced peer. + /// + /// Rejected rather than quoted. Quoting correctly means knowing which + /// context the placeholder sits in — bare, inside `"…"`, inside `'…'` — and + /// guessing that wrong is how a quoting fix becomes the next injection. + /// Nothing that names a real host or user is refused here. + @visibleForTesting + static String checkSubstitutable(String what, String value) { + if (_substitutable.hasMatch(value)) return value; + throw SSHErr( + type: SSHErrType.connect, + message: + 'ProxyCommand cannot use this $what: "$value" contains characters ' + 'a shell would read as syntax.', + ); + } + static String _resolveCommand({ required String command, required String host, @@ -202,9 +230,9 @@ class ProxyCommandSocket implements SSHSocket { const percentPlaceholder = '\u0000PERCENT\u0000'; return command .replaceAll('%%', percentPlaceholder) - .replaceAll('%h', host) + .replaceAll('%h', checkSubstitutable('host', host)) .replaceAll('%p', port.toString()) - .replaceAll('%r', user) + .replaceAll('%r', checkSubstitutable('user', user)) .replaceAll(percentPlaceholder, '%'); } diff --git a/lib/core/utils/server.dart b/lib/core/utils/server.dart index 9ef42e9773..9ce40b9988 100644 --- a/lib/core/utils/server.dart +++ b/lib/core/utils/server.dart @@ -75,6 +75,27 @@ String getPrivateKey(String id) { /// store, which is a different question. const _keyFileMaxSize = 1024 * 1024; +/// The key file's text, or a refusal if it did not stop inside the cap. +/// +/// One bounded read off one open handle, rather than a stat followed by an +/// unbounded read: between those two the path can be replaced — a symlink +/// repointed, a file appended to — and the size that was checked is then not +/// the size that is read. Asking for one byte past the cap is what tells the +/// two cases apart without ever holding more than that. +String _decodeCapped(String path, List bytes) { + if (bytes.length > _keyFileMaxSize) { + throw SSHErr( + type: SSHErrType.noPrivateKey, + message: l10n.fileTooLarge( + path, + '>${_keyFileMaxSize.bytes2Str}', + _keyFileMaxSize.bytes2Str, + ), + ); + } + return utf8.decode(bytes); +} + /// The PEM [ssh] authenticates with, or null when it has no key at all. /// /// Two sources that are not interchangeable, which is the whole point of them @@ -106,27 +127,12 @@ String? resolvePrivateKey(SshCredential ssh) { final expanded = SSHConfig.expandHome(keyPath); try { - final file = File(expanded); - // Guard against unbounded key files on the main isolate. + final handle = File(expanded).openSync(); try { - final size = file.statSync().size; - if (size > _keyFileMaxSize) { - throw SSHErr( - type: SSHErrType.noPrivateKey, - message: l10n.fileTooLarge( - expanded, - size.bytes2Str, - _keyFileMaxSize.bytes2Str, - ), - ); - } - } catch (e) { - // A failed stat is non-fatal — let the read attempt decide. The size - // check above is not: rethrowing is what stops an oversized file going - // on to be read into memory anyway. - if (e is SSHErr) rethrow; + return _decodeCapped(expanded, handle.readSync(_keyFileMaxSize + 1)); + } finally { + handle.closeSync(); } - return file.readAsStringSync(); } catch (e) { if (e is SSHErr) rethrow; throw SSHErr( @@ -150,19 +156,12 @@ Future resolvePrivateKeyAsync(SshCredential ssh) async { } final expanded = SSHConfig.expandHome(keyPath); try { - final file = File(expanded); - final stat = await file.stat(); - if (stat.size > _keyFileMaxSize) { - throw SSHErr( - type: SSHErrType.noPrivateKey, - message: l10n.fileTooLarge( - expanded, - stat.size.bytes2Str, - _keyFileMaxSize.bytes2Str, - ), - ); + final handle = await File(expanded).open(); + try { + return _decodeCapped(expanded, await handle.read(_keyFileMaxSize + 1)); + } finally { + await handle.close(); } - return await file.readAsString(); } catch (e) { if (e is SSHErr) rethrow; throw SSHErr( @@ -283,7 +282,8 @@ Future genClient( visitedServerIds: {...chainVisitedServerIds}, ); - return await jumpClient.forwardLocal(ssh.ip, ssh.port); + final forwarded = await jumpClient.forwardLocal(ssh.ip, ssh.port); + return _JumpSocket(forwarded, jumpClient); } catch (e, stack) { jumpClient?.close(); if (!_isJumpFailoverError(e)) { @@ -416,6 +416,23 @@ Future _authenticatedClient({ cacheKey: keyRef, keyName: privateKeyDisplayName(keyRef), ); + final List identities; + try { + // Must use [compute] here, instead of [Computer.shared.start] + identities = await compute(loadIdentity, privateKey); + } catch (e) { + // A PEM that will not parse is a key problem and the caller has a category + // for those. Left raw it arrived as whatever the parser threw — naming + // neither the key nor the server, and matching none of the handling every + // other key failure gets. + throw SSHErr( + type: SSHErrType.noPrivateKey, + message: l10n.privateKeyFileUnreadable( + privateKeyDisplayName(keyRef), + '$e', + ), + ); + } return SSHClient( socket, // The same fallback user the password branch above uses. Key auth read @@ -423,8 +440,7 @@ Future _authenticatedClient({ // is where `alterUser` comes from — authenticated as the primary host's // user and failed with a permission error naming neither. username: alterUser ?? ssh.user, - // Must use [compute] here, instead of [Computer.shared.start] - identities: await compute(loadIdentity, privateKey), + identities: identities, onPasswordRequest: ssh.pwd?.isNotEmpty == true ? () => ssh.pwd : null, onUserInfoRequest: onKeyboardInteractive == null ? null @@ -433,6 +449,56 @@ Future _authenticatedClient({ ); } +/// A forwarded channel that owns the jump connection carrying it. +/// +/// `forwardLocal` hands back a channel and nothing else, so the authenticated +/// jump client it came from had no owner: on success nobody held it — closing +/// the target closed the channel and left the jump session, its socket and its +/// process running until the far end timed them out — and on a target failure +/// the socket was destroyed with the jump client still open. Every connection +/// through a jump host leaked one, and a status poll that keeps failing on the +/// target's host key leaked one per attempt. +/// +/// Everything is the channel's; the only addition is that closing this closes +/// the client behind it, which is the ownership the return type could not +/// express. +class _JumpSocket implements SSHSocket { + _JumpSocket(this._inner, this._jumpClient); + + final SSHSocket _inner; + final SSHClient _jumpClient; + + @override + Stream get stream => _inner.stream; + + @override + StreamSink> get sink => _inner.sink; + + @override + Future get done => _inner.done; + + @override + Future close() async { + try { + await _inner.close(); + } finally { + _jumpClient.close(); + } + } + + @override + void destroy() { + try { + _inner.destroy(); + } finally { + _jumpClient.close(); + } + } + + @override + Future flush() => _inner.flush(); +} + typedef HostKeyPersistCallback = FutureOr Function(String storageKey, String fingerprint); @@ -592,8 +658,27 @@ Map _loadKnownHostFingerprints() { } } +/// One queue for every change to the remembered fingerprints. +/// +/// Read-modify-write over a whole map, from callers that do not know about +/// each other: an acceptance arriving from a transfer isolate, a forget from +/// the settings page, a server being deleted. Interleaving two of those loses +/// one of them, and when the pair is an acceptance and a forget, the one lost +/// can be the forget — the queued write reads the map as it was before the +/// pruning and puts the fingerprint the user just revoked straight back. Future _hostKeyPersistence = Future.value(); +Future _enqueueHostKeyWrite(String what, void Function() body) { + _hostKeyPersistence = _hostKeyPersistence.then((_) async { + try { + body(); + } catch (e, stack) { + Loggers.app.warning('$what failed', e, stack); + } + }); + return _hostKeyPersistence; +} + Future persistHostKeyFingerprint( String storageKey, String fingerprint, @@ -618,34 +703,56 @@ Future persistHostKeyFingerprint( /// For an ad-hoc connection that was never kept: it accepted a key under an id /// nothing will ever look up again, and one entry per trial connection is a /// setting that only grows. -void forgetHostKeyFingerprints(String serverId) { - try { - final prop = Stores.setting.sshKnownHostFingerprints; - final known = Map.from(prop.get()); - final updated = withoutHostKeysFor(known, serverId); - if (updated.length == known.length) return; - prop.put(updated); - } catch (e, stack) { - Loggers.app.warning('Forget SSH host key fingerprints failed', e, stack); - } +/// Queued behind any acceptance still waiting to be written — see +/// [_hostKeyPersistence]. Awaiting the returned future is optional; joining the +/// queue is not. +Future forgetHostKeyFingerprints(String serverId) => + _enqueueHostKeyWrite('Forget SSH host key fingerprints', () { + final prop = Stores.setting.sshKnownHostFingerprints; + final known = Map.from(prop.get()); + final updated = withoutHostKeysFor(known, serverId); + if (updated.length == known.length) return; + prop.put(updated); + }); + +/// A stored key read back as the server id and the key type it was built from. +/// +/// The separator carries the whole of the correctness here, and which one to +/// split on follows from what each half can contain. A key type is an SSH +/// algorithm name — `ssh-ed25519`, `rsa-sha2-512` — and never holds a `::`, +/// while an id can: one restored from a backup is whatever that file said. +/// So the id is everything before the **last** separator. +/// +/// It was the first one, which reads a key belonging to `a::b` as server `a` +/// with the type `b::ssh-rsa`: listed under a server that is not its own, and +/// forgetting the real `a` took it along with `a`'s. +/// +/// A key with no separator at all is its whole self as the id and an empty +/// type — unreadable rather than absent, and a list that dropped it would +/// leave something trusted and invisible. +@visibleForTesting +(String serverId, String keyType) splitHostKeyStorageKey(String storageKey) { + final at = storageKey.lastIndexOf('::'); + if (at < 0) return (storageKey, ''); + return (storageKey.substring(0, at), storageKey.substring(at + 2)); } /// [known] without the entries belonging to [serverId]. /// -/// Split out and pure because the separator carries the whole of the -/// correctness here: keys are `::`, a host may have offered -/// several types, and matching on the id alone would take every other server -/// whose id happens to start with the same characters. +/// Compared as a whole id rather than as a prefix, for the reason +/// [splitHostKeyStorageKey] gives: `startsWith('$serverId::')` also matched +/// every server whose id merely *begins* with this one followed by `::`, so +/// forgetting `a` reached into `a::b`. @visibleForTesting Map withoutHostKeysFor( Map known, String serverId, ) { if (serverId.isEmpty) return known; - final prefix = '$serverId::'; return { for (final entry in known.entries) - if (!entry.key.startsWith(prefix)) entry.key: entry.value, + if (splitHostKeyStorageKey(entry.key).$1 != serverId) + entry.key: entry.value, }; } @@ -845,9 +952,20 @@ Future ensureKnownHostKey( } } +/// Whether anything is remembered for [spi] at all. +/// +/// Compared as a whole id, not as a prefix, for the reason +/// [splitHostKeyStorageKey] gives: `startsWith('$id::')` also answered yes for +/// server `a` on a key belonging to the distinct server `a::b`. +/// +/// Any key type counts. Which one a connection ends up negotiating is not +/// knowable without making it, so a server that offered `ssh-rsa` when this +/// was remembered and negotiates `ssh-ed25519` now still reaches the prompt on +/// the connection itself — the same prompt a first connection raises, in a +/// flow that had hoped to have settled it here. bool _hasKnownHostFingerprintForSpi(Spi spi, Map cache) { - final prefix = '${_hostIdentifier(spi)}::'; - return cache.keys.any((key) => key.startsWith(prefix)); + final id = _hostIdentifier(spi); + return cache.keys.any((key) => splitHostKeyStorageKey(key).$1 == id); } String _hostKeyStorageKey(Spi spi, String keyType) { @@ -936,21 +1054,14 @@ class KnownHostKey { /// [known] read out as entries, grouped by the server they belong to. /// /// Pure, and split out for the same reason [withoutHostKeysFor] is: the -/// separator is the whole of the correctness. A key type may itself contain no -/// `::`, but an id could — so the split is on the **first** one, and everything -/// after it is the type. -/// -/// Entries whose key has no separator at all are kept under their whole string -/// as the id and an empty type: they are unreadable rather than absent, and a -/// list that silently dropped them would leave something trusted and invisible. +/// separator is the whole of the correctness, and [splitHostKeyStorageKey] is +/// where that decision lives so the two sides cannot disagree about it. Map> groupHostKeysByServer( Map known, ) { final grouped = >{}; for (final entry in known.entries) { - final at = entry.key.indexOf('::'); - final serverId = at < 0 ? entry.key : entry.key.substring(0, at); - final keyType = at < 0 ? '' : entry.key.substring(at + 2); + final (serverId, keyType) = splitHostKeyStorageKey(entry.key); grouped.putIfAbsent(serverId, () => []).add( KnownHostKey( storageKey: entry.key, @@ -971,13 +1082,10 @@ Map> groupHostKeysByServer( /// Beside [forgetHostKeyFingerprints], which takes every type a server /// offered. A host that rotated one algorithm and kept another is the case /// this exists for. -void forgetHostKey(String storageKey) { - try { - final prop = Stores.setting.sshKnownHostFingerprints; - final known = Map.from(prop.get()); - if (known.remove(storageKey) == null) return; - prop.put(known); - } catch (e, stack) { - Loggers.app.warning('Forget SSH host key failed', e, stack); - } -} +Future forgetHostKey(String storageKey) => + _enqueueHostKeyWrite('Forget SSH host key', () { + final prop = Stores.setting.sshKnownHostFingerprints; + final known = Map.from(prop.get()); + if (known.remove(storageKey) == null) return; + prop.put(known); + }); diff --git a/lib/core/utils/sftp_file_backend.dart b/lib/core/utils/sftp_file_backend.dart index 020a0d7854..d80529e960 100644 --- a/lib/core/utils/sftp_file_backend.dart +++ b/lib/core/utils/sftp_file_backend.dart @@ -205,10 +205,16 @@ class SftpFileBackend implements FileBackend { } @override - Future write(String path, Stream> data, {int? size}) async { + Future write( + String path, + Stream> data, { + int? size, + void Function(String staging)? onStaging, + }) async { // Beside the destination for the same reason as the local backend: a // rename on the far side is cheap and atomic only within one filesystem. - final staging = '$path.${_stagingSuffix()}'; + final staging = stagingNameFor(path); + onStaging?.call(staging); var wrote = false; try { final file = await _bounded( @@ -261,16 +267,38 @@ class SftpFileBackend implements FileBackend { failure = e; } - // Only "the destination is in the way" is worth a second attempt. If the - // path cannot even be stat'd, the rename failed for its own reasons and - // deleting something on the strength of a misread would be worse. + // Moved aside, never deleted first. Reading "the rename failed and the + // destination stats" as "the destination is in the way" was a guess: a + // server refuses a rename for permission, quota or policy reasons too, + // with the destination sitting there intact, and the remove that followed + // could well succeed and take a good file with it. + // + // Renaming the destination away asks the same question without betting on + // the answer — a refusal that was not about the destination refuses this + // too, and nothing has been lost. Only once the staged copy is in place + // does the old one go. + final aside = stagingNameFor(path); try { - if (await stat(path) == null) throw failure; + await _bounded('rename', _sftp.rename(path, aside)); } catch (_) { throw failure; } - await _bounded('remove', _sftp.remove(path)); - await _bounded('rename', _sftp.rename(staging, path)); + try { + await _bounded('rename', _sftp.rename(staging, path)); + } catch (_) { + // Put it back: losing the destination to a replacement that did not + // happen is the whole thing this path exists to avoid. + try { + await _bounded('rename', _sftp.rename(aside, path)); + } catch (_) {} + rethrow; + } + try { + await _bounded('remove', _sftp.remove(aside)); + } catch (_) { + // The replacement is done. A leftover beside it is visible in the + // browser and not worth failing a finished write for. + } } @override @@ -279,10 +307,6 @@ class SftpFileBackend implements FileBackend { Future _bounded(String what, Future future) => timeout == null ? future : withSftpOpTimeout(what, future, timeout!); - static var _staging = 0; - - static String _stagingSuffix() => '${kStagingSuffix.substring(1)}${_staging++}'; - /// `SSH_FX_NO_SUCH_FILE`, from the SFTP protocol. static const _sftpStatusNoSuchFile = 2; diff --git a/lib/data/model/file/copy_tree.dart b/lib/data/model/file/copy_tree.dart index 29fd7ffc7c..044a80cf12 100644 --- a/lib/data/model/file/copy_tree.dart +++ b/lib/data/model/file/copy_tree.dart @@ -133,10 +133,6 @@ Future runCopy( var transferred = 0; for (final item in plan.items) { checkCancelled(); - // Told before the write starts, so a caller whose process is about to be - // killed mid-file knows what to clean up. `write` removes its own staging - // on a normal failure; being killed is not one. - onStaging?.call(item.to); final counted = source.read(item.from).map((chunk) { checkCancelled(); transferred += chunk.length; @@ -145,7 +141,18 @@ Future runCopy( }); // `write` stages beside the destination and renames, so a file that dies // halfway leaves no half-file under the name something else opens. - await dest.write(item.to, counted, size: item.size); + // + // The staging path comes back from `write`, which is the only place that + // knows it, and it arrives before any byte is written there — so a caller + // whose process is about to be killed mid-file knows the one file to + // remove. `write` removes its own leftovers when it fails; being killed + // is not a failure it gets to handle. + await dest.write( + item.to, + counted, + size: item.size, + onStaging: onStaging, + ); } } diff --git a/lib/data/model/file/file_backend.dart b/lib/data/model/file/file_backend.dart index 5e27ec2ffe..cd1f1e6be6 100644 --- a/lib/data/model/file/file_backend.dart +++ b/lib/data/model/file/file_backend.dart @@ -1,3 +1,5 @@ +import 'dart:math'; + import 'package:meta/meta.dart'; /// What a directory listing can say about one entry. @@ -73,6 +75,27 @@ bool isStagingOf(String name, String destination) { return name.startsWith('$base$kStagingSuffix'); } +/// Where to park a write to [destination] until it can be renamed into place. +/// +/// The counter alone was unique only within the isolate holding it, and every +/// transfer runs in a fresh one that starts it at zero — so two transfers to +/// the same destination both picked `.sb-part-0`, wrote into each +/// other's bytes, and cleaned up each other's file. [_stagingToken] is drawn +/// once per isolate from a source that does not repeat across them, which is +/// what makes the two disagree; the counter then separates writes within one. +String stagingNameFor(String destination) => + '$destination$kStagingSuffix$_stagingToken-${_staging++}'; + +var _staging = 0; + +/// Not `Random()`: its default seed is derived from the clock, and two +/// isolates spawned in the same millisecond would draw the same token — the +/// collision this exists to prevent. +final _stagingToken = Random.secure() + .nextInt(1 << 32) + .toRadixString(36) + .padLeft(7, '0'); + /// The bits [FileEntry.mode] keeps: `rwxrwxrwx` plus setuid, setgid and /// sticky, and nothing above them. const kFilePermMask = 0xFFF; @@ -167,7 +190,19 @@ abstract interface class FileBackend { /// and renames, so a transfer that dies halfway leaves no half-file under the /// name something else is about to open. [size] is a hint for progress and /// pre-allocation, not a contract. - Future write(String path, Stream> data, {int? size}); + /// + /// [onStaging] is called with the path being staged onto, before anything is + /// written there, for the caller that has to clean up after a process this + /// side kills: `write` removes its own leftovers when it fails, and being + /// killed is not a failure it gets to handle. A backend that stages + /// somewhere this side cannot reach — the agent does its own — never calls + /// it, and there is correspondingly nothing here to remove. + Future write( + String path, + Stream> data, { + int? size, + void Function(String staging)? onStaging, + }); /// Releases whatever this holds. A backend may be used again afterwards only /// if its own documentation says so. diff --git a/lib/data/model/file/transfer_status.dart b/lib/data/model/file/transfer_status.dart index ec3ca33da9..d5b9b525a7 100644 --- a/lib/data/model/file/transfer_status.dart +++ b/lib/data/model/file/transfer_status.dart @@ -91,38 +91,33 @@ class FileTransferStatus { /// copy running. bool get _cancelled => _disposed; - /// Removes a staged copy the transfer did not get to rename. + /// Removes the staged copy this transfer did not get to rename. /// /// Only where this device is the destination. Killing an isolate skips the - /// cleanup its own `catch` would have done, and a `.sb-part-N` nobody + /// cleanup its own `catch` would have done, and a `.sb-part-…` nobody /// deletes is worse than the partial file this staging replaced. /// - /// Swept by name rather than deleted by path: the backend picks the staging - /// name inside `write`, and the two sides agree on the pattern rather than - /// on the whole string. Two transfers staging the same destination are - /// already writing over each other. + /// The one path the transfer reported, not every name in the directory that + /// looks like one. Sweeping by pattern deleted a *sibling* transfer's file + /// whenever two of them targeted the same basename — and for a download it + /// swept nothing at all, because what arrives here is already the staging + /// path and no file is a staged copy of that. Every backend that stages + /// somewhere this side can reach now reports where, before it writes a byte. /// /// A cancelled *upload* leaves one on the server, which this side cannot /// reach without opening the connection again. It is at least visible in the /// browser, beside the file it was going to become. void _discardStaging() { - final destination = stagingPath; - if (destination == null || job.to is! LocalFileRef) return; + final staging = stagingPath; + if (staging == null || job.to is! LocalFileRef) return; stagingPath = null; - unawaited(_sweep(destination)); + unawaited(_remove(staging)); } - static Future _sweep(String destination) async { + static Future _remove(String staging) async { try { - final native = LocalFileBackend.nativePath(destination); - final dir = File(native).parent; - if (!await dir.exists()) return; - await for (final entity in dir.list(followLinks: false)) { - final name = entity.path.split(Platform.pathSeparator).last; - if (entity is File && isStagingOf(name, destination)) { - await entity.delete(); - } - } + final file = File(LocalFileBackend.nativePath(staging)); + if (await file.exists()) await file.delete(); } catch (e, s) { Loggers.app.warning('Failed to clean up after a cancelled transfer', e, s); } @@ -136,6 +131,12 @@ class FileTransferStatus { for (final ref in [job.from, job.to]) { if (ref is SftpFileRef) await ref.creds.unlockKeys(); } + // Unlocking asks for a passphrase, so this await lasts as long as + // somebody takes to answer it — plenty of time to cancel. `dispose` has + // already killed a worker that was never started; going on would spawn a + // fresh isolate for a transfer that is no longer in the list, and it + // would run to completion with nothing watching it. + if (_disposed) return; await worker!.init(); } catch (e, s) { Loggers.app.warning('Failed to initialize the transfer worker', e, s); diff --git a/lib/data/model/file/transfer_worker.dart b/lib/data/model/file/transfer_worker.dart index 12691743ec..664c4eb042 100644 --- a/lib/data/model/file/transfer_worker.dart +++ b/lib/data/model/file/transfer_worker.dart @@ -355,7 +355,7 @@ Future _download( // Beside the destination, not under its name: a download that dies // halfway used to leave a truncated file where a whole one was expected, // and nothing about it said so. - staging = File('${to.path}.$_stagingSuffix'); + staging = File(stagingNameFor(to.path)); mainSendPort.send(TransferStaging(staging.path)); final localFile = await staging.open(mode: FileMode.write); @@ -393,9 +393,10 @@ Future _download( }); } + Future? pending; try { resetIdleTimer(); - final downloadFuture = openedRemoteFile.downloadToRandomAccess( + pending = openedRemoteFile.downloadToRandomAccess( localFile, length: length, offset: offset, @@ -424,13 +425,24 @@ Future _download( }, ); final segmentBytes = await Future.any([ - downloadFuture, + pending, idleTimeout.future, ]); totalBytes += segmentBytes; chunkCount += (segmentBytes / _sftpChunkSize).ceil(); } on TimeoutException { + // `Future.any` stops waiting; it does not stop the download, which + // goes on writing into `localFile` — the handle closed a few lines + // below and the file deleted after that. Closing the remote file is + // what ends the reads still in flight, and `ignore` is what keeps + // their failure from surfacing later as an unhandled asynchronous + // error with no transfer left to attach it to. + pending?.ignore(); + try { + await openedRemoteFile.close(); + } catch (_) {} + remoteFile = null; throw SftpError('Download timed out at offset=$offset'); } finally { idleTimer?.cancel(); @@ -474,17 +486,9 @@ Future _download( } } -/// The name a half-finished transfer is parked under. -/// -/// A counter rather than a timestamp: two transfers of the same file, started -/// in the same millisecond, must not stage onto each other. -var _staging = 0; - -String get _stagingSuffix => 'sb-part-${_staging++}'; - -/// Renames [staging] over [path], deleting what is there if the server will -/// not replace it itself. See `SftpFileBackend._replace`, which faces the same -/// `SSH_FXP_RENAME` rule. +/// Renames [staging] over [path], moving what is there out of the way if the +/// server will not replace it itself. See `SftpFileBackend._replace`, which +/// faces the same `SSH_FXP_RENAME` rule and does the same thing. Future _replaceRemote( SftpClient sftp, String staging, @@ -499,17 +503,30 @@ Future _replaceRemote( failure = e; } - // Only "the destination is in the way" is worth a second attempt. Anything - // else — no permission, no such directory — is the rename's own answer, and - // deleting something on the strength of a misread would be worse than - // failing. + // Moved aside, never deleted first. A successful `stat` was being read as + // "the destination is in the way", but a server refuses a rename for + // permission, quota or policy reasons too, with the destination sitting + // there intact — and the remove that followed could succeed and destroy a + // good remote file on behalf of an upload that was never going to land. + final aside = stagingNameFor(path); try { - await withSftpOpTimeout('stat', sftp.stat(path), timeout); + await withSftpOpTimeout('rename', sftp.rename(path, aside), timeout); } catch (_) { throw failure; } - await withSftpOpTimeout('remove', sftp.remove(path), timeout); - await withSftpOpTimeout('rename', sftp.rename(staging, path), timeout); + try { + await withSftpOpTimeout('rename', sftp.rename(staging, path), timeout); + } catch (_) { + try { + await withSftpOpTimeout('rename', sftp.rename(aside, path), timeout); + } catch (_) {} + rethrow; + } + try { + await withSftpOpTimeout('remove', sftp.remove(aside), timeout); + } catch (_) { + // The replacement is done; a leftover beside it is not worth failing for. + } } Future _discardRemote(SftpClient? sftp, String? staging) async { @@ -568,7 +585,7 @@ Future _upload( sftp = openedSftp; // Beside the destination rather than onto it. Truncating first meant a // failed upload replaced a good remote file with a partial one. - staging = '${to.path}.$_stagingSuffix'; + staging = stagingNameFor(to.path); Loggers.app.info('Transfer upload opening remote file: $staging'); final openedRemoteFile = await withSftpOpTimeout( 'open remote file for upload', diff --git a/lib/data/model/server/ssh_credential.dart b/lib/data/model/server/ssh_credential.dart index 8e8a80e3a4..001c6b8d17 100644 --- a/lib/data/model/server/ssh_credential.dart +++ b/lib/data/model/server/ssh_credential.dart @@ -115,11 +115,21 @@ final class SshCredential { /// only as long as it does — so the shape of these strings is free to change. String? get keyRef { final id = keyId; - if (id != null) return 'id:$id'; + if (id != null) return keyRefForId(id); final path = keyPath; return path == null ? null : 'path:$path'; } + /// The same reference for a stored key named by its id alone. + /// + /// A function because two places have to arrive at the same string and never + /// did: a connection unlocks under [keyRef], while the key editor invalidated + /// and warmed the cache under the bare id. Editing an encrypted key therefore + /// left the decrypted copy a connection was holding untouched, so the next + /// connection authenticated with the key that had just been replaced — and + /// the passphrase verified on save warmed an entry nothing ever read. + static String keyRefForId(String id) => 'id:$id'; + /// Parses [alterUrl] into its (ip, user, port) parts. Throws [SSHErr] on any /// malformed input rather than guessing — the value is user-entered and a /// silent fallback would connect somewhere unintended. diff --git a/lib/data/store/entity_store.dart b/lib/data/store/entity_store.dart index cfe0d0e809..e9fbe38d75 100644 --- a/lib/data/store/entity_store.dart +++ b/lib/data/store/entity_store.dart @@ -345,7 +345,16 @@ abstract class EntityStore { final raw = backupData[id]; if (raw is! Map) continue; - final item = fromJson(Map.from(raw)); + final json = Map.from(raw); + // A record written before ids existed carries none — the map key was + // its name and that was the whole of its identity. Every `fromJson` + // here requires an id, so such a record decoded to null and was + // skipped, which took the entire store with it: the pass that re-points + // a server at its key or its BMC account by name then had nothing to + // point at, and the server lost the reference or was skipped outright. + // `reconcile` is what turns the name back into this device's id. + if (json['id'] == null) json['id'] = id; + final item = fromJson(json); if (item == null) continue; try { final resolved = reconcile(item); @@ -371,6 +380,13 @@ abstract class EntityStore { /// Replaces everything with [items], for the v1 backup format, which carries /// no per-record timestamps and so can only be taken or left whole. + /// + /// Whole means whole: a write that fails takes the transaction with it. This + /// used to log the record and carry on, but it had already deleted + /// everything — so a backup whose records this schema cannot accept left the + /// user with the rows gone and only some of the replacements written, and a + /// warning in a log as the only sign. [merge] can afford to skip a record + /// because it never removes what it is not replacing; this cannot. bool replaceAll(Iterable items) { SqliteStore.transact(() { for (final id in keys()) { @@ -379,14 +395,10 @@ abstract class EntityStore { db.execute('DELETE FROM $table;'); final written = []; for (final item in items) { - try { - final resolved = reconcile(item); - write(resolved); - written.add(resolved); - synced.stamp(idOf(resolved)); - } on SqliteException catch (e) { - Loggers.app.warning('Restore skipped a $T', e); - } + final resolved = reconcile(item); + write(resolved); + written.add(resolved); + synced.stamp(idOf(resolved)); } // Once every row exists — see [writeLinks]. for (final item in written) { diff --git a/lib/data/store/migrations/m004_kv_to_tables.dart b/lib/data/store/migrations/m004_kv_to_tables.dart index 841d35b5af..fc76a81609 100644 --- a/lib/data/store/migrations/m004_kv_to_tables.dart +++ b/lib/data/store/migrations/m004_kv_to_tables.dart @@ -147,7 +147,12 @@ class KvToTablesMigration implements SchemaMigration { } final id = ShortId.generate(); - ids[oldId] = id; + // The first row under a name wins the reference, and it is also the one + // that keeps the name unrenamed above — so a server that pointed at `X` + // ends up on the key still called `X`. Assigning unconditionally handed + // every such server to whichever duplicate happened to be read last, + // which is to say: to a key the user never chose, silently. + ids.putIfAbsent(oldId, () => id); _db.execute( 'INSERT INTO private_key (id, name, key, updated_at) ' 'VALUES (?, ?, ?, ?);', diff --git a/lib/view/page/private_key/edit.dart b/lib/view/page/private_key/edit.dart index 03f202d8d1..164ffe4e99 100644 --- a/lib/view/page/private_key/edit.dart +++ b/lib/view/page/private_key/edit.dart @@ -11,6 +11,7 @@ import 'package:server_box/core/utils/server.dart'; import 'package:server_box/core/utils/ssh_key_unlock.dart'; import 'package:server_box/core/utils/ssh_keygen.dart'; import 'package:server_box/data/model/server/private_key_info.dart'; +import 'package:server_box/data/model/server/ssh_credential.dart'; import 'package:server_box/data/provider/private_key.dart'; import 'package:server_box/data/res/misc.dart'; import 'package:server_box/data/store/entity_store.dart'; @@ -130,7 +131,7 @@ class _PrivateKeyEditPageState extends ConsumerState { actions: Btn.ok(red: true).toList, ); if (confirmed != true || !context.mounted) return; - PrivateKeyUnlock.forget(pki.id); + PrivateKeyUnlock.forget(SshCredential.keyRefForId(pki.id)); await _notifier.delete(pki); context.pop(); }, @@ -151,7 +152,7 @@ class _PrivateKeyEditPageState extends ConsumerState { try { final opened = await PrivateKeyUnlock.open( pki.key, - cacheKey: pki.id, + cacheKey: SshCredential.keyRefForId(pki.id), keyName: pki.name, ); line = publicKeyLine( @@ -392,9 +393,10 @@ class _PrivateKeyEditPageState extends ConsumerState { // opened for it no longer describes what is stored — and then the // passphrase just verified is put back, rather than asking for it again // seconds later on the first connection. - PrivateKeyUnlock.forget(pki.id); + final cacheKey = SshCredential.keyRefForId(pki.id); + PrivateKeyUnlock.forget(cacheKey); if (pwd.isNotEmpty && opened != key) { - PrivateKeyUnlock.remember(pki.id, opened); + PrivateKeyUnlock.remember(cacheKey, opened); } final originPki = this.pki; if (originPki != null) { diff --git a/lib/view/page/private_key/generate.dart b/lib/view/page/private_key/generate.dart index dc6d82fbd1..5bf464b23b 100644 --- a/lib/view/page/private_key/generate.dart +++ b/lib/view/page/private_key/generate.dart @@ -223,6 +223,12 @@ class _PrivateKeyGeneratePageState comment: comment, passphrase: _pwdController.text.isEmpty ? null : _pwdController.text, ); + // RSA searches for primes and takes seconds, which is long enough to + // leave. Saving after that would put a key in the list whose public half + // was never shown — the half that has to reach the server for it to be + // any use — so a page that has gone drops the key instead. Making + // another costs only the wait. + if (!mounted) return; await ref .read(privateKeyProvider.notifier) .add( diff --git a/lib/view/page/setting/seq/known_hosts.dart b/lib/view/page/setting/seq/known_hosts.dart index dbe5b99447..46eb61943c 100644 --- a/lib/view/page/setting/seq/known_hosts.dart +++ b/lib/view/page/setting/seq/known_hosts.dart @@ -74,11 +74,15 @@ class _KnownHostsPageState extends State { actions: Btnx.cancelRedOk, ); if (ok != true) return; + // Awaited: the forget is queued behind any acceptance still being written, + // so reloading without waiting would read the map as it was before the + // pruning and put the row straight back on screen. if (storageKey != null) { - forgetHostKey(storageKey); + await forgetHostKey(storageKey); } else { - forgetHostKeyFingerprints(serverId!); + await forgetHostKeyFingerprints(serverId!); } + if (!mounted) return; setState(_reload); } diff --git a/test/file_browser_test.dart b/test/file_browser_test.dart index e8b510a015..f585da17e2 100644 --- a/test/file_browser_test.dart +++ b/test/file_browser_test.dart @@ -84,7 +84,12 @@ class _MapBackend implements FileBackend { Future stat(String path) async => null; @override - Future write(String path, Stream> data, {int? size}) async {} + Future write( + String path, + Stream> data, { + int? size, + void Function(String staging)? onStaging, + }) async {} } FileEntry _dir(String name) => FileEntry(name: name, kind: FileKind.dir); diff --git a/test/host_key_forget_test.dart b/test/host_key_forget_test.dart index 1f07e7debb..90ac68e464 100644 --- a/test/host_key_forget_test.dart +++ b/test/host_key_forget_test.dart @@ -58,13 +58,27 @@ void main() { expect(grouped['abcdef'], hasLength(1)); }); - test('the split is on the first separator, not the last', () { - // A key type carries no `::`, but nothing stops an id from having one, - // and taking the last would move part of the id into the type. + test('the split is on the last separator, not the first', () { + // A key type is an SSH algorithm name and carries no `::`, while an id + // restored from a backup is whatever that file said — so the id is + // everything before the last one. Taking the first read this as server + // `a` with the type `b::ssh-rsa`, which is a server it does not belong + // to and a type that is not one. final grouped = groupHostKeysByServer({'a::b::ssh-rsa': 'ff'}); - expect(grouped.keys, ['a']); - expect(grouped['a']!.single.keyType, 'b::ssh-rsa'); + expect(grouped.keys, ['a::b']); + expect(grouped['a::b']!.single.keyType, 'ssh-rsa'); + }); + + test('and forgetting one id does not reach into another that extends it', + () { + // The same mistake from the other side: `startsWith('a::')` is true of + // `a::b::ssh-rsa`, so forgetting server `a` took the distinct server + // `a::b`'s key with it. + const nested = {'a::ssh-rsa': 'aa', 'a::b::ssh-rsa': 'bb'}; + + expect(withoutHostKeysFor(nested, 'a'), {'a::b::ssh-rsa': 'bb'}); + expect(withoutHostKeysFor(nested, 'a::b'), {'a::ssh-rsa': 'aa'}); }); test('an entry with no separator is kept, not dropped', () { diff --git a/test/identity_file_key_test.dart b/test/identity_file_key_test.dart index 505bb3c300..e6a5b4544a 100644 --- a/test/identity_file_key_test.dart +++ b/test/identity_file_key_test.dart @@ -32,6 +32,17 @@ void main() { TestWidgetsFlutterBinding.ensureInitialized(); group('SshCredential', () { + test('the id-only reference is the one a connection unlocks under', () { + // The key editor invalidates and warms the unlock cache from an id it + // holds, with no credential to ask. It spelled that `` while + // `genClient` unlocks under `keyRef`, so editing an encrypted key left + // the decrypted copy a connection was holding in place and the next + // connection authenticated with the key that had just been replaced. + const stored = SshCredential(ip: 'a', keyId: 'work'); + + expect(SshCredential.keyRefForId('work'), stored.keyRef); + }); + test('keyRef names whichever key is set, and tells them apart', () { const stored = SshCredential(ip: 'a', keyId: 'work'); const onDisk = SshCredential(ip: 'a', keyPath: '~/.ssh/id_ed25519'); diff --git a/test/proxy_command_sandbox_test.dart b/test/proxy_command_sandbox_test.dart index cacfdce16a..a92b988510 100644 --- a/test/proxy_command_sandbox_test.dart +++ b/test/proxy_command_sandbox_test.dart @@ -56,4 +56,50 @@ void main() { expect(explained, contains('~/.ssh')); }); }); + + group('what a placeholder may expand to', () { + test('the shapes a real host or user comes in are all allowed', () { + for (final value in [ + 'example.com', + '192.168.1.10', + '[2001:db8::1]', + 'fe80::1', + 'my-host_01.internal', + 'root', + 'ad\\user', + 'user@realm', + ]) { + expect( + ProxyCommandSocket.checkSubstitutable('host', value), + value, + reason: '$value names a host or a user and has to go through', + ); + } + }); + + test('anything a shell would read as syntax is refused', () { + // The expansion is textual and the result runs under `sh -c`, so each of + // these is a local command executing before authentication. The address + // is not necessarily this device's own: it arrives from an imported + // `~/.ssh/config`, a restored backup or a synced peer. + for (final value in [ + 'h; touch /tmp/pwned', + r'h$(id)', + 'h`id`', + 'h | sh', + 'h && id', + r'h$IFS', + 'h\nid', + "h'", + 'h"', + 'h%p', + ]) { + expect( + () => ProxyCommandSocket.checkSubstitutable('host', value), + throwsA(isA()), + reason: '$value must not reach /bin/sh', + ); + } + }); + }); }