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 574ca77d54..9ce40b9988 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) { @@ -51,6 +67,35 @@ 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 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 @@ -82,20 +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 > 1024 * 1024) { - throw SSHErr( - type: SSHErrType.noPrivateKey, - message: l10n.privateKeyFileUnreadable(expanded, 'File too large ($size bytes)'), - ); - } - } catch (_) { - // statSync failure is non-fatal; let read attempt decide. + return _decodeCapped(expanded, handle.readSync(_keyFileMaxSize + 1)); + } finally { + handle.closeSync(); } - return file.readAsStringSync(); } catch (e) { if (e is SSHErr) rethrow; throw SSHErr( @@ -119,15 +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 > 1024 * 1024) { - throw SSHErr( - type: SSHErrType.noPrivateKey, - message: l10n.privateKeyFileUnreadable(expanded, 'File too large (${stat.size} bytes)'), - ); + 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( @@ -248,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)) { @@ -372,11 +407,40 @@ 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), + ); + 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, - username: ssh.user, - // Must use [compute] here, instead of [Computer.shared.start] - identities: await compute(loadIdentity, privateKey), + // 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, + identities: identities, onPasswordRequest: ssh.pwd?.isNotEmpty == true ? () => ssh.pwd : null, onUserInfoRequest: onKeyboardInteractive == null ? null @@ -385,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); @@ -440,14 +554,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; @@ -455,15 +565,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. @@ -557,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, @@ -583,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, }; } @@ -716,7 +858,12 @@ Future _showHostKeyDialog( SelectableText('${libL10n.server}: ${info.spi.name}'), SelectableText('${libL10n.addr}: $hostLine'), SelectableText('${l10n.sshHostKeyType}: ${info.keyType}'), - SelectableText(l10n.sshHostKeyFingerprintMd5Hex(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( @@ -805,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) { @@ -896,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, @@ -931,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/core/utils/ssh_key_unlock.dart b/lib/core/utils/ssh_key_unlock.dart new file mode 100644 index 0000000000..a2a27662e2 --- /dev/null +++ b/lib/core/utils/ssh_key_unlock.dart @@ -0,0 +1,254 @@ +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 = >{}; + + /// 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. + 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; + + // 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; + + final attempt = _ask(pem, cacheKey: cacheKey, keyName: keyName); + _inFlight[cacheKey] = attempt; + try { + return await attempt; + } finally { + _inFlight.remove(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); + _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() { + // 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, + required String keyName, + }) async { + final prompt = promptOverrideForTesting ?? _showDialog; + + 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) { + _declined.add(cacheKey); + throw _locked(keyName); + } + if (passphrase.isEmpty) continue; + guesses++; + + 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]); + // 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 + // passphrase and belongs to the caller. + continue; + } + } + + _declined.add(cacheKey); + throw _locked(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 new file mode 100644 index 0000000000..04b16dad00 --- /dev/null +++ b/lib/core/utils/ssh_keygen.dart @@ -0,0 +1,281 @@ +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; +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', + }; +} + +/// 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 compute(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: 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] +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`. +/// +/// 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(); + 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))), + ), + ); +} + +/// 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; +} + +/// Remembers what each key said. +/// +/// 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) { + 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 { + 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/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/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/file_ref.dart b/lib/data/model/file/file_ref.dart index bfe16fc0be..23ec3a9f8a 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,35 @@ class SshTransferCreds { String? jumpPrivateKey; 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 + /// 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; + } } diff --git a/lib/data/model/file/transfer_status.dart b/lib/data/model/file/transfer_status.dart index 24ce9dc516..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); } @@ -130,6 +125,18 @@ 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(); + } + // 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 f15c82650f..664c4eb042 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: @@ -351,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); @@ -389,9 +393,10 @@ Future _download( }); } + Future? pending; try { resetIdleTimer(); - final downloadFuture = openedRemoteFile.downloadToRandomAccess( + pending = openedRemoteFile.downloadToRandomAccess( localFile, length: length, offset: offset, @@ -420,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(); @@ -470,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, @@ -495,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 { @@ -564,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/private_key_info.dart b/lib/data/model/server/private_key_info.dart index fd5f26bdd9..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. @@ -18,10 +20,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 +44,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, - ); + /// [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, + Object? comment = _unset, + }) => PrivateKeyInfo( + id: id ?? this.id, + name: name ?? this.name, + key: key ?? this.key, + comment: identical(comment, _unset) ? this.comment : comment as String?, + ); 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/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/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/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/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/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/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/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..642bf3bd95 100644 --- a/lib/data/store/schema.dart +++ b/lib/data/store/schema.dart @@ -63,7 +63,9 @@ 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; + /// 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 /// device's* storage, and restoring another device's number would make the diff --git a/lib/generated/l10n/l10n.dart b/lib/generated/l10n/l10n.dart index d0ff0b3e1c..fd2794c84d 100644 --- a/lib/generated/l10n/l10n.dart +++ b/lib/generated/l10n/l10n.dart @@ -1119,6 +1119,72 @@ 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 @sshKeyRecommended. + /// + /// In en, this message translates to: + /// **'Recommended'** + String get sshKeyRecommended; + + /// 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: @@ -1994,18 +2060,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 @sshHostKeyFingerprintMd5Base64. - /// - /// In en, this message translates to: - /// **'Fingerprint (MD5 base64): {fingerprint}'** - String sshHostKeyFingerprintMd5Base64(Object fingerprint); - - /// No description provided for @sshHostKeyFingerprintMd5Hex. - /// - /// In en, this message translates to: - /// **'Fingerprint (SHA256): {fingerprint}'** - String sshHostKeyFingerprintMd5Hex(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 c16bb166c0..222d1c64c8 100644 --- a/lib/generated/l10n/l10n_de.dart +++ b/lib/generated/l10n/l10n_de.dart @@ -577,6 +577,45 @@ 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 get sshKeyRecommended => 'Empfohlen'; + + @override + String sshKeyUnlockTip(String name) { + return 'Passphrase für den privaten Schlüssel [$name] eingeben.'; + } + @override String get unused => 'Ungenutzt'; @@ -1101,16 +1140,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 sshHostKeyFingerprintMd5Base64(Object fingerprint) { - return 'Fingerabdruck (MD5 Base64): $fingerprint'; - } - - @override - String sshHostKeyFingerprintMd5Hex(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 6eca41fb7e..b2c5f35a3d 100644 --- a/lib/generated/l10n/l10n_en.dart +++ b/lib/generated/l10n/l10n_en.dart @@ -568,6 +568,45 @@ 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 get sshKeyRecommended => 'Recommended'; + + @override + String sshKeyUnlockTip(String name) { + return 'Enter the passphrase for the private key [$name].'; + } + @override String get unused => 'Unused'; @@ -1086,16 +1125,6 @@ class AppLocalizationsEn extends AppLocalizations { return 'The SSH host key changed for $serverName. Only continue if you trust this server.'; } - @override - String sshHostKeyFingerprintMd5Base64(Object fingerprint) { - return 'Fingerprint (MD5 base64): $fingerprint'; - } - - @override - String sshHostKeyFingerprintMd5Hex(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 49194e5723..db06fa0254 100644 --- a/lib/generated/l10n/l10n_es.dart +++ b/lib/generated/l10n/l10n_es.dart @@ -581,6 +581,45 @@ 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 get sshKeyRecommended => 'Recomendado'; + + @override + String sshKeyUnlockTip(String name) { + return 'Introduce la frase de contraseña de la clave privada [$name].'; + } + @override String get unused => 'Sin usar'; @@ -1110,16 +1149,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 sshHostKeyFingerprintMd5Base64(Object fingerprint) { - return 'Huella (MD5 Base64): $fingerprint'; - } - - @override - String sshHostKeyFingerprintMd5Hex(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 21162d0ef5..01fb81427c 100644 --- a/lib/generated/l10n/l10n_fr.dart +++ b/lib/generated/l10n/l10n_fr.dart @@ -583,6 +583,45 @@ 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 get sshKeyRecommended => 'Recommandé'; + + @override + String sshKeyUnlockTip(String name) { + return 'Saisissez la phrase secrète de la clé privée [$name].'; + } + @override String get unused => 'Inutilisé'; @@ -1109,16 +1148,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 sshHostKeyFingerprintMd5Base64(Object fingerprint) { - return 'Empreinte (MD5 Base64) : $fingerprint'; - } - - @override - String sshHostKeyFingerprintMd5Hex(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 afef32adce..fc42e2292d 100644 --- a/lib/generated/l10n/l10n_id.dart +++ b/lib/generated/l10n/l10n_id.dart @@ -574,6 +574,45 @@ 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 get sshKeyRecommended => 'Disarankan'; + + @override + String sshKeyUnlockTip(String name) { + return 'Masukkan frasa sandi untuk kunci privat [$name].'; + } + @override String get unused => 'Tidak terpakai'; @@ -1094,16 +1133,6 @@ class AppLocalizationsId extends AppLocalizations { return 'Kunci host SSH untuk $serverName telah berubah. Lanjutkan hanya jika Anda mempercayai server ini.'; } - @override - String sshHostKeyFingerprintMd5Base64(Object fingerprint) { - return 'Sidik jari (MD5 Base64): $fingerprint'; - } - - @override - String sshHostKeyFingerprintMd5Hex(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 be731c7fe7..0b5b8ab451 100644 --- a/lib/generated/l10n/l10n_it.dart +++ b/lib/generated/l10n/l10n_it.dart @@ -580,6 +580,45 @@ 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 get sshKeyRecommended => 'Consigliato'; + + @override + String sshKeyUnlockTip(String name) { + return 'Inserisci la passphrase della chiave privata [$name].'; + } + @override String get unused => 'Inutilizzato'; @@ -1102,16 +1141,6 @@ class AppLocalizationsIt extends AppLocalizations { return 'La chiave host SSH è cambiata per $serverName. Continua solo se ti fidi di questo server.'; } - @override - String sshHostKeyFingerprintMd5Base64(Object fingerprint) { - return 'Impronta digitale (MD5 base64): $fingerprint'; - } - - @override - String sshHostKeyFingerprintMd5Hex(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 e59ee1f527..e3943e5cd5 100644 --- a/lib/generated/l10n/l10n_ja.dart +++ b/lib/generated/l10n/l10n_ja.dart @@ -543,6 +543,45 @@ 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 get sshKeyRecommended => '推奨'; + + @override + String sshKeyUnlockTip(String name) { + return '秘密鍵 [$name] のパスフレーズを入力してください。'; + } + @override String get unused => '未使用'; @@ -1034,16 +1073,6 @@ class AppLocalizationsJa extends AppLocalizations { return '$serverName の SSH ホスト鍵が変更されました。このサーバーを信頼できる場合のみ続行してください。'; } - @override - String sshHostKeyFingerprintMd5Base64(Object fingerprint) { - return 'フィンガープリント (MD5 Base64): $fingerprint'; - } - - @override - String sshHostKeyFingerprintMd5Hex(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 f8d3f08a6b..087d2abf61 100644 --- a/lib/generated/l10n/l10n_ko.dart +++ b/lib/generated/l10n/l10n_ko.dart @@ -542,6 +542,44 @@ 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 get sshKeyRecommended => '권장'; + + @override + String sshKeyUnlockTip(String name) { + return '개인 키 [$name]의 암호를 입력하세요.'; + } + @override String get unused => '미사용'; @@ -1035,16 +1073,6 @@ class AppLocalizationsKo extends AppLocalizations { return '$serverName의 SSH 호스트 키가 변경되었습니다. 이 서버를 신뢰하는 경우에만 계속 진행하세요.'; } - @override - String sshHostKeyFingerprintMd5Base64(Object fingerprint) { - return '지문 (MD5 base64): $fingerprint'; - } - - @override - String sshHostKeyFingerprintMd5Hex(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 35a2dce409..53f4847b6e 100644 --- a/lib/generated/l10n/l10n_nl.dart +++ b/lib/generated/l10n/l10n_nl.dart @@ -576,6 +576,45 @@ 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 get sshKeyRecommended => 'Aanbevolen'; + + @override + String sshKeyUnlockTip(String name) { + return 'Voer de wachtwoordzin voor de privésleutel [$name] in.'; + } + @override String get unused => 'Ongebruikt'; @@ -1098,16 +1137,6 @@ class AppLocalizationsNl extends AppLocalizations { return 'De SSH-hostsleutel voor $serverName is gewijzigd. Ga alleen verder als u deze server vertrouwt.'; } - @override - String sshHostKeyFingerprintMd5Base64(Object fingerprint) { - return 'Vingerafdruk (MD5 Base64): $fingerprint'; - } - - @override - String sshHostKeyFingerprintMd5Hex(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 6453c8be98..ec95ca6712 100644 --- a/lib/generated/l10n/l10n_pt.dart +++ b/lib/generated/l10n/l10n_pt.dart @@ -576,6 +576,45 @@ 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 get sshKeyRecommended => 'Recomendado'; + + @override + String sshKeyUnlockTip(String name) { + return 'Introduza a frase-passe da chave privada [$name].'; + } + @override String get unused => 'Não utilizado'; @@ -1095,16 +1134,6 @@ class AppLocalizationsPt extends AppLocalizations { return 'A chave de host SSH de $serverName foi alterada. Continue apenas se confiar neste servidor.'; } - @override - String sshHostKeyFingerprintMd5Base64(Object fingerprint) { - return 'Impressão digital (MD5 Base64): $fingerprint'; - } - - @override - String sshHostKeyFingerprintMd5Hex(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 d98beb494a..f578de8c6b 100644 --- a/lib/generated/l10n/l10n_ru.dart +++ b/lib/generated/l10n/l10n_ru.dart @@ -579,6 +579,45 @@ 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 get sshKeyRecommended => 'Рекомендуется'; + + @override + String sshKeyUnlockTip(String name) { + return 'Введите парольную фразу закрытого ключа [$name].'; + } + @override String get unused => 'Не используется'; @@ -1102,16 +1141,6 @@ class AppLocalizationsRu extends AppLocalizations { return 'SSH-ключ хоста для $serverName изменился. Продолжайте только если доверяете этому серверу.'; } - @override - String sshHostKeyFingerprintMd5Base64(Object fingerprint) { - return 'Отпечаток (MD5 Base64): $fingerprint'; - } - - @override - String sshHostKeyFingerprintMd5Hex(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 8e56d81c37..636ade6ca7 100644 --- a/lib/generated/l10n/l10n_tr.dart +++ b/lib/generated/l10n/l10n_tr.dart @@ -575,6 +575,45 @@ 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 get sshKeyRecommended => 'Önerilen'; + + @override + String sshKeyUnlockTip(String name) { + return '[$name] özel anahtarının parolasını girin.'; + } + @override String get unused => 'Kullanılmıyor'; @@ -1095,16 +1134,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 sshHostKeyFingerprintMd5Base64(Object fingerprint) { - return 'Parmak izi (MD5 Base64): $fingerprint'; - } - - @override - String sshHostKeyFingerprintMd5Hex(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 89f7afb69f..08e8e4bf6d 100644 --- a/lib/generated/l10n/l10n_uk.dart +++ b/lib/generated/l10n/l10n_uk.dart @@ -578,6 +578,45 @@ 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 get sshKeyRecommended => 'Рекомендовано'; + + @override + String sshKeyUnlockTip(String name) { + return 'Введіть парольну фразу закритого ключа [$name].'; + } + @override String get unused => 'Не використовується'; @@ -1099,16 +1138,6 @@ class AppLocalizationsUk extends AppLocalizations { return 'SSH-ключ хоста для $serverName змінено. Продовжуйте лише якщо довіряєте цьому серверу.'; } - @override - String sshHostKeyFingerprintMd5Base64(Object fingerprint) { - return 'Відбиток (MD5 Base64): $fingerprint'; - } - - @override - String sshHostKeyFingerprintMd5Hex(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 fec08867a5..f75a458480 100644 --- a/lib/generated/l10n/l10n_zh.dart +++ b/lib/generated/l10n/l10n_zh.dart @@ -532,6 +532,43 @@ 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 get sshKeyRecommended => '推荐'; + + @override + String sshKeyUnlockTip(String name) { + return '请输入私钥 [$name] 的口令。'; + } + @override String get unused => '未使用'; @@ -1016,16 +1053,6 @@ class AppLocalizationsZh extends AppLocalizations { return '服务器 $serverName 的 SSH 主机密钥已更改,仅在信任该服务器时继续。'; } - @override - String sshHostKeyFingerprintMd5Base64(Object fingerprint) { - return '指纹(MD5 Base64):$fingerprint'; - } - - @override - String sshHostKeyFingerprintMd5Hex(Object fingerprint) { - return '指纹(SHA256):$fingerprint'; - } - @override String get sshHostKeyType => 'SSH 主机密钥类型'; @@ -1953,6 +1980,43 @@ 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 get sshKeyRecommended => '推薦'; + + @override + String sshKeyUnlockTip(String name) { + return '請輸入私密金鑰 [$name] 的通行密碼。'; + } + @override String get unused => '未使用'; @@ -2438,16 +2502,6 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { return '伺服器 $serverName 的 SSH 主機金鑰已變更,僅在信任該伺服器時繼續。'; } - @override - String sshHostKeyFingerprintMd5Base64(Object fingerprint) { - return '指紋(MD5 Base64):$fingerprint'; - } - - @override - String sshHostKeyFingerprintMd5Hex(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 af459098bf..06bfd9349a 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -99,6 +99,17 @@ "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.", + "sshKeyRecommended": "Empfohlen", + "sshKeyUnlockTip": "Passphrase für den privaten Schlüssel [{name}] eingeben.", "unused": "Ungenutzt", "dangling": "Verwaist", "pruneUnusedImages": "Ungenutzte Images bereinigen", @@ -230,8 +241,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.", - "sshHostKeyFingerprintMd5Base64": "Fingerabdruck (MD5 Base64): {fingerprint}", - "sshHostKeyFingerprintMd5Hex": "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 9a3ec19da6..ec331689d1 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -175,6 +175,31 @@ "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.", + "sshKeyRecommended": "Recommended", + "sshKeyUnlockTip": "Enter the passphrase for the private key [{name}].", + "@sshKeyUnlockTip": { + "placeholders": { + "name": { + "type": "String" + } + } + }, "unused": "Unused", "dangling": "Dangling", "pruneUnusedImages": "Prune unused images", @@ -390,8 +415,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.", - "sshHostKeyFingerprintMd5Base64": "Fingerprint (MD5 base64): {fingerprint}", - "sshHostKeyFingerprintMd5Hex": "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 efa219d719..a3c5fd539c 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -99,6 +99,17 @@ "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.", + "sshKeyRecommended": "Recomendado", + "sshKeyUnlockTip": "Introduce la frase de contraseña de la clave privada [{name}].", "unused": "Sin usar", "dangling": "Colgante", "pruneUnusedImages": "Limpiar imágenes sin usar", @@ -230,8 +241,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.", - "sshHostKeyFingerprintMd5Base64": "Huella (MD5 Base64): {fingerprint}", - "sshHostKeyFingerprintMd5Hex": "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 e6d0e80926..3945e5eeb5 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -99,6 +99,17 @@ "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.", + "sshKeyRecommended": "Recommandé", + "sshKeyUnlockTip": "Saisissez la phrase secrète de la clé privée [{name}].", "unused": "Inutilisé", "dangling": "Fantôme", "pruneUnusedImages": "Nettoyer les images inutilisées", @@ -230,8 +241,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.", - "sshHostKeyFingerprintMd5Base64": "Empreinte (MD5 Base64) : {fingerprint}", - "sshHostKeyFingerprintMd5Hex": "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 edaa335269..179b31d677 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -99,6 +99,17 @@ "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.", + "sshKeyRecommended": "Disarankan", + "sshKeyUnlockTip": "Masukkan frasa sandi untuk kunci privat [{name}].", "unused": "Tidak terpakai", "dangling": "Menggantung", "pruneUnusedImages": "Bersihkan gambar tidak terpakai", @@ -230,8 +241,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.", - "sshHostKeyFingerprintMd5Base64": "Sidik jari (MD5 Base64): {fingerprint}", - "sshHostKeyFingerprintMd5Hex": "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 313f6f98dd..71e85434e8 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -99,6 +99,17 @@ "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.", + "sshKeyRecommended": "Consigliato", + "sshKeyUnlockTip": "Inserisci la passphrase della chiave privata [{name}].", "unused": "Inutilizzato", "dangling": "Orfana", "pruneUnusedImages": "Rimuovi immagini inutilizzate", @@ -231,8 +242,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.", - "sshHostKeyFingerprintMd5Base64": "Impronta digitale (MD5 base64): {fingerprint}", - "sshHostKeyFingerprintMd5Hex": "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 af4d0085f6..6c54f81d49 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -99,6 +99,17 @@ "macDmgTip": "ローカルターミナルと snippet のローカル実行(DMG 版)", "macDmgTitle": "DMG 版", "showHiddenFiles": "隠しファイルを表示", + "sshKeyAlgorithm": "アルゴリズム", + "sshKeyComment": "コメント", + "sshKeyGenerate": "鍵ペアを生成", + "sshKeyGenerating": "生成中…", + "sshKeyLockedFmt": "秘密鍵 [{name}] のロックが解除されていません。", + "sshKeyPassphraseTip": "任意。パスフレーズを設定すると秘密鍵は暗号化して保存され、接続でこの鍵を最初に使うときに入力を求められます。", + "sshKeyPassphraseWrong": "パスフレーズが違います。", + "sshKeyPublicKey": "公開鍵", + "sshKeyPublicKeyTip": "この行をサーバーの ~/.ssh/authorized_keys に追記してください。", + "sshKeyRecommended": "推奨", + "sshKeyUnlockTip": "秘密鍵 [{name}] のパスフレーズを入力してください。", "unused": "未使用", "dangling": "未タグ", "pruneUnusedImages": "未使用イメージをクリーンアップ", @@ -230,8 +241,6 @@ "sshConfigImportTip": "初回サーバー作成時に~/.ssh/configの読み取りを促す", "sshConfigImported": "SSH設定から{count}個のサーバーをインポートしました", "sshHostKeyChangedDesc": "{serverName} の SSH ホスト鍵が変更されました。このサーバーを信頼できる場合のみ続行してください。", - "sshHostKeyFingerprintMd5Base64": "フィンガープリント (MD5 Base64): {fingerprint}", - "sshHostKeyFingerprintMd5Hex": "フィンガープリント (SHA256): {fingerprint}", "sshHostKeyType": "SSH ホストキーの種類", "sshKnownHostKeys": "既知のホスト", "sshKnownHostKeysTip": "このアプリが受け入れたホスト鍵", diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 4504fd0de0..7f01585536 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -112,6 +112,17 @@ "macDmgTip": "로컬 터미널과 로컬 snippet 실행 (DMG 버전)", "macDmgTitle": "DMG 버전", "showHiddenFiles": "숨김 파일 표시", + "sshKeyAlgorithm": "알고리즘", + "sshKeyComment": "설명", + "sshKeyGenerate": "키 쌍 생성", + "sshKeyGenerating": "생성 중…", + "sshKeyLockedFmt": "개인 키 [{name}]의 잠금이 해제되지 않았습니다.", + "sshKeyPassphraseTip": "선택 사항. 암호를 설정하면 개인 키가 암호화되어 저장되며, 연결에서 이 키를 처음 사용할 때 입력을 요구합니다.", + "sshKeyPassphraseWrong": "암호가 올바르지 않습니다.", + "sshKeyPublicKey": "공개 키", + "sshKeyPublicKeyTip": "이 줄을 서버의 ~/.ssh/authorized_keys에 추가하세요.", + "sshKeyRecommended": "권장", + "sshKeyUnlockTip": "개인 키 [{name}]의 암호를 입력하세요.", "unused": "미사용", "dangling": "댕글링", "pruneUnusedImages": "미사용 이미지 정리", @@ -276,8 +287,6 @@ "sshConfigImportTip": "첫 서버 생성 시 ~/.ssh/config 읽기 안내", "sshConfigImported": "SSH 설정에서 서버 {count}개를 가져왔습니다", "sshHostKeyChangedDesc": "{serverName}의 SSH 호스트 키가 변경되었습니다. 이 서버를 신뢰하는 경우에만 계속 진행하세요.", - "sshHostKeyFingerprintMd5Base64": "지문 (MD5 base64): {fingerprint}", - "sshHostKeyFingerprintMd5Hex": "지문 (SHA256): {fingerprint}", "sshHostKeyType": "SSH 호스트 키 유형", "sshKnownHostKeys": "알려진 호스트", "sshKnownHostKeysTip": "이 앱이 수락한 호스트 키", diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb index a71343e3f7..4c86ec90f3 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -99,6 +99,17 @@ "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.", + "sshKeyRecommended": "Aanbevolen", + "sshKeyUnlockTip": "Voer de wachtwoordzin voor de privésleutel [{name}] in.", "unused": "Ongebruikt", "dangling": "Bungelend", "pruneUnusedImages": "Ongebruikte images opschonen", @@ -230,8 +241,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.", - "sshHostKeyFingerprintMd5Base64": "Vingerafdruk (MD5 Base64): {fingerprint}", - "sshHostKeyFingerprintMd5Hex": "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 571231c8c7..86ec1fa24d 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -99,6 +99,17 @@ "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.", + "sshKeyRecommended": "Recomendado", + "sshKeyUnlockTip": "Introduza a frase-passe da chave privada [{name}].", "unused": "Não utilizado", "dangling": "Sem referência", "pruneUnusedImages": "Limpar imagens não utilizadas", @@ -230,8 +241,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.", - "sshHostKeyFingerprintMd5Base64": "Impressão digital (MD5 Base64): {fingerprint}", - "sshHostKeyFingerprintMd5Hex": "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 5414e9d47e..bbce4b6dd6 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -99,6 +99,17 @@ "macDmgTip": "Локальный терминал и запуск сниппетов локально (версия DMG)", "macDmgTitle": "Сборка DMG", "showHiddenFiles": "Показывать скрытые файлы", + "sshKeyAlgorithm": "Алгоритм", + "sshKeyComment": "Комментарий", + "sshKeyGenerate": "Создать пару ключей", + "sshKeyGenerating": "Создание…", + "sshKeyLockedFmt": "Закрытый ключ [{name}] не разблокирован.", + "sshKeyPassphraseTip": "Необязательно. Ключ с парольной фразой хранится в зашифрованном виде, и она запрашивается при первом использовании ключа.", + "sshKeyPassphraseWrong": "Неверная парольная фраза.", + "sshKeyPublicKey": "Открытый ключ", + "sshKeyPublicKeyTip": "Добавьте эту строку в ~/.ssh/authorized_keys на сервере.", + "sshKeyRecommended": "Рекомендуется", + "sshKeyUnlockTip": "Введите парольную фразу закрытого ключа [{name}].", "unused": "Не используется", "dangling": "Висячий", "pruneUnusedImages": "Очистить неиспользуемые образы", @@ -230,8 +241,6 @@ "sshConfigImportTip": "Предложение прочитать ~/.ssh/config при создании первого сервера", "sshConfigImported": "Импортировано {count} серверов из SSH-конфигурации", "sshHostKeyChangedDesc": "SSH-ключ хоста для {serverName} изменился. Продолжайте только если доверяете этому серверу.", - "sshHostKeyFingerprintMd5Base64": "Отпечаток (MD5 Base64): {fingerprint}", - "sshHostKeyFingerprintMd5Hex": "Отпечаток (SHA256): {fingerprint}", "sshHostKeyType": "Тип ключа хоста SSH", "sshKnownHostKeys": "Известные хосты", "sshKnownHostKeysTip": "Ключи хостов, принятые этим приложением", diff --git a/lib/l10n/app_tr.arb b/lib/l10n/app_tr.arb index 2dfb7bac6b..93dee1b588 100644 --- a/lib/l10n/app_tr.arb +++ b/lib/l10n/app_tr.arb @@ -99,6 +99,17 @@ "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.", + "sshKeyRecommended": "Önerilen", + "sshKeyUnlockTip": "[{name}] özel anahtarının parolasını girin.", "unused": "Kullanılmıyor", "dangling": "Askıda", "pruneUnusedImages": "Kullanılmayan görüntüleri temizle", @@ -230,8 +241,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.", - "sshHostKeyFingerprintMd5Base64": "Parmak izi (MD5 Base64): {fingerprint}", - "sshHostKeyFingerprintMd5Hex": "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 251641fe9d..238819d03d 100644 --- a/lib/l10n/app_uk.arb +++ b/lib/l10n/app_uk.arb @@ -99,6 +99,17 @@ "macDmgTip": "Локальний термінал і запуск сніпетів локально (версія DMG)", "macDmgTitle": "Збірка DMG", "showHiddenFiles": "Показувати приховані файли", + "sshKeyAlgorithm": "Алгоритм", + "sshKeyComment": "Коментар", + "sshKeyGenerate": "Створити пару ключів", + "sshKeyGenerating": "Створення…", + "sshKeyLockedFmt": "Закритий ключ [{name}] не розблоковано.", + "sshKeyPassphraseTip": "Необов'язково. Ключ із парольною фразою зберігається зашифрованим, і її запитують під час першого використання ключа.", + "sshKeyPassphraseWrong": "Неправильна парольна фраза.", + "sshKeyPublicKey": "Відкритий ключ", + "sshKeyPublicKeyTip": "Додайте цей рядок до ~/.ssh/authorized_keys на сервері.", + "sshKeyRecommended": "Рекомендовано", + "sshKeyUnlockTip": "Введіть парольну фразу закритого ключа [{name}].", "unused": "Не використовується", "dangling": "Висячий", "pruneUnusedImages": "Очистити невикористані образи", @@ -231,8 +242,6 @@ "sshConfigImportTip": "Пропозиція прочитати ~/.ssh/config при створенні першого сервера", "sshConfigImported": "Імпортовано {count} серверів з SSH-конфігурації", "sshHostKeyChangedDesc": "SSH-ключ хоста для {serverName} змінено. Продовжуйте лише якщо довіряєте цьому серверу.", - "sshHostKeyFingerprintMd5Base64": "Відбиток (MD5 Base64): {fingerprint}", - "sshHostKeyFingerprintMd5Hex": "Відбиток (SHA256): {fingerprint}", "sshHostKeyType": "Тип ключа хоста SSH", "sshKnownHostKeys": "Відомі хости", "sshKnownHostKeysTip": "Ключі хостів, які прийняв цей застосунок", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 811f30f3da..7c17af0e44 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -172,6 +172,17 @@ "macDmgTip": "本机终端、在本机运行 snippet(DMG 版)", "macDmgTitle": "DMG 版", "showHiddenFiles": "显示隐藏文件", + "sshKeyAlgorithm": "算法", + "sshKeyComment": "备注", + "sshKeyGenerate": "生成密钥对", + "sshKeyGenerating": "生成中…", + "sshKeyLockedFmt": "私钥 [{name}] 未解锁。", + "sshKeyPassphraseTip": "可选。设置口令后,私钥将加密存储,每次连接首次使用该密钥时会要求输入。", + "sshKeyPassphraseWrong": "口令错误。", + "sshKeyPublicKey": "公钥", + "sshKeyPublicKeyTip": "将此行追加到服务器的 ~/.ssh/authorized_keys。", + "sshKeyRecommended": "推荐", + "sshKeyUnlockTip": "请输入私钥 [{name}] 的口令。", "unused": "未使用", "dangling": "悬空", "pruneUnusedImages": "清理未使用镜像", @@ -332,8 +343,6 @@ "sshConfigImportTip": "首次创建服务器时提示读取 ~/.ssh/config", "sshConfigImported": "从 SSH 配置导入了 {count} 个服务器", "sshHostKeyChangedDesc": "服务器 {serverName} 的 SSH 主机密钥已更改,仅在信任该服务器时继续。", - "sshHostKeyFingerprintMd5Base64": "指纹(MD5 Base64):{fingerprint}", - "sshHostKeyFingerprintMd5Hex": "指纹(SHA256):{fingerprint}", "sshHostKeyType": "SSH 主机密钥类型", "sshKnownHostKeys": "已信任的主机", "sshKnownHostKeysTip": "本 app 已接受的主机密钥", diff --git a/lib/l10n/app_zh_tw.arb b/lib/l10n/app_zh_tw.arb index d257637a7a..77f6a3035d 100644 --- a/lib/l10n/app_zh_tw.arb +++ b/lib/l10n/app_zh_tw.arb @@ -172,6 +172,17 @@ "macDmgTip": "本機終端、在本機執行 snippet(DMG 版)", "macDmgTitle": "DMG 版", "showHiddenFiles": "顯示隱藏檔案", + "sshKeyAlgorithm": "演算法", + "sshKeyComment": "備註", + "sshKeyGenerate": "產生金鑰對", + "sshKeyGenerating": "產生中…", + "sshKeyLockedFmt": "私密金鑰 [{name}] 未解鎖。", + "sshKeyPassphraseTip": "選填。設定通行密碼後,私密金鑰將加密儲存,每次連線首次使用該金鑰時會要求輸入。", + "sshKeyPassphraseWrong": "通行密碼錯誤。", + "sshKeyPublicKey": "公開金鑰", + "sshKeyPublicKeyTip": "將此行附加到伺服器的 ~/.ssh/authorized_keys。", + "sshKeyRecommended": "推薦", + "sshKeyUnlockTip": "請輸入私密金鑰 [{name}] 的通行密碼。", "unused": "未使用", "dangling": "懸空", "pruneUnusedImages": "清理未使用映像檔", @@ -331,8 +342,6 @@ "sshConfigImportTip": "在建立第一個伺服器時提示讀取 ~/.ssh/config", "sshConfigImported": "已從SSH設定匯入{count}個伺服器", "sshHostKeyChangedDesc": "伺服器 {serverName} 的 SSH 主機金鑰已變更,僅在信任該伺服器時繼續。", - "sshHostKeyFingerprintMd5Base64": "指紋(MD5 Base64):{fingerprint}", - "sshHostKeyFingerprintMd5Hex": "指紋(SHA256):{fingerprint}", "sshHostKeyType": "SSH 主機金鑰類型", "sshKnownHostKeys": "已信任的主機", "sshKnownHostKeysTip": "本 app 已接受的主機金鑰", diff --git a/lib/view/page/private_key/edit.dart b/lib/view/page/private_key/edit.dart index f10198ba45..164ffe4e99 100644 --- a/lib/view/page/private_key/edit.dart +++ b/lib/view/page/private_key/edit.dart @@ -1,13 +1,17 @@ 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'; 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/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'; @@ -39,6 +43,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(); @@ -57,6 +62,7 @@ class _PrivateKeyEditPageState extends ConsumerState { _nameController.dispose(); _keyController.dispose(); _pwdController.dispose(); + _commentController.dispose(); _nameNode.dispose(); _keyNode.dispose(); _pwdNode.dispose(); @@ -70,6 +76,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; @@ -100,6 +111,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 +131,7 @@ class _PrivateKeyEditPageState extends ConsumerState { actions: Btn.ok(red: true).toList, ); if (confirmed != true || !context.mounted) return; + PrivateKeyUnlock.forget(SshCredential.keyRefForId(pki.id)); await _notifier.delete(pki); context.pop(); }, @@ -125,6 +142,58 @@ 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: SshCredential.keyRefForId(pki.id), + keyName: pki.name, + ); + line = publicKeyLine( + SSHKeyPair.fromPem(opened).first, + // 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()); + 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'); } @@ -254,6 +323,13 @@ 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(), ), SizedBox(height: MediaQuery.of(context).size.height * 0.1), @@ -285,15 +361,43 @@ 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. + // + // 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. + // + // `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. + final comment = _commentController.text.trim(); final pki = PrivateKeyInfo( id: this.pki?.id ?? ShortId.generate(), name: name, - key: decrypted, + 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 — and then the + // passphrase just verified is put back, rather than asking for it again + // seconds later on the first connection. + final cacheKey = SshCredential.keyRefForId(pki.id); + PrivateKeyUnlock.forget(cacheKey); + if (pwd.isNotEmpty && opened != key) { + PrivateKeyUnlock.remember(cacheKey, 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 new file mode 100644 index 0000000000..5bf464b23b --- /dev/null +++ b/lib/view/page/private_key/generate.dart @@ -0,0 +1,261 @@ +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(); + + /// Closes the algorithm list once a choice has been made. + final _algorithmTile = ExpansibleController(); + + 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(); + _algorithmTile.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, + ), + // 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, + 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), + ), + // 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), + ), + ], + ); + } + + 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. 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 => l10n.sshKeyRecommended, + 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 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, + 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( + PrivateKeyInfo( + 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; + 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..97dbb21107 100644 --- a/lib/view/page/private_key/list.dart +++ b/lib/view/page/private_key/list.dart @@ -5,10 +5,12 @@ 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'; 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,8 +31,8 @@ class _PrivateKeyListState extends ConsumerState return Scaffold( body: SafeArea(child: _buildBody()), floatingActionButton: FloatingActionButton( + onPressed: _onTapAdd, child: const Icon(Icons.add), - onPressed: () => PrivateKeyEditPage.route.go(context), ), ); } @@ -48,9 +50,30 @@ 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 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, + ?(item.comment ?? 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), @@ -66,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/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/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/dartssh2 b/packages/dartssh2 index afefe21492..ebbe517e3f 160000 --- a/packages/dartssh2 +++ b/packages/dartssh2 @@ -1 +1 @@ -Subproject commit afefe21492df4dd5ee92885e95a38e887ac95a1d +Subproject commit ebbe517e3f14598f87e64f9944035e98b37998b8 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 diff --git a/pubspec.lock b/pubspec.lock index 183d791b96..800ea3fd2e 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1297,7 +1297,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 6ceb576a5b..4278de318b 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: ^2.1.0 equatable: ^2.1.0 easy_isolate: ^1.3.0 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/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/identity_file_key_test.dart b/test/identity_file_key_test.dart index aceebbe338..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'); @@ -230,6 +241,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', () { diff --git a/test/m007_private_key_comment_test.dart b/test/m007_private_key_comment_test.dart new file mode 100644 index 0000000000..2579b62efa --- /dev/null +++ b/test/m007_private_key_comment_test.dart @@ -0,0 +1,92 @@ +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. + /// + /// 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 (' + '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;', + ); + } + + 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 (updated_at, rev, id, name, key) ' + "VALUES (1700000000, 3, '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-----'); + // 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. + 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 (' + '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 (updated_at, rev, id, name, key, comment) ' + "VALUES (0, 0, '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/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', + ); + } + }); + }); } 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, ), ), diff --git a/test/ssh_key_unlock_test.dart b/test/ssh_key_unlock_test.dart new file mode 100644 index 0000000000..3968509f6a --- /dev/null +++ b/test/ssh_key_unlock_test.dart @@ -0,0 +1,258 @@ +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'; +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); + }); + + 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('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. + expect(PrivateKeyUnlock.isLocked('not a pem at all'), isFalse); + expect(PrivateKeyUnlock.isLocked(''), isFalse); + }); +} diff --git a/test/ssh_keygen_page_test.dart b/test/ssh_keygen_page_test.dart new file mode 100644 index 0000000000..3ec4534589 --- /dev/null +++ b/test/ssh_keygen_page_test.dart @@ -0,0 +1,120 @@ +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/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'; +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); + }); +} diff --git a/test/ssh_keygen_test.dart b/test/ssh_keygen_test.dart new file mode 100644 index 0000000000..ecac6e6f04 --- /dev/null +++ b/test/ssh_keygen_test.dart @@ -0,0 +1,234 @@ +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)), + ); + }); + + 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() { + 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; +}