168 lines
7.6 KiB
Dart
168 lines
7.6 KiB
Dart
import 'dart:convert';
|
|
import 'dart:io';
|
|
import 'dart:typed_data';
|
|
|
|
import 'package:dartssh2/dartssh2.dart';
|
|
|
|
import '../../l10n/tr.dart';
|
|
import '../webdav.dart' show DavException;
|
|
|
|
// 약속 (나누기 sftp): SFTP 서버 설정 (저장 꼴) · 실패 종류 · 오류 · 개인 키 읽기 · 호스트 키 지문.
|
|
// 입력: 설정 JSON (비밀 값 없음) · 개인 키 글 + 키 암호 · dartssh2 가 넘긴 지문 바이트. 출력: [SftpServer] · 키 · "SHA256:…" 지문.
|
|
// 오류: 키 형식 · 암호 없음 · 암호 틀림 → [SftpException] ([SftpFailure.keyFormat] · [SftpFailure.keyPassphrase]). 그 밖은 던지지 않는다.
|
|
|
|
/// 88: SFTP 서버 설정 (환경 설정 > 파일 탐색기 > 네트워크 서버). 비밀번호 · 개인 키 · 키 암호는 안전 저장소에 (설정 파일 · 보관본에는 넣지 않음).
|
|
/// 호스트 키 (지문) 는 비밀이 아니라 설정에 둔다 - 처음 연결 때 사용자가 믿은 것 (TOFU).
|
|
class SftpServer {
|
|
final String id;
|
|
final String name;
|
|
final String host;
|
|
final int port;
|
|
final String user;
|
|
|
|
/// 시작 폴더 (서버 기준 절대 경로). 비우면 로그인한 사용자의 홈 폴더. 탭의 "맨 위" 가 이 폴더다
|
|
final String root;
|
|
final String password;
|
|
|
|
/// 개인 키 (OpenSSH · PEM 글). 파일을 고르면 내용을 여기로 옮기고 원래 파일 경로는 저장하지 않는다
|
|
final String privateKey;
|
|
|
|
/// 개인 키의 암호 (사용자 결정 88-2: 안전 저장소에 저장)
|
|
final String keyPassphrase;
|
|
|
|
/// 오래된 ssh-rsa (SHA-1) 서명 · SHA-1 키 교환 허용 (사용자 결정 88-3: 처음 끔, 서버마다 켬 - 예전 NAS 용)
|
|
final bool legacyRsa;
|
|
|
|
/// 믿은 호스트 키: 종류 ("ssh-ed25519" 등) 와 지문 ("SHA256:…"). 비어 있으면 아직 믿은 키가 없음
|
|
final String hostKeyType;
|
|
final String hostKeyFingerprint;
|
|
|
|
const SftpServer({
|
|
required this.id,
|
|
required this.name,
|
|
required this.host,
|
|
this.port = 22,
|
|
this.user = '',
|
|
this.root = '',
|
|
this.password = '',
|
|
this.privateKey = '',
|
|
this.keyPassphrase = '',
|
|
this.legacyRsa = false,
|
|
this.hostKeyType = '',
|
|
this.hostKeyFingerprint = '',
|
|
});
|
|
|
|
/// 탐색기 위쪽에 보일 이름
|
|
String get label => name.trim().isNotEmpty ? name.trim() : host;
|
|
|
|
SftpServer copyWith({
|
|
String? name,
|
|
String? host,
|
|
int? port,
|
|
String? user,
|
|
String? root,
|
|
String? password,
|
|
String? privateKey,
|
|
String? keyPassphrase,
|
|
bool? legacyRsa,
|
|
String? hostKeyType,
|
|
String? hostKeyFingerprint,
|
|
}) =>
|
|
SftpServer(
|
|
id: id,
|
|
name: name ?? this.name,
|
|
host: host ?? this.host,
|
|
port: port ?? this.port,
|
|
user: user ?? this.user,
|
|
root: root ?? this.root,
|
|
password: password ?? this.password,
|
|
privateKey: privateKey ?? this.privateKey,
|
|
keyPassphrase: keyPassphrase ?? this.keyPassphrase,
|
|
legacyRsa: legacyRsa ?? this.legacyRsa,
|
|
hostKeyType: hostKeyType ?? this.hostKeyType,
|
|
hostKeyFingerprint: hostKeyFingerprint ?? this.hostKeyFingerprint,
|
|
);
|
|
|
|
/// 비밀 값은 넣지 않는다 (안전 저장소). 저장해 두었는지만
|
|
Map<String, Object?> toJson() => {
|
|
'id': id,
|
|
'name': name,
|
|
'host': host,
|
|
'port': port,
|
|
'user': user,
|
|
'root': root,
|
|
'hasPassword': password.isNotEmpty,
|
|
'hasKey': privateKey.isNotEmpty,
|
|
'legacyRsa': legacyRsa,
|
|
'hostKeyType': hostKeyType,
|
|
'hostKeyFingerprint': hostKeyFingerprint,
|
|
};
|
|
|
|
factory SftpServer.fromJson(Map<Object?, Object?> j) => SftpServer(
|
|
id: '${j['id'] ?? DateTime.now().microsecondsSinceEpoch}',
|
|
name: j['name'] as String? ?? '',
|
|
host: j['host'] as String? ?? '',
|
|
port: switch (j['port']) { final int p when p > 0 && p < 65536 => p, _ => 22 },
|
|
user: j['user'] as String? ?? '',
|
|
root: j['root'] as String? ?? '',
|
|
legacyRsa: j['legacyRsa'] == true,
|
|
hostKeyType: j['hostKeyType'] as String? ?? '',
|
|
hostKeyFingerprint: j['hostKeyFingerprint'] as String? ?? '',
|
|
);
|
|
}
|
|
|
|
/// SFTP 실패의 종류 (안내 글 · 할 일 단추를 고르는 데)
|
|
/// [keyFormat] · [keyPassphrase] · [keyRejected]: SSH 키 로그인의 실패를 따로 (관리자 지시 10/21 - 연결 확인에서 키 형식 · 암호 틀림 · 서버 거부를 나눠 알림)
|
|
enum SftpFailure { unreachable, auth, noSecret, hostKeyUnknown, hostKeyChanged, permission, notFound, keyFormat, keyPassphrase, keyRejected, other }
|
|
|
|
/// 개인 키 글이 쓸 수 있는 꼴인지 본다 (OpenSSH · PEM RSA · PEM EC - dartssh2 가 읽는 것). 암호가 걸려 있으면 true.
|
|
/// 쓸 수 없으면 [SftpException] ([SftpFailure.keyFormat]) - PuTTY (.ppk) · PKCS#8 (BEGIN PRIVATE KEY) · 공개 키는 바꾸는 법을 알린다
|
|
bool checkPrivateKey(String text) {
|
|
if (text.contains('PuTTY-User-Key-File')) throw SftpException(tr('PuTTY 키 (.ppk) 는 쓸 수 없습니다. PuTTYgen 의 [Conversions] > [Export OpenSSH key] 로 바꾼 파일을 고르세요'), SftpFailure.keyFormat);
|
|
try {
|
|
return SSHKeyPair.isEncryptedPem(text);
|
|
} catch (_) {
|
|
throw SftpException(tr('개인 키를 읽을 수 없습니다. OpenSSH 형식이나 PEM (RSA · EC) 형식의 비밀 키 (ed25519 · rsa · ecdsa) 를 고르세요 (.pub 은 공개 키)'), SftpFailure.keyFormat);
|
|
}
|
|
}
|
|
|
|
/// 개인 키 글 + 키 암호 → 로그인에 쓸 키. 키 형식 · 암호 없음 · 암호 틀림을 따로 알린다
|
|
List<SSHKeyPair> parsePrivateKey(String text, String passphrase) {
|
|
final encrypted = checkPrivateKey(text);
|
|
if (encrypted && passphrase.isEmpty) throw SftpException(tr('개인 키에 암호가 걸려 있습니다. 키 암호를 넣으세요'), SftpFailure.keyPassphrase);
|
|
try {
|
|
return SSHKeyPair.fromPem(text, encrypted ? passphrase : null);
|
|
} catch (_) {
|
|
// 암호가 걸린 키를 풀지 못함 = 암호 틀림 (PEM 은 틀린 암호로 풀면 다른 오류가 나기도 한다)
|
|
if (encrypted) throw SftpException(tr('개인 키의 암호가 맞지 않습니다'), SftpFailure.keyPassphrase);
|
|
throw SftpException(tr('개인 키를 읽을 수 없습니다. OpenSSH 형식이나 PEM (RSA · EC) 형식의 비밀 키 (ed25519 · rsa · ecdsa) 를 고르세요 (.pub 은 공개 키)'), SftpFailure.keyFormat);
|
|
}
|
|
}
|
|
|
|
/// SFTP 오류 - WebDAV 오류와 같은 곳에서 보이도록 [DavException] 을 잇는다
|
|
class SftpException extends DavException {
|
|
final SftpFailure kind;
|
|
const SftpException(super.message, this.kind);
|
|
}
|
|
|
|
/// 처음 보는 호스트 키 (또는 바뀐 키): 화면이 지문을 보이고 믿을지 묻는다. 믿으면 [SftpServer.hostKeyFingerprint] 에 저장
|
|
class SftpHostKeyException extends SftpException {
|
|
final String type;
|
|
final String fingerprint;
|
|
|
|
/// 바뀐 경우 예전에 믿은 지문 (처음이면 null)
|
|
final String? previous;
|
|
const SftpHostKeyException(super.message, super.kind, {required this.type, required this.fingerprint, this.previous});
|
|
bool get changed => previous != null;
|
|
}
|
|
|
|
/// OpenSSH 와 같은 꼴의 지문 "SHA256:" + base64 (끝 '=' 없음). dartssh2 는 이 글을 바이트로 넘긴다 -
|
|
/// 판이 바뀌어 SHA-256 값 그대로 오면 직접 만든다
|
|
String sshFingerprint(Uint8List fp) {
|
|
final text = utf8.decode(fp, allowMalformed: true);
|
|
return text.startsWith('SHA256:') ? text : 'SHA256:${base64.encode(fp).replaceAll('=', '')}';
|
|
}
|
|
|
|
/// 연결할 서버 주소. DNS 강제면 정한 DNS 로 (집 안 이름 · 주소는 기기 DNS) - 앱의 다른 연결과 같게
|
|
typedef SftpConnect = Future<Socket> Function(String host, int port, Duration timeout);
|