diff --git a/assets/l10n/en.json b/assets/l10n/en.json index 79fe092..a7bbe72 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -274,6 +274,7 @@ "PDF 를 만들지 못했습니다: {0}": "Could not make the PDF: {0}", "PDF 를 열 수 없습니다: {0}": "Cannot open the PDF: {0}", "PDF 에 넣을 그림 선택": "Choose images to add to the PDF", + "PuTTY 키 (.ppk) 는 쓸 수 없습니다. PuTTYgen 의 [Conversions] > [Export OpenSSH key] 로 바꾼 파일을 고르세요": "PuTTY keys (.ppk) can't be used. Choose a file converted with PuTTYgen [Conversions] > [Export OpenSSH key]", "Real-ESRGAN x4plus (사진)": "Real-ESRGAN x4plus (photos)", "Real-ESRGAN x4plus anime 6B (만화 · 그림, 가볍고 빠름)": "Real-ESRGAN x4plus anime 6B (comics · art, light and fast)", "Rsync (지금 여기)": "Rsync (you are here)", @@ -699,11 +700,12 @@ "개인 키 암호": "Private key passphrase", "개인 키 있음": "Private key set", "개인 키 파일 고르기": "Choose private key file", - "개인 키 파일이 아닙니다 (OpenSSH · PEM 형식의 비밀 키를 고르세요, .pub 아님)": "This isn't a private key file (choose an OpenSSH or PEM private key, not .pub)", "개인 키 파일이 아닙니다 (너무 큼)": "This isn't a private key file (too large)", "개인 키를 넣었습니다 (원래 파일 경로는 저장하지 않습니다)": "Private key added (the original file path isn't saved)", - "개인 키를 읽을 수 없습니다 (OpenSSH · PEM 형식)": "Can't read the private key (OpenSSH or PEM format)", + "개인 키를 넣었습니다. 암호가 걸린 키라 아래에 키 암호를 넣으세요 (원래 파일 경로는 저장하지 않습니다)": "Private key added. It is protected by a passphrase, so enter the key passphrase below (the original file path is not saved)", + "개인 키를 읽을 수 없습니다. OpenSSH 형식이나 PEM (RSA · EC) 형식의 비밀 키 (ed25519 · rsa · ecdsa) 를 고르세요 (.pub 은 공개 키)": "Can't read the private key. Choose an OpenSSH or PEM (RSA · EC) private key (ed25519 · rsa · ecdsa) (.pub is the public key)", "개인 키만 쓰면 비웁니다": "Leave empty if you use only a private key", + "개인 키에 암호가 걸려 있습니다. 키 암호를 넣으세요": "The private key is protected by a passphrase. Enter the key passphrase", "개인 키의 암호가 맞지 않습니다": "The private key passphrase is incorrect", "거꾸로": "Reverse", "거꾸로 된 범위: {0}": "Reversed range: {0}", @@ -1581,6 +1583,7 @@ "서버가 암호화 (FTPS) 를 시작하기 전에 예상하지 못한 답을 보냈습니다. 연결을 끊었습니다": "The server sent an unexpected reply before starting encryption (FTPS). The connection was closed", "서버가 암호화 (FTPS) 를 하지 않습니다 ({0} {1}). 서버 설정에서 FTPS 를 켜거나, 위험을 알고 \"평문 FTP\" 를 고르세요": "The server doesn't support encryption (FTPS) ({0} {1}). Turn on FTPS on the server, or choose \"Plain FTP\" knowing the risk", "서버가 이 기능을 허용하지 않음": "the server does not allow this", + "서버가 이 키를 받지 않습니다. 서버의 ~/.ssh/authorized_keys 에 짝이 되는 공개 키 (.pub) 가 있는지, 아이디가 맞는지 보세요": "The server doesn't accept this key. Check that the matching public key (.pub) is in the server's ~/.ssh/authorized_keys and that the user name is right", "서버에 닿을 수 없음": "host unreachable", "서버에 닿지 않습니다": "Cannot reach the server", "서버에서 이 계정의 권한을 확인하세요.": "Check this account's permissions on the server.", @@ -1740,6 +1743,7 @@ "아이디 (비우면 게스트)": "User name (guest if empty)", "아이디 (비우면 익명)": "User name (empty = anonymous)", "아이디 (선택)": "Username (optional)", + "아이디 · 비밀번호 · 키가 모두 서버에서 거부되었습니다": "The user name, password and key were all rejected by the server", "아이디 · 비밀번호를 확인하세요": "check the user name and password", "아이디 또는 비밀번호가 맞지 않습니다": "The user name or password is wrong", "아이디·비밀번호는 환경 설정에서 넣을 수 있습니다 (선택, 하루 받기 횟수가 늘어남).": "You can enter the username · password in Settings (optional, raises the daily download limit).", diff --git a/assets/l10n/ja.json b/assets/l10n/ja.json index 2b1da07..36d5fe9 100644 --- a/assets/l10n/ja.json +++ b/assets/l10n/ja.json @@ -274,6 +274,7 @@ "PDF 를 만들지 못했습니다: {0}": "PDF を作成できませんでした: {0}", "PDF 를 열 수 없습니다: {0}": "PDF を開けません: {0}", "PDF 에 넣을 그림 선택": "PDF に追加する画像を選択", + "PuTTY 키 (.ppk) 는 쓸 수 없습니다. PuTTYgen 의 [Conversions] > [Export OpenSSH key] 로 바꾼 파일을 고르세요": "PuTTY のキー (.ppk) は使えません。PuTTYgen の [Conversions] > [Export OpenSSH key] で変換したファイルを選んでください", "Real-ESRGAN x4plus (사진)": "Real-ESRGAN x4plus (写真)", "Real-ESRGAN x4plus anime 6B (만화 · 그림, 가볍고 빠름)": "Real-ESRGAN x4plus anime 6B (マンガ・イラスト、軽くて速い)", "Rsync (지금 여기)": "Rsync (現在地)", @@ -699,11 +700,12 @@ "개인 키 암호": "秘密鍵のパスフレーズ", "개인 키 있음": "秘密鍵あり", "개인 키 파일 고르기": "秘密鍵ファイルを選択", - "개인 키 파일이 아닙니다 (OpenSSH · PEM 형식의 비밀 키를 고르세요, .pub 아님)": "秘密鍵ファイルではありません (OpenSSH・PEM 形式の秘密鍵を選んでください。.pub ではありません)", "개인 키 파일이 아닙니다 (너무 큼)": "秘密鍵ファイルではありません (大きすぎます)", "개인 키를 넣었습니다 (원래 파일 경로는 저장하지 않습니다)": "秘密鍵を登録しました (元のファイルのパスは保存しません)", - "개인 키를 읽을 수 없습니다 (OpenSSH · PEM 형식)": "秘密鍵を読み込めません (OpenSSH・PEM 形式)", + "개인 키를 넣었습니다. 암호가 걸린 키라 아래에 키 암호를 넣으세요 (원래 파일 경로는 저장하지 않습니다)": "秘密鍵を入れました。パスフレーズ付きのキーなので下にキーのパスフレーズを入れてください (元のファイルの場所は保存しません)", + "개인 키를 읽을 수 없습니다. OpenSSH 형식이나 PEM (RSA · EC) 형식의 비밀 키 (ed25519 · rsa · ecdsa) 를 고르세요 (.pub 은 공개 키)": "秘密鍵を読めません。OpenSSH 形式か PEM (RSA・EC) 形式の秘密鍵 (ed25519・rsa・ecdsa) を選んでください (.pub は公開鍵)", "개인 키만 쓰면 비웁니다": "秘密鍵だけを使うなら空にします", + "개인 키에 암호가 걸려 있습니다. 키 암호를 넣으세요": "秘密鍵にパスフレーズがかかっています。キーのパスフレーズを入れてください", "개인 키의 암호가 맞지 않습니다": "秘密鍵のパスフレーズが正しくありません", "거꾸로": "逆順", "거꾸로 된 범위: {0}": "逆順の範囲: {0}", @@ -1581,6 +1583,7 @@ "서버가 암호화 (FTPS) 를 시작하기 전에 예상하지 못한 답을 보냈습니다. 연결을 끊었습니다": "サーバーが暗号化 (FTPS) の開始前に予期しない応答を送りました。接続を切りました", "서버가 암호화 (FTPS) 를 하지 않습니다 ({0} {1}). 서버 설정에서 FTPS 를 켜거나, 위험을 알고 \"평문 FTP\" 를 고르세요": "サーバーが暗号化 (FTPS) に対応していません ({0} {1})。サーバー側で FTPS を有効にするか、リスクを理解した上で「平文 FTP」を選んでください", "서버가 이 기능을 허용하지 않음": "サーバーがこの機能を許可していません", + "서버가 이 키를 받지 않습니다. 서버의 ~/.ssh/authorized_keys 에 짝이 되는 공개 키 (.pub) 가 있는지, 아이디가 맞는지 보세요": "サーバーがこのキーを受け付けません。サーバーの ~/.ssh/authorized_keys に対になる公開鍵 (.pub) があるか、ユーザー名が合っているか確認してください", "서버에 닿을 수 없음": "ホストに到達できません", "서버에 닿지 않습니다": "サーバーに接続できません", "서버에서 이 계정의 권한을 확인하세요.": "サーバーでこのアカウントの権限を確認してください。", @@ -1740,6 +1743,7 @@ "아이디 (비우면 게스트)": "ユーザー名 (空欄ならゲスト)", "아이디 (비우면 익명)": "ユーザー名 (空欄なら匿名)", "아이디 (선택)": "ユーザー名(任意)", + "아이디 · 비밀번호 · 키가 모두 서버에서 거부되었습니다": "ユーザー名・パスワード・キーのすべてがサーバーに拒否されました", "아이디 · 비밀번호를 확인하세요": "ID とパスワードを確認してください", "아이디 또는 비밀번호가 맞지 않습니다": "ユーザー名またはパスワードが正しくありません", "아이디·비밀번호는 환경 설정에서 넣을 수 있습니다 (선택, 하루 받기 횟수가 늘어남).": "ユーザー名・パスワードは環境設定で入力できます(任意、1日のダウンロード回数が増えます)。", diff --git a/assets/l10n/zh-Hans.json b/assets/l10n/zh-Hans.json index 69b10cb..6c132c9 100644 --- a/assets/l10n/zh-Hans.json +++ b/assets/l10n/zh-Hans.json @@ -274,6 +274,7 @@ "PDF 를 만들지 못했습니다: {0}": "无法制作 PDF: {0}", "PDF 를 열 수 없습니다: {0}": "无法打开 PDF: {0}", "PDF 에 넣을 그림 선택": "选择要加入 PDF 的图片", + "PuTTY 키 (.ppk) 는 쓸 수 없습니다. PuTTYgen 의 [Conversions] > [Export OpenSSH key] 로 바꾼 파일을 고르세요": "无法使用 PuTTY 密钥 (.ppk)。请选择用 PuTTYgen 的 [Conversions] > [Export OpenSSH key] 转换的文件", "Real-ESRGAN x4plus (사진)": "Real-ESRGAN x4plus (照片)", "Real-ESRGAN x4plus anime 6B (만화 · 그림, 가볍고 빠름)": "Real-ESRGAN x4plus anime 6B (漫画 · 插画,轻量快速)", "Rsync (지금 여기)": "Rsync (当前位置)", @@ -699,11 +700,12 @@ "개인 키 암호": "私钥密码", "개인 키 있음": "已设置私钥", "개인 키 파일 고르기": "选择私钥文件", - "개인 키 파일이 아닙니다 (OpenSSH · PEM 형식의 비밀 키를 고르세요, .pub 아님)": "这不是私钥文件 (请选择 OpenSSH · PEM 格式的私钥,而不是 .pub)", "개인 키 파일이 아닙니다 (너무 큼)": "这不是私钥文件 (文件过大)", "개인 키를 넣었습니다 (원래 파일 경로는 저장하지 않습니다)": "已添加私钥 (不保存原文件路径)", - "개인 키를 읽을 수 없습니다 (OpenSSH · PEM 형식)": "无法读取私钥 (OpenSSH · PEM 格式)", + "개인 키를 넣었습니다. 암호가 걸린 키라 아래에 키 암호를 넣으세요 (원래 파일 경로는 저장하지 않습니다)": "已添加私钥。此密钥设有密码,请在下方输入私钥密码 (不保存原文件路径)", + "개인 키를 읽을 수 없습니다. OpenSSH 형식이나 PEM (RSA · EC) 형식의 비밀 키 (ed25519 · rsa · ecdsa) 를 고르세요 (.pub 은 공개 키)": "无法读取私钥。请选择 OpenSSH 或 PEM (RSA · EC) 格式的私钥 (ed25519 · rsa · ecdsa) (.pub 是公钥)", "개인 키만 쓰면 비웁니다": "仅使用私钥时留空", + "개인 키에 암호가 걸려 있습니다. 키 암호를 넣으세요": "私钥设有密码。请输入私钥密码", "개인 키의 암호가 맞지 않습니다": "私钥密码不正确", "거꾸로": "倒序", "거꾸로 된 범위: {0}": "范围顺序颠倒: {0}", @@ -1581,6 +1583,7 @@ "서버가 암호화 (FTPS) 를 시작하기 전에 예상하지 못한 답을 보냈습니다. 연결을 끊었습니다": "服务器在开始加密 (FTPS) 前发送了意外的回复。已断开连接", "서버가 암호화 (FTPS) 를 하지 않습니다 ({0} {1}). 서버 설정에서 FTPS 를 켜거나, 위험을 알고 \"평문 FTP\" 를 고르세요": "服务器不支持加密 (FTPS) ({0} {1})。请在服务器上启用 FTPS,或在了解风险后选择\"明文 FTP\"", "서버가 이 기능을 허용하지 않음": "服务器不允许此操作", + "서버가 이 키를 받지 않습니다. 서버의 ~/.ssh/authorized_keys 에 짝이 되는 공개 키 (.pub) 가 있는지, 아이디가 맞는지 보세요": "服务器不接受此密钥。请检查服务器的 ~/.ssh/authorized_keys 中是否有对应的公钥 (.pub),以及用户名是否正确", "서버에 닿을 수 없음": "主机不可达", "서버에 닿지 않습니다": "无法连接服务器", "서버에서 이 계정의 권한을 확인하세요.": "请在服务器上检查此账户的权限。", @@ -1740,6 +1743,7 @@ "아이디 (비우면 게스트)": "用户名 (留空则为访客)", "아이디 (비우면 익명)": "用户名 (留空则匿名)", "아이디 (선택)": "用户名(可选)", + "아이디 · 비밀번호 · 키가 모두 서버에서 거부되었습니다": "用户名、密码和密钥都被服务器拒绝", "아이디 · 비밀번호를 확인하세요": "请检查用户名和密码", "아이디 또는 비밀번호가 맞지 않습니다": "用户名或密码错误", "아이디·비밀번호는 환경 설정에서 넣을 수 있습니다 (선택, 하루 받기 횟수가 늘어남).": "可在设置中填写用户名 · 密码(可选,可增加每日下载次数)。", diff --git a/lib/core/sftp.dart b/lib/core/sftp.dart index ca5aeaa..d6b0ab7 100644 --- a/lib/core/sftp.dart +++ b/lib/core/sftp.dart @@ -113,7 +113,32 @@ class SftpServer { } /// SFTP 실패의 종류 (안내 글 · 할 일 단추를 고르는 데) -enum SftpFailure { unreachable, auth, noSecret, hostKeyUnknown, hostKeyChanged, permission, notFound, other } +/// [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 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 { @@ -187,13 +212,10 @@ class SftpRemote implements RemoteClient { List? keys; if (s.privateKey.isNotEmpty) { try { - keys = SSHKeyPair.fromPem(s.privateKey, s.keyPassphrase.isEmpty ? null : s.keyPassphrase); - } on SSHKeyDecryptError { - sock.destroy(); - throw SftpException(tr('개인 키의 암호가 맞지 않습니다'), SftpFailure.auth); + keys = parsePrivateKey(s.privateKey, s.keyPassphrase); } catch (_) { sock.destroy(); - throw SftpException(tr('개인 키를 읽을 수 없습니다 (OpenSSH · PEM 형식)'), SftpFailure.auth); + rethrow; } } final ssh = SSHClient( @@ -250,7 +272,12 @@ class SftpRemote implements RemoteClient { : tr('서버와 암호 방식을 맞추지 못했습니다. 오래된 서버면 서버 설정의 "오래된 ssh-rsa 허용" 을 켜 보세요'), SftpFailure.other); } - if (e is SSHAuthError) return SftpException(tr('아이디나 비밀번호 (또는 키) 가 맞지 않습니다'), SftpFailure.auth); + if (e is SSHAuthError) { + // 관리자 지시 (10/21): 키만으로 들어가다 거부되면 서버가 그 키를 모르는 것 - 비밀번호와 따로 알린다 + if (s.privateKey.isNotEmpty && s.password.isEmpty) return SftpException(tr('서버가 이 키를 받지 않습니다. 서버의 ~/.ssh/authorized_keys 에 짝이 되는 공개 키 (.pub) 가 있는지, 아이디가 맞는지 보세요'), SftpFailure.keyRejected); + if (s.privateKey.isNotEmpty) return SftpException(tr('아이디 · 비밀번호 · 키가 모두 서버에서 거부되었습니다'), SftpFailure.auth); + return SftpException(tr('아이디나 비밀번호 (또는 키) 가 맞지 않습니다'), SftpFailure.auth); + } return SftpException('$e', SftpFailure.other); } diff --git a/lib/ui/sftp_settings.dart b/lib/ui/sftp_settings.dart index ffcb44d..a7084bd 100644 --- a/lib/ui/sftp_settings.dart +++ b/lib/ui/sftp_settings.dart @@ -227,15 +227,16 @@ Future editSftpServer(BuildContext context, AppController c, {SftpS // 개인 키는 작다 - 큰 파일은 키가 아니다 (실수로 다른 파일을 고름) if (await f.length() > 64 * 1024) throw FormatException(tr('개인 키 파일이 아닙니다 (너무 큼)')); final text = await f.readAsString(); - if (!text.contains('PRIVATE KEY')) throw FormatException(tr('개인 키 파일이 아닙니다 (OpenSSH · PEM 형식의 비밀 키를 고르세요, .pub 아님)')); + // 관리자 지시 (10/21): 고를 때 바로 꼴을 본다 (OpenSSH · PEM RSA · EC - PuTTY · PKCS#8 · 공개 키는 바꾸는 법을 알림) + final encrypted = checkPrivateKey(text); set(() { key = text; - result = tr('개인 키를 넣었습니다 (원래 파일 경로는 저장하지 않습니다)'); + result = encrypted ? tr('개인 키를 넣었습니다. 암호가 걸린 키라 아래에 키 암호를 넣으세요 (원래 파일 경로는 저장하지 않습니다)') : tr('개인 키를 넣었습니다 (원래 파일 경로는 저장하지 않습니다)'); passed = true; }); } catch (e) { set(() { - result = e is FormatException ? e.message : '$e'; + result = e is FormatException ? e.message : e is SftpException ? e.message : '$e'; passed = false; }); } diff --git a/test/sftp_test.dart b/test/sftp_test.dart index 637be7f..0beaa54 100644 --- a/test/sftp_test.dart +++ b/test/sftp_test.dart @@ -299,10 +299,78 @@ void main() { addTearDown(c.close); expect(await c.list('/'), isEmpty); final bad = await err(() => SftpRemote(srv(password: '', key: pem, kp: 'nope', fp: fingerprint)).list('/')); - expect((bad! as SftpException).kind, SftpFailure.auth); + expect((bad! as SftpException).kind, SftpFailure.keyPassphrase); expect('$bad', contains('개인 키의 암호')); }, skip: hasServer && File(keygen).existsSync() ? false : 'SFTP 시험 서버 · ssh-keygen 없음'); + test('관리자 지시 (10/21): SSH 키 파일 로그인 - ed25519 · rsa · ecdsa × OpenSSH · PEM (암호 있음 · 없음) 이 실제 서버에 붙고, 서버 거부 · 암호 없음은 따로', () async { + final dir = Directory(p.join(tmp.path, 'keys'))..createSync(); + Future gen(String name, List args) async { + final f = p.join(dir.path, name); + final r = await Process.run(keygen, ['-q', ...args, '-f', f, '-C', 'jj-test']); + return r.exitCode == 0 ? f : null; + } + + final cases = [ + ('ed25519 OpenSSH 암호', ['-t', 'ed25519', '-N', 'kp'], 'kp'), + ('ed25519 OpenSSH 암호 없음', ['-t', 'ed25519', '-N', ''], ''), + ('rsa OpenSSH 암호', ['-t', 'rsa', '-b', '2048', '-N', 'kp'], 'kp'), + ('rsa PEM 암호 없음', ['-t', 'rsa', '-b', '2048', '-m', 'PEM', '-N', ''], ''), + ('ecdsa OpenSSH 암호', ['-t', 'ecdsa', '-b', '256', '-N', 'kp'], 'kp'), + ('ecdsa PEM 암호 없음', ['-t', 'ecdsa', '-b', '256', '-m', 'PEM', '-N', ''], ''), + ]; + final failed = []; + for (final (i, (name, args, kp)) in cases.indexed) { + final key = await gen('k$i', args); + if (key == null) return markTestSkipped('ssh-keygen 이 $name 을 만들지 못함'); + await server?.stop(); + await start(password: null, pubkey: '$key.pub'); + final pem = File(key).readAsStringSync(); + expect(checkPrivateKey(pem), kp.isNotEmpty, reason: '$name: 암호가 걸렸는지'); + final c = SftpRemote(srv(password: '', key: pem, kp: kp, fp: fingerprint)); + try { + await c.list('/'); + } catch (e) { + failed.add('$name: $e'); + } finally { + c.close(); + } + } + expect(failed, isEmpty, reason: failed.join('\n')); + // 서버가 모르는 키 (짝 공개 키가 서버에 없음) → 키 거부 (비밀번호 틀림과 따로) + final other = await gen('other', ['-t', 'ed25519', '-N', '']); + final rejected = await err(() => SftpRemote(srv(password: '', key: File(other!).readAsStringSync(), fp: fingerprint)).list('/')); + expect((rejected! as SftpException).kind, SftpFailure.keyRejected, reason: '$rejected'); + expect('$rejected', contains('authorized_keys')); + // 암호가 걸린 키인데 키 암호를 넣지 않음 → 그렇다고 (서버에 묻기 전에) + final locked = File(p.join(dir.path, 'k0')).readAsStringSync(); + final noPass = await err(() => SftpRemote(srv(password: '', key: locked, fp: fingerprint)).list('/')); + expect((noPass! as SftpException).kind, SftpFailure.keyPassphrase); + expect('$noPass', contains('키 암호를 넣으세요')); + }, skip: hasServer && File(keygen).existsSync() ? false : 'SFTP 시험 서버 · ssh-keygen 없음', timeout: const Timeout(Duration(minutes: 3))); + + test('관리자 지시 (10/21): 쓸 수 없는 키 꼴은 고를 때 바로 - PuTTY (.ppk) · PKCS#8 · 공개 키 · 다른 파일', () async { + final dir = Directory(p.join(tmp.path, 'keys'))..createSync(); + final f = p.join(dir.path, 'p8'); + final r = await Process.run(keygen, ['-q', '-t', 'rsa', '-b', '2048', '-m', 'PKCS8', '-N', '', '-f', f]); + final samples = { + 'PuTTY': 'PuTTY-User-Key-File-3: ssh-ed25519\nEncryption: none\nComment: x\nPublic-Lines: 1\nAAAA\n', + if (r.exitCode == 0) 'PKCS#8': File(f).readAsStringSync(), + if (r.exitCode == 0) '공개 키': File('$f.pub').readAsStringSync(), + '다른 파일': 'hello', + }; + for (final MapEntry(key: name, value: text) in samples.entries) { + Object? e; + try { + checkPrivateKey(text); + } catch (x) { + e = x; + } + expect(e, isA().having((x) => x.kind, 'kind', SftpFailure.keyFormat), reason: name); + if (name == 'PuTTY') expect('$e', contains('Export OpenSSH key')); + } + }, skip: File(keygen).existsSync() ? false : 'ssh-keygen 없음'); + test('오래된 서버 (ssh-rsa 만): 처음엔 연결 안 됨 + 켜는 법 안내 → 서버마다 켜면 연결 (88-3)', () async { await start(keytype: 'rsa', legacyOnly: true); final e = await err(() => SftpRemote(srv(fp: fingerprint)).list('/'));