DartSSH 2
SSH and SFTP client written in pure Dart, aiming to be feature-rich as well as easy to use.
dartssh2 is now a complete rewrite of dartssh.
โจ Features
- Pure Dart: Working with both Dart VM and Flutter.
- SSH Session: Executing commands, spawning shells, setting environment variables, pseudo terminals, etc.
- Authentication: Supports password, in-memory private keys (
SSHKeyPair), external asynchronous identities (SSHIdentityfor Secure Enclave, YubiKey/FIDO2, smart cards, OS agents), RFC 4252 ยง7.8 public-key probing, RFC 4252 ยง9 hostbased authentication, and keyboard-interactive authentication. - Forwarding: Supports local forwarding, remote forwarding, and dynamic forwarding (SOCKS5 CONNECT).
- SFTP: Supports all operations defined in SFTPv3 protocol including upload, download, list, link, remove, rename, etc.
- Non-blocking Key Exchange: Automatically offloads heavy key exchange calculations (X25519, NIST Curves, DH) to background isolates on supported VM platforms, preventing the main UI thread from freezing during connection.
๐งฌ Built with dartssh2
| ServerBox | NoPorts | DartShell | Naviterm | TealKit |
|
|
|
|
|
Feel free to add your own app here by opening a pull request.
๐งช Try
# Install the `dartssh` command.
dart pub global activate dartssh2_cli
# Then use `dartssh` as regular `ssh` command.
dartssh user@example.com
# Example: execute a command on remote host.
dartssh user@example.com ls -al
# Example: connect to a non-standard port.
dartssh user@example.com:<port>
# Transfer files via SFTP.
dartsftp user@example.com
If the
dartsshcommand can't be found after installation, you might need to set up your path.
๐ Quick start
Connect to a remote host
void main() async {
final client = SSHClient(
await SSHSocket.connect('localhost', 22),
username: '<username>',
onPasswordRequest: () => '<password>',
);
}
Note:
SSHSocket.connect()uses native TCP sockets (dart:io) and is not available on Flutter Web / Dart Web. See Web support below for browser-compatible transport options.
SSHSocketis an interface and it's possible to implement your ownSSHSocketif you want to use a different underlying transport rather than standard TCP socket. For example WebSocket or Unix domain socket.
Verify the host key
The example above authenticates the server to nobody: dartssh2 always checks that the host key signature is valid, which proves the server owns the private key it presented, but it cannot know whether that key is the one you expected. Without that second check, an attacker who can intercept the connection can present their own key and read the whole session.
Pass onVerifyHostKey to decide whether the key is trusted. It receives the
key type and the OpenSSH-style SHA256 fingerprint, and returning false
aborts the connection:
final client = SSHClient(
await SSHSocket.connect('localhost', 22),
username: '<username>',
onPasswordRequest: () => '<password>',
onVerifyHostKey: (type, fingerprint) {
final expected = knownHosts['localhost']; // your own storage
if (expected == null) {
// First connection: ask the user, then remember the answer.
return promptUserToTrust(type, utf8.decode(fingerprint));
}
return utf8.decode(fingerprint) == expected;
},
);
The handler may return a Future<bool>, so it can await a UI prompt or a
lookup in persistent storage. When onVerifyHostKey is omitted, every host
key is accepted, which is only appropriate for tests and for networks you
already trust end to end.
disableHostkeyVerification: trueis a separate and stronger opt-out: it skips the signature check as well. Do not use it outside of local testing.
Web support
Direct native TCP sockets are not available in browsers, so this will fail on Flutter Web / Dart Web:
await SSHSocket.connect('host', 22);
For web apps, use a custom SSHSocket transport over a browser-supported
channel (for example, a WebSocket tunnel/proxy to your SSH endpoint).
Customize client SSH identification
If your jump host or SSH gateway restricts client versions, you can customize the
software version part of the identification string (SSH-2.0-<ident>):
void main() async {
final client = SSHClient(
await SSHSocket.connect('localhost', 22),
username: '<username>',
onPasswordRequest: () => '<password>',
ident: 'MyClient_1.0',
);
}
ident defaults to DartSSH_2.0.
Configure handshake and authentication timeouts
You can specify optional timeouts for the transport handshake and user authentication:
void main() async {
final client = SSHClient(
await SSHSocket.connect('localhost', 22),
username: '<username>',
onPasswordRequest: () => '<password>',
handshakeTimeout: const Duration(seconds: 15),
authTimeout: const Duration(seconds: 15),
);
}
By default, these parameters are null (no timeout is enforced). Without these timeouts, the connection or authentication process could hang indefinitely if the remote server becomes unresponsive.
Spawn a shell on remote host
void main() async {
final shell = await client.shell();
// Attach local terminal streams only when a terminal is available.
// GUI apps on Windows may not have stdin/stdout/stderr attached.
final hasTerminal = stdin.hasTerminal && stdout.hasTerminal && stderr.hasTerminal;
if (hasTerminal) {
stdout.addStream(shell.stdout); // listening for stdout
stderr.addStream(shell.stderr); // listening for stderr
stdin.cast<Uint8List>().listen(shell.write); // writing to stdin
}
await shell.done; // wait for shell to exit
await client.close();
}
Note: The stdin/stdout bridging above is for CLI apps. If your app is launched without a terminal (for example, double-clicking a Windows
.exe), skip the local stdio wiring and use your own UI/input pipeline.
Execute a command on remote host
void main() async {
final uptime = await client.run('uptime');
print(utf8.decode(uptime));
}
Ignoring stderr:
void main() async {
final uptime = await client.run('uptime', stderr: false);
print(utf8.decode(uptime));
}
client.run()is a convenience method that returns combined output bytes. Useclient.runWithResult()when you need separatestdout/stderrstreams and command exit metadata (exitCode/exitSignal).
To also access command exit metadata:
void main() async {
final result = await client.runWithResult('echo hello');
print('exitCode: ${result.exitCode}');
print('stdout: ${utf8.decode(result.stdout)}');
print('stderr: ${utf8.decode(result.stderr)}');
}
End-to-end flow example
Use example/run_flows.dart to test the main execution flows in one run:
run()runWithResult()execute()- optional
shell()via--shell
Run it with environment variables:
SSH_HOST=test.rebex.net SSH_PORT=22 SSH_USERNAME=demo SSH_PASSWORD=password dart run example/run_flows.dart
Run shell flow too:
SSH_HOST=test.rebex.net SSH_PORT=22 SSH_USERNAME=demo SSH_PASSWORD=password dart run example/run_flows.dart --shell
On Windows PowerShell:
$env:SSH_HOST = 'test.rebex.net'
$env:SSH_PORT = '22'
$env:SSH_USERNAME = 'demo'
$env:SSH_PASSWORD = 'password'
dart run example/run_flows.dart --shell
Start a process on remote host
void main() async {
final session = await client.execute('cat > file.txt');
await session.stdin.addStream(File('local_file.txt').openRead().cast());
await session.stdin.close(); // Close the sink to send EOF to the remote process.
await session.done; // Wait for session to exit to ensure all data is flushed to the remote process.
print(session.exitCode); // You can get the exit code after the session is done
}
session.write()is a shorthand forsession.stdin.add(). It's recommended to usesession.stdin.addStream()instead ofsession.write()when you want to stream large amount of data to the remote process.
Killing a remote process by sending signal
void main() async {
session.kill(SSHSignal.KILL);
await session.done;
print('exitCode: ${session.exitCode}'); // -> exitCode: null
print('signal: ${session.exitSignal?.signalName}'); // -> signal: KILL
}
Processes killed by signals do not have an exit code, instead they have an exit signal property.
Waiting for exit status with a timeout
Alternatively, you can wait for the remote process to report its exit status or exit signal with an optional timeout using session.waitForExit():
void main() async {
final session = await client.execute('sleep 5');
// Wait for the exit status to be reported (or up to 10 seconds).
final exitCode = await session.waitForExit(timeout: Duration(seconds: 10));
if (exitCode != null) {
print('Process exited with code: $exitCode');
} else {
print('Process timed out or was terminated by a signal');
}
}
Forward connections on local port 8080 to the server
void main() async {
final serverSocket = await ServerSocket.bind('localhost', 8080);
await for (final socket in serverSocket) {
final forward = await client.forwardLocal('httpbin.org', 80);
forward.stream.cast<List<int>>().pipe(socket);
socket.pipe(forward.sink);
}
}
Forward connections to port 2222 on the server to local port 22
void main() async {
final forward = await client.forwardRemote(port: 2222);
if (forward == null) {
print('Failed to forward remote port');
return;
}
await for (final connection in forward.connections) {
final socket = await Socket.connect('localhost', 22);
connection.stream.cast<List<int>>().pipe(socket);
socket.pipe(connection.sink);
}
}
Start a local SOCKS5 proxy through SSH (ssh -D style)
void main() async {
final dynamicForward = await client.forwardDynamic(
bindHost: '127.0.0.1',
bindPort: 1080,
options: const SSHDynamicForwardOptions(
handshakeTimeout: Duration(seconds: 10),
connectTimeout: Duration(seconds: 15),
maxConnections: 128,
),
filter: (host, port) {
// Optional allow/deny policy.
return true;
},
);
print('SOCKS5 proxy at ${dynamicForward.host}:${dynamicForward.port}');
}
This currently supports SOCKS5 NO AUTH + CONNECT.
It requires dart:io and is not available on web runtimes.
Quick verification from your terminal:
curl --proxy socks5h://127.0.0.1:1080 https://ifconfig.me
If the proxy is working, this command returns the public egress IP seen through the SSH tunnel.
Authenticate with public keys
void main() async {
final client = SSHClient(
socket,
username: '<username>',
identities: [
// A single private key file may contain multiple keys.
...SSHKeyPair.fromPem(await File('path/to/id_rsa').readAsString())
],
);
}
Authenticate with external identities (Secure Enclave, Smart Cards, Hardware Tokens)
Use SSHIdentity.custom or implement SSHIdentity when signing is performed asynchronously by external hardware, the operating system, or a remote agent:
void main() async {
final identity = SSHIdentity.custom(
type: 'ssh-ed25519',
publicKey: SSHRawHostKey(rawPublicKeyBytes),
signer: (challengeData) async {
// Perform signing asynchronously via Hardware Token, OS Keystore, or Secure Enclave
final signatureBytes = await myExternalSigner.sign(challengeData);
return SSHRawSignature(signatureBytes);
},
// Set shouldProbe: true to send an unsigned RFC 4252 ยง7.8 query first,
// avoiding PIN prompts or user interaction if the server rejects the key.
shouldProbe: true,
comment: 'YubiKey 5C NFC',
);
final client = SSHClient(
socket,
username: '<username>',
identities: [identity],
);
}
Authenticate with the client host key (hostbased)
Hostbased authentication (RFC 4252 ยง9) proves the identity of the machine the client runs on, rather than the user. The server trusts the client host, and authorises the local account through it:
void main() async {
final client = SSHClient(
socket,
username: '<username on the server>',
hostbasedIdentities: [hostKeyPair],
hostName: 'workstation.example.com',
userNameOnClientHost: 'localuser',
);
}
hostbasedIdentities holds keys belonging to the client host, not to the user,
so they are usually the host keys in /etc/ssh. The method is only offered when
all three options are set and the identity list is not empty, and hostName
must be the fully qualified name the server knows the client host by.
Use encrypted PEM files
void main() async {
// Test whether the private key is encrypted.
final encrypted = SSHKeyPair.isEncryptedPem(await File('path/to/id_rsa').readAsString());
print(encrypted);
// If the private key is encrypted, you need to provide the passphrase.
final keys = SSHKeyPair.fromPem('<pem text>', '<passphrase>');
print(keys);
}
Decrypting encrypted PEM files (especially those using secure key derivation functions like bcrypt with many rounds) is a CPU-intensive operation that can freeze the UI. In Flutter, you can offload this decryption to a background isolate using the compute function:
void main() async {
List<SSHKeyPair> decryptKeyPairs((String pem, String passphrase) args) {
return SSHKeyPair.fromPem(args.$1, args.$2);
}
final keypairs = await compute(decryptKeyPairs, ('<pem text>', '<passphrase>'));
}
Get the version of SSH server
void main() async {
await client.authenticated;
print(client.remoteVersion); // SSH-2.0-OpenSSH_7.4p1
}
Connect through a jump server
void main() async {
final jumpServer = SSHClient(
await SSHSocket.connect('<jump server>', 22),
username: '...',
onPasswordRequest: () => '...',
);
final client = SSHClient(
await jumpServer.forwardLocal('<target server>', 22),
username: '...',
onPasswordRequest: () => '...',
);
print(utf8.decode(await client.run('hostname'))); // -> hostname of <target server>
}
}
SFTP
List remote directory
void main() async {
final sftp = await client.sftp();
final items = await sftp.listdir('/');
for (final item in items) {
print(item.longname);
}
}
Read remote file
void main() async {
final sftp = await client.sftp();
final file = await sftp.open('/etc/passwd');
final content = await file.readBytes();
print(latin1.decode(content));
}
Download remote file (high-level API)
void main() async {
final sftp = await client.sftp();
final output = File('local_file.txt').openWrite();
final bytes = await sftp.download(
'/remote/file.txt',
output,
onProgress: (bytesRead) => print('downloaded: $bytesRead bytes'),
closeDestination: true,
);
print('download complete: $bytes bytes');
}
download() and downloadTo() are opt-in convenience APIs built on top of the
existing stream-based behavior, so existing code remains fully compatible.
When to use each API:
- Use
sftp.download(path, sink)when you only have a remote path and want the simplest one-liner flow. It opens and closes the remote file for you. - Use
file.downloadTo(sink)when you already have an openSftpFile(for example you want partial downloads withoffset/lengthor want to reuse the same handle).
void main() async {
final sftp = await client.sftp();
final file = await sftp.open('/remote/file.txt');
final output = File('local_partial.bin').openWrite();
try {
// Download bytes [1024, 1024 + 4096) using an existing open handle.
await file.downloadTo(
output,
offset: 1024,
length: 4096,
closeDestination: true,
);
} finally {
await file.close();
}
}
For high-latency links or large files, you can tune pipelining:
void main() async {
final sftp = await client.sftp();
final output = File('local_file.txt').openWrite();
await sftp.download(
'/remote/file.txt',
output,
chunkSize: 64 * 1024,
maxPendingRequests: 128,
closeDestination: true,
);
}
Write remote file
void main() async {
final sftp = await client.sftp();
final file = await sftp.open('file.txt', mode: SftpFileOpenMode.write);
await file.writeBytes(utf8.encode('hello there!') as Uint8List);
}
Write at specific offset
void main() async {
final data = utf8.encode('world') as Uint8List;
await file.writeBytes(data, offset: 6);
}
File upload
void main() async {
final sftp = await client.sftp();
final file = await sftp.open('file.txt', mode: SftpFileOpenMode.create | SftpFileOpenMode.write);
await file.write(File('local_file.txt').openRead().cast());
}
Pause and resume file upload
void main() async {
final uploader = await file.write(File('local_file.txt').openRead().cast());
// ...
await uploader.pause();
// ...
await uploader.resume();
await uploader.done;
}
Clear the remote file before opening it
void main() async {
final file = await sftp.open('file.txt',
mode: SftpFileOpenMode.create | SftpFileOpenMode.truncate | SftpFileOpenMode.write
);
}
Directory operations
void main() async {
final sftp = await client.sftp();
await sftp.mkdir('/path/to/dir');
await sftp.rmdir('/path/to/dir');
}
Get/Set attributes from/to remote file/directory
void main() async {
await sftp.stat('/path/to/file');
await sftp.setStat(
'/path/to/file',
SftpFileAttrs(mode: SftpFileMode(userRead: true)),
);
}
Get the type of a remote file
void main() async {
final stat = await sftp.stat('/path/to/file');
print(stat.type);
// or
print(stat.isDirectory);
print(stat.isSocket);
print(stat.isSymbolicLink);
// ...
}
Create a link
void main() async {
final sftp = await client.sftp();
sftp.link('/from', '/to');
}
Get (estimated) total and free space on the remote filesystem
void main() async {
final sftp = await client.sftp();
final statvfs = await sftp.statvfs('/root');
print('total: ${statvfs.blockSize * statvfs.totalBlocks}');
print('free: ${statvfs.blockSize * statvfs.freeBlocks}');
}
๐ช Example
SSH client:
- example/example.dart
- example/execute.dart
- example/forward_local.dart
- example/forward_remote.dart
- example/pubkey.dart
- example/shell.dart
- example/ssh_jump.dart
SFTP:
- example/sftp_read.dart
- example/sftp_list.dart
- example/sftp_stat.dart
- example/sftp_upload.dart
- example/sftp_filetype.dart
๐ Supported algorithms
Host key:
ssh-rsarsa-sha2-[256|512]ecdsa-sha2-nistp[256|384|521]ssh-ed25519
Key exchange:
curve25519-sha256ecdh-sha2-nistp[256|384|521]diffie-hellman-group-exchange-sha[1|256]diffie-hellman-group14-sha[1|256]diffie-hellman-group1-sha1
Cipher:
chacha20-poly1305@openssh.comaes[128|256]-gcm@openssh.comaes[128|192|256]-ctraes[128|192|256]-cbc
Integrity:
hmac-sha2-[256|512]-etm@openssh.comhmac-sha2-[256|512]hmac-sha2-[256|512]-96hmac-sha1hmac-md5
Default preferences
Each list is ordered by preference and the first algorithm the server also supports is the one that gets used. The defaults lead with the strongest option and keep the weaker ones only as a fallback for old servers:
| Default order | |
|---|---|
| Key exchange | curve25519 โ ECDH NIST โ DH group-exchange/group14 SHA-256 โ the SHA-1 variants |
| Host key | ssh-ed25519 โ rsa-sha2-512/256 โ ECDSA โ ssh-rsa |
| Cipher | AES-GCM โ ChaCha20-Poly1305 โ AES-CTR โ AES-CBC |
| Integrity | ETM variants โ hmac-sha2-256/512 โ hmac-sha1 |
Three groups are implemented but left out of the defaults, because they are
considered broken rather than merely old. Pass them to SSHAlgorithms
explicitly if a legacy server leaves you no choice:
diffie-hellman-group1-sha1, whose 1024-bit group is below any current recommendation.hmac-md5.- The truncated
hmac-sha2-[256|512]-96variants.
void main() async {
final client = SSHClient(
await SSHSocket.connect('localhost', 22),
username: '<username>',
onPasswordRequest: () => '<password>',
algorithms: const SSHAlgorithms(
// Only do this for a server that supports nothing better.
kex: [SSHKexType.dh14Sha1, SSHKexType.dh1Sha1],
),
);
// Use the client...
await client.close();
}
Protocol hardening
- Strict key exchange (
kex-strict-c-v00@openssh.com) is negotiated automatically and enabled whenever the server supports it. It is the countermeasure against the Terrapin attack (CVE-2023-48795): packet sequence numbers are reset after everySSH_MSG_NEWKEYS, and the optionalSSH_MSG_IGNORE/SSH_MSG_UNIMPLEMENTED/SSH_MSG_DEBUGmessages are rejected while a key exchange is running.SSHClient.strictKexreports whether it is active on a live connection. - EXT_INFO (RFC 8308) is requested via
ext-info-c. The signature algorithms the server advertises are exposed asSSHClient.serverSigAlgs.
Private key:
| Type | Decode | Decrypt | Encode | Encrypt |
|---|---|---|---|---|
| RSA | โ๏ธ | โ๏ธ | โ๏ธ | WIP |
| OpenSSH RSA | โ๏ธ | โ๏ธ | โ๏ธ | WIP |
| OpenSSH ECDSA | โ๏ธ | โ๏ธ | โ๏ธ | WIP |
| OpenSSH Ed25519 | โ๏ธ | โ๏ธ | โ๏ธ | WIP |
โณ Roadmap
xFix broken tests.xSound null safety.xRedesign API to allow starting multiple sessions.xFull SFTP.Server.
References
RFC 4250The Secure Shell (SSH) Protocol Assigned Numbers.RFC 4251The Secure Shell (SSH) Protocol Architecture.RFC 4252The Secure Shell (SSH) Authentication Protocol.RFC 4253The Secure Shell (SSH) Transport Layer Protocol.RFC 4254The Secure Shell (SSH) Connection Protocol.RFC 4255Using DNS to Securely Publish Secure Shell (SSH) Key Fingerprints.RFC 4256Generic Message Exchange Authentication for the Secure Shell Protocol (SSH).RFC 4419Diffie-Hellman Group Exchange for the Secure Shell (SSH) Transport Layer Protocol.RFC 4716The Secure Shell (SSH) Public Key File Format.RFC 5656Elliptic Curve Algorithm Integration in the Secure Shell Transport Layer.RFC 8332Use of RSA Keys with SHA-256 and SHA-512 in the Secure Shell (SSH) Protocol.RFC 8731Secure Shell (SSH) Key Exchange Method Using Curve25519 and Curve448.draft-miller-ssh-agent-03SSH Agent Protocol.draft-ietf-secsh-filexfer-02SSH File Transfer Protocol.draft-dbider-sha2-mac-for-ssh-06SHA-2 Data Integrity Verification for the Secure Shell (SSH) Transport Layer Protocol.
Credits
- https://github.com/GreenAppers/dartssh by GreenAppers.
- dartssh2 was created at TerminalStudio, where it was developed and maintained up to version 3.0.1, before moving to this repository.
License
dartssh is released under the terms of the MIT license. See LICENSE.