Merge commit '63b92b6' into next974
commit
11d709a97b
|
|
@ -86,8 +86,10 @@ object SmbBridge {
|
||||||
val c = conn(call)
|
val c = conn(call)
|
||||||
val path = smbPath(call.argument<String>("path") ?: "")
|
val path = smbPath(call.argument<String>("path") ?: "")
|
||||||
return when (call.method) {
|
return when (call.method) {
|
||||||
|
// 보안 검토 6: 이 연결 (세션) 이 실제로 암호화 · 서명되는지
|
||||||
"info" -> mapOf(
|
"info" -> mapOf(
|
||||||
"encrypted" to (c.connection.connectionContext.supportsEncryption()),
|
"encrypted" to runCatching { c.session.shouldEncryptData() }.getOrDefault(false),
|
||||||
|
"signed" to c.session.isSigningRequired,
|
||||||
"dialect" to c.connection.connectionContext.negotiatedProtocol.dialect.name,
|
"dialect" to c.connection.connectionContext.negotiatedProtocol.dialect.name,
|
||||||
)
|
)
|
||||||
"list" -> c.share.list(path).filter { it.fileName != "." && it.fileName != ".." }.map {
|
"list" -> c.share.list(path).filter { it.fileName != "." && it.fileName != ".." }.map {
|
||||||
|
|
@ -173,7 +175,8 @@ object SmbBridge {
|
||||||
val user = call.argument<String>("user") ?: ""
|
val user = call.argument<String>("user") ?: ""
|
||||||
val domain = call.argument<String>("domain") ?: ""
|
val domain = call.argument<String>("domain") ?: ""
|
||||||
val password = call.argument<String>("password") ?: ""
|
val password = call.argument<String>("password") ?: ""
|
||||||
val key = listOf(host, port, shareName, user, domain, password.hashCode()).joinToString("|")
|
val requireSecure = call.argument<Boolean>("requireSecure") ?: false
|
||||||
|
val key = listOf(host, port, shareName, user, domain, password.hashCode(), requireSecure).joinToString("|")
|
||||||
conns[id]?.let {
|
conns[id]?.let {
|
||||||
if (it.key == key && it.connection.isConnected) return it
|
if (it.key == key && it.connection.isConnected) return it
|
||||||
conns.remove(id)
|
conns.remove(id)
|
||||||
|
|
@ -183,6 +186,8 @@ object SmbBridge {
|
||||||
// Android 의 기본 보안 제공자에는 NTLM 에 필요한 MD4 가 없다 - Bouncy Castle 로
|
// Android 의 기본 보안 제공자에는 NTLM 에 필요한 MD4 가 없다 - Bouncy Castle 로
|
||||||
.withSecurityProvider(BCSecurityProvider())
|
.withSecurityProvider(BCSecurityProvider())
|
||||||
.withEncryptData(true)
|
.withEncryptData(true)
|
||||||
|
// 보안 검토 6: 설정 "SMB 서명 · 암호화 필수" 면 서명을 요구한다 (서버가 서명을 켜 두었으면 서명)
|
||||||
|
.withSigningRequired(requireSecure)
|
||||||
.withTimeout(30, TimeUnit.SECONDS)
|
.withTimeout(30, TimeUnit.SECONDS)
|
||||||
.withSoTimeout(60, TimeUnit.SECONDS)
|
.withSoTimeout(60, TimeUnit.SECONDS)
|
||||||
.build()
|
.build()
|
||||||
|
|
@ -192,6 +197,10 @@ object SmbBridge {
|
||||||
val auth = if (user.isEmpty()) AuthenticationContext.guest()
|
val auth = if (user.isEmpty()) AuthenticationContext.guest()
|
||||||
else AuthenticationContext(user, password.toCharArray(), domain.ifEmpty { null })
|
else AuthenticationContext(user, password.toCharArray(), domain.ifEmpty { null })
|
||||||
val session = connection.authenticate(auth)
|
val session = connection.authenticate(auth)
|
||||||
|
if (requireSecure && !session.isSigningRequired && !runCatching { session.shouldEncryptData() }.getOrDefault(false)) {
|
||||||
|
runCatching { session.close() }
|
||||||
|
throw SmbBridgeException("insecure", "signing and encryption are off")
|
||||||
|
}
|
||||||
val share = session.connectShare(shareName) as? DiskShare
|
val share = session.connectShare(shareName) as? DiskShare
|
||||||
?: throw SmbBridgeException("noShare", "공유 폴더가 아닙니다: $shareName")
|
?: throw SmbBridgeException("noShare", "공유 폴더가 아닙니다: $shareName")
|
||||||
val c = Conn(key, client, connection, session, share)
|
val c = Conn(key, client, connection, session, share)
|
||||||
|
|
|
||||||
|
|
@ -284,6 +284,7 @@
|
||||||
"SFTP 서버 추가": "Add SFTP server",
|
"SFTP 서버 추가": "Add SFTP server",
|
||||||
"SFTP 에서 받는 중": "Downloading from SFTP",
|
"SFTP 에서 받는 중": "Downloading from SFTP",
|
||||||
"SHA-1 서명 · 키 교환을 씁니다. 오래된 NAS 가 다른 방식을 모를 때만 켜세요 (보안이 약함)": "Uses SHA-1 signatures and key exchange. Turn on only when an old NAS supports nothing else (weaker security)",
|
"SHA-1 서명 · 키 교환을 씁니다. 오래된 NAS 가 다른 방식을 모를 때만 켜세요 (보안이 약함)": "Uses SHA-1 signatures and key exchange. Turn on only when an old NAS supports nothing else (weaker security)",
|
||||||
|
"SMB 서명 · 암호화 필수": "Require SMB signing or encryption",
|
||||||
"SMB 서버 \"{0}\" 을(를) 지울까요?": "Delete SMB server \"{0}\"?",
|
"SMB 서버 \"{0}\" 을(를) 지울까요?": "Delete SMB server \"{0}\"?",
|
||||||
"SMB 서버 고치기": "Edit SMB server",
|
"SMB 서버 고치기": "Edit SMB server",
|
||||||
"SMB 서버 설정이 없습니다 ({0})": "There are no settings for this SMB server ({0})",
|
"SMB 서버 설정이 없습니다 ({0})": "There are no settings for this SMB server ({0})",
|
||||||
|
|
@ -1502,6 +1503,8 @@
|
||||||
"새로고침": "Reload",
|
"새로고침": "Reload",
|
||||||
"색 보정": "Color correction",
|
"색 보정": "Color correction",
|
||||||
"색온도": "Temperature",
|
"색온도": "Temperature",
|
||||||
|
"서명 · 암호화가 되지 않는 연결이라 열지 않았습니다 (설정 \"SMB 서명 · 암호화 필수\"). NAS 에서 SMB 서명이나 SMB3 암호화를 켜거나, 설정을 끄세요": "Not opened: the connection is neither signed nor encrypted (setting \"Require SMB signing or encryption\"). Turn on SMB signing or SMB3 encryption on the NAS, or turn the setting off",
|
||||||
|
"서명도 암호화도 되지 않는 연결은 열지 않습니다 (중간에서 내용을 보거나 바꾸지 못하게). 오래된 NAS 는 붙지 않을 수 있습니다. 꺼 두어도 그런 서버는 탐색기 탭에 ⚠ 를 보입니다": "Connections that are neither signed nor encrypted are not opened (so nobody in between can read or change the data). Older NAS devices may not connect. Even when off, such servers show ⚠ on the explorer tab",
|
||||||
"서버 ({0}) 가 답하지 않습니다": "The server ({0}) isn't responding",
|
"서버 ({0}) 가 답하지 않습니다": "The server ({0}) isn't responding",
|
||||||
"서버 ({0}) 에 연결할 수 없습니다. 서버가 켜져 있는지, 집 밖이면 Tailscale 이 켜져 있는지 확인하세요": "Can't connect to the server ({0}). Check that the server is on and, if you're away from home, that Tailscale is on",
|
"서버 ({0}) 에 연결할 수 없습니다. 서버가 켜져 있는지, 집 밖이면 Tailscale 이 켜져 있는지 확인하세요": "Can't connect to the server ({0}). Check that the server is on and, if you're away from home, that Tailscale is on",
|
||||||
"서버 ({0}) 에 연결할 수 없습니다: {1}": "Can't connect to the server ({0}): {1}",
|
"서버 ({0}) 에 연결할 수 없습니다: {1}": "Can't connect to the server ({0}): {1}",
|
||||||
|
|
@ -2008,6 +2011,7 @@
|
||||||
"이 설치본에는 rsync 가 들어 있지 않습니다. 직접 지정하세요.": "This install has no rsync. Choose one yourself.",
|
"이 설치본에는 rsync 가 들어 있지 않습니다. 직접 지정하세요.": "This install has no rsync. Choose one yourself.",
|
||||||
"이 앱 (APK) 에는 Tor 가 들어 있지 않습니다.": "This app (APK) does not contain Tor.",
|
"이 앱 (APK) 에는 Tor 가 들어 있지 않습니다.": "This app (APK) does not contain Tor.",
|
||||||
"이 언어 지우기": "Remove this language",
|
"이 언어 지우기": "Remove this language",
|
||||||
|
"이 연결은 서명도 암호화도 되지 않습니다 ({0}). 같은 네트워크의 다른 사람이 파일 내용을 보거나 바꿀 수 있으니, NAS 설정에서 SMB 서명이나 SMB3 암호화를 켜는 것이 좋습니다": "This connection is neither signed nor encrypted ({0}). Others on the same network could read or change the files, so it's best to turn on SMB signing or SMB3 encryption in the NAS settings",
|
||||||
"이 연결은 암호화되지 않습니다 ({0}). 같은 네트워크의 다른 사람이 파일 내용을 볼 수 있으니, NAS 설정에서 SMB3 암호화를 켜는 것이 좋습니다": "This connection isn't encrypted ({0}). Others on the same network could see file contents, so it's best to turn on SMB3 encryption in the NAS settings",
|
"이 연결은 암호화되지 않습니다 ({0}). 같은 네트워크의 다른 사람이 파일 내용을 볼 수 있으니, NAS 설정에서 SMB3 암호화를 켜는 것이 좋습니다": "This connection isn't encrypted ({0}). Others on the same network could see file contents, so it's best to turn on SMB3 encryption in the NAS settings",
|
||||||
"이 위치 (네트워크 드라이브 · 네트워크 공유 · USB 메모리 등) 에는 휴지통이 없어 바로 지워집니다.": "This location (network drive, network share, USB memory etc.) has no Recycle Bin, so items are deleted right away.",
|
"이 위치 (네트워크 드라이브 · 네트워크 공유 · USB 메모리 등) 에는 휴지통이 없어 바로 지워집니다.": "This location (network drive, network share, USB memory etc.) has no Recycle Bin, so items are deleted right away.",
|
||||||
"이 이동 버튼 지우기": "Delete this move button",
|
"이 이동 버튼 지우기": "Delete this move button",
|
||||||
|
|
|
||||||
|
|
@ -284,6 +284,7 @@
|
||||||
"SFTP 서버 추가": "SFTP サーバーを追加",
|
"SFTP 서버 추가": "SFTP サーバーを追加",
|
||||||
"SFTP 에서 받는 중": "SFTP から受信中",
|
"SFTP 에서 받는 중": "SFTP から受信中",
|
||||||
"SHA-1 서명 · 키 교환을 씁니다. 오래된 NAS 가 다른 방식을 모를 때만 켜세요 (보안이 약함)": "SHA-1 の署名・鍵交換を使います。古い NAS が他の方式に対応していない場合のみオンにしてください (セキュリティが弱い)",
|
"SHA-1 서명 · 키 교환을 씁니다. 오래된 NAS 가 다른 방식을 모를 때만 켜세요 (보안이 약함)": "SHA-1 の署名・鍵交換を使います。古い NAS が他の方式に対応していない場合のみオンにしてください (セキュリティが弱い)",
|
||||||
|
"SMB 서명 · 암호화 필수": "SMB 署名・暗号化を必須",
|
||||||
"SMB 서버 \"{0}\" 을(를) 지울까요?": "SMB サーバー「{0}」を削除しますか?",
|
"SMB 서버 \"{0}\" 을(를) 지울까요?": "SMB サーバー「{0}」を削除しますか?",
|
||||||
"SMB 서버 고치기": "SMB サーバーを編集",
|
"SMB 서버 고치기": "SMB サーバーを編集",
|
||||||
"SMB 서버 설정이 없습니다 ({0})": "この SMB サーバーの設定がありません ({0})",
|
"SMB 서버 설정이 없습니다 ({0})": "この SMB サーバーの設定がありません ({0})",
|
||||||
|
|
@ -1502,6 +1503,8 @@
|
||||||
"새로고침": "再読み込み",
|
"새로고침": "再読み込み",
|
||||||
"색 보정": "カラー補正",
|
"색 보정": "カラー補正",
|
||||||
"색온도": "色温度",
|
"색온도": "色温度",
|
||||||
|
"서명 · 암호화가 되지 않는 연결이라 열지 않았습니다 (설정 \"SMB 서명 · 암호화 필수\"). NAS 에서 SMB 서명이나 SMB3 암호화를 켜거나, 설정을 끄세요": "署名も暗号化もされない接続のため開きませんでした (設定「SMB 署名・暗号化を必須」)。NAS で SMB 署名か SMB3 暗号化をオンにするか、設定をオフにしてください",
|
||||||
|
"서명도 암호화도 되지 않는 연결은 열지 않습니다 (중간에서 내용을 보거나 바꾸지 못하게). 오래된 NAS 는 붙지 않을 수 있습니다. 꺼 두어도 그런 서버는 탐색기 탭에 ⚠ 를 보입니다": "署名も暗号化もされない接続は開きません (途中で内容を見たり変えたりできないように)。古い NAS はつながらないことがあります。オフでもそのようなサーバーはエクスプローラーのタブに ⚠ を表示します",
|
||||||
"서버 ({0}) 가 답하지 않습니다": "サーバー ({0}) が応答しません",
|
"서버 ({0}) 가 답하지 않습니다": "サーバー ({0}) が応答しません",
|
||||||
"서버 ({0}) 에 연결할 수 없습니다. 서버가 켜져 있는지, 집 밖이면 Tailscale 이 켜져 있는지 확인하세요": "サーバー ({0}) に接続できません。サーバーの電源が入っているか、外出先なら Tailscale がオンか確認してください",
|
"서버 ({0}) 에 연결할 수 없습니다. 서버가 켜져 있는지, 집 밖이면 Tailscale 이 켜져 있는지 확인하세요": "サーバー ({0}) に接続できません。サーバーの電源が入っているか、外出先なら Tailscale がオンか確認してください",
|
||||||
"서버 ({0}) 에 연결할 수 없습니다: {1}": "サーバー ({0}) に接続できません: {1}",
|
"서버 ({0}) 에 연결할 수 없습니다: {1}": "サーバー ({0}) に接続できません: {1}",
|
||||||
|
|
@ -2008,6 +2011,7 @@
|
||||||
"이 설치본에는 rsync 가 들어 있지 않습니다. 직접 지정하세요.": "このインストールには rsync が含まれていません。直接指定してください。",
|
"이 설치본에는 rsync 가 들어 있지 않습니다. 직접 지정하세요.": "このインストールには rsync が含まれていません。直接指定してください。",
|
||||||
"이 앱 (APK) 에는 Tor 가 들어 있지 않습니다.": "このアプリ (APK) には Tor が入っていません。",
|
"이 앱 (APK) 에는 Tor 가 들어 있지 않습니다.": "このアプリ (APK) には Tor が入っていません。",
|
||||||
"이 언어 지우기": "この言語を削除",
|
"이 언어 지우기": "この言語を削除",
|
||||||
|
"이 연결은 서명도 암호화도 되지 않습니다 ({0}). 같은 네트워크의 다른 사람이 파일 내용을 보거나 바꿀 수 있으니, NAS 설정에서 SMB 서명이나 SMB3 암호화를 켜는 것이 좋습니다": "この接続は署名も暗号化もされません ({0})。同じネットワークの他の人がファイルの内容を見たり変えたりできるため、NAS の設定で SMB 署名か SMB3 暗号化をオンにすることをおすすめします",
|
||||||
"이 연결은 암호화되지 않습니다 ({0}). 같은 네트워크의 다른 사람이 파일 내용을 볼 수 있으니, NAS 설정에서 SMB3 암호화를 켜는 것이 좋습니다": "この接続は暗号化されていません ({0})。同じネットワークの他の人がファイルの内容を見られるため、NAS の設定で SMB3 暗号化をオンにすることをお勧めします",
|
"이 연결은 암호화되지 않습니다 ({0}). 같은 네트워크의 다른 사람이 파일 내용을 볼 수 있으니, NAS 설정에서 SMB3 암호화를 켜는 것이 좋습니다": "この接続は暗号化されていません ({0})。同じネットワークの他の人がファイルの内容を見られるため、NAS の設定で SMB3 暗号化をオンにすることをお勧めします",
|
||||||
"이 위치 (네트워크 드라이브 · 네트워크 공유 · USB 메모리 등) 에는 휴지통이 없어 바로 지워집니다.": "この場所 (ネットワークドライブ・ネットワーク共有・USB メモリなど) にはごみ箱がないため、すぐに削除されます。",
|
"이 위치 (네트워크 드라이브 · 네트워크 공유 · USB 메모리 등) 에는 휴지통이 없어 바로 지워집니다.": "この場所 (ネットワークドライブ・ネットワーク共有・USB メモリなど) にはごみ箱がないため、すぐに削除されます。",
|
||||||
"이 이동 버튼 지우기": "この移動ボタンを削除",
|
"이 이동 버튼 지우기": "この移動ボタンを削除",
|
||||||
|
|
|
||||||
|
|
@ -284,6 +284,7 @@
|
||||||
"SFTP 서버 추가": "添加 SFTP 服务器",
|
"SFTP 서버 추가": "添加 SFTP 服务器",
|
||||||
"SFTP 에서 받는 중": "正在从 SFTP 下载",
|
"SFTP 에서 받는 중": "正在从 SFTP 下载",
|
||||||
"SHA-1 서명 · 키 교환을 씁니다. 오래된 NAS 가 다른 방식을 모를 때만 켜세요 (보안이 약함)": "使用 SHA-1 签名和密钥交换。仅当旧 NAS 不支持其他方式时开启 (安全性较弱)",
|
"SHA-1 서명 · 키 교환을 씁니다. 오래된 NAS 가 다른 방식을 모를 때만 켜세요 (보안이 약함)": "使用 SHA-1 签名和密钥交换。仅当旧 NAS 不支持其他方式时开启 (安全性较弱)",
|
||||||
|
"SMB 서명 · 암호화 필수": "必须 SMB 签名或加密",
|
||||||
"SMB 서버 \"{0}\" 을(를) 지울까요?": "要删除 SMB 服务器\"{0}\"吗?",
|
"SMB 서버 \"{0}\" 을(를) 지울까요?": "要删除 SMB 服务器\"{0}\"吗?",
|
||||||
"SMB 서버 고치기": "编辑 SMB 服务器",
|
"SMB 서버 고치기": "编辑 SMB 服务器",
|
||||||
"SMB 서버 설정이 없습니다 ({0})": "没有此 SMB 服务器的设置 ({0})",
|
"SMB 서버 설정이 없습니다 ({0})": "没有此 SMB 服务器的设置 ({0})",
|
||||||
|
|
@ -1502,6 +1503,8 @@
|
||||||
"새로고침": "刷新",
|
"새로고침": "刷新",
|
||||||
"색 보정": "色彩校正",
|
"색 보정": "色彩校正",
|
||||||
"색온도": "色温",
|
"색온도": "色温",
|
||||||
|
"서명 · 암호화가 되지 않는 연결이라 열지 않았습니다 (설정 \"SMB 서명 · 암호화 필수\"). NAS 에서 SMB 서명이나 SMB3 암호화를 켜거나, 설정을 끄세요": "连接未签名也未加密,因此未打开 (设置“必须 SMB 签名或加密”)。请在 NAS 上开启 SMB 签名或 SMB3 加密,或关闭此设置",
|
||||||
|
"서명도 암호화도 되지 않는 연결은 열지 않습니다 (중간에서 내용을 보거나 바꾸지 못하게). 오래된 NAS 는 붙지 않을 수 있습니다. 꺼 두어도 그런 서버는 탐색기 탭에 ⚠ 를 보입니다": "不打开既未签名也未加密的连接 (防止中途查看或篡改内容)。旧的 NAS 可能无法连接。即使关闭,此类服务器也会在浏览器标签上显示 ⚠",
|
||||||
"서버 ({0}) 가 답하지 않습니다": "服务器 ({0}) 没有响应",
|
"서버 ({0}) 가 답하지 않습니다": "服务器 ({0}) 没有响应",
|
||||||
"서버 ({0}) 에 연결할 수 없습니다. 서버가 켜져 있는지, 집 밖이면 Tailscale 이 켜져 있는지 확인하세요": "无法连接服务器 ({0})。请确认服务器已开启;如果不在家,请确认 Tailscale 已开启",
|
"서버 ({0}) 에 연결할 수 없습니다. 서버가 켜져 있는지, 집 밖이면 Tailscale 이 켜져 있는지 확인하세요": "无法连接服务器 ({0})。请确认服务器已开启;如果不在家,请确认 Tailscale 已开启",
|
||||||
"서버 ({0}) 에 연결할 수 없습니다: {1}": "无法连接服务器 ({0}):{1}",
|
"서버 ({0}) 에 연결할 수 없습니다: {1}": "无法连接服务器 ({0}):{1}",
|
||||||
|
|
@ -2008,6 +2011,7 @@
|
||||||
"이 설치본에는 rsync 가 들어 있지 않습니다. 직접 지정하세요.": "此安装中没有 rsync。请自行指定。",
|
"이 설치본에는 rsync 가 들어 있지 않습니다. 직접 지정하세요.": "此安装中没有 rsync。请自行指定。",
|
||||||
"이 앱 (APK) 에는 Tor 가 들어 있지 않습니다.": "此应用 (APK) 未包含 Tor。",
|
"이 앱 (APK) 에는 Tor 가 들어 있지 않습니다.": "此应用 (APK) 未包含 Tor。",
|
||||||
"이 언어 지우기": "删除此语言",
|
"이 언어 지우기": "删除此语言",
|
||||||
|
"이 연결은 서명도 암호화도 되지 않습니다 ({0}). 같은 네트워크의 다른 사람이 파일 내용을 보거나 바꿀 수 있으니, NAS 설정에서 SMB 서명이나 SMB3 암호화를 켜는 것이 좋습니다": "此连接既未签名也未加密 ({0})。同一网络中的其他人可能查看或篡改文件内容,建议在 NAS 设置中开启 SMB 签名或 SMB3 加密",
|
||||||
"이 연결은 암호화되지 않습니다 ({0}). 같은 네트워크의 다른 사람이 파일 내용을 볼 수 있으니, NAS 설정에서 SMB3 암호화를 켜는 것이 좋습니다": "此连接未加密 ({0})。同一网络中的其他人可能看到文件内容,建议在 NAS 设置中开启 SMB3 加密",
|
"이 연결은 암호화되지 않습니다 ({0}). 같은 네트워크의 다른 사람이 파일 내용을 볼 수 있으니, NAS 설정에서 SMB3 암호화를 켜는 것이 좋습니다": "此连接未加密 ({0})。同一网络中的其他人可能看到文件内容,建议在 NAS 设置中开启 SMB3 加密",
|
||||||
"이 위치 (네트워크 드라이브 · 네트워크 공유 · USB 메모리 등) 에는 휴지통이 없어 바로 지워집니다.": "此位置 (网络驱动器 · 网络共享 · U 盘等) 没有回收站,会直接删除。",
|
"이 위치 (네트워크 드라이브 · 네트워크 공유 · USB 메모리 등) 에는 휴지통이 없어 바로 지워집니다.": "此位置 (网络驱动器 · 网络共享 · U 盘等) 没有回收站,会直接删除。",
|
||||||
"이 이동 버튼 지우기": "删除此移动按钮",
|
"이 이동 버튼 지우기": "删除此移动按钮",
|
||||||
|
|
|
||||||
|
|
@ -157,6 +157,7 @@ class AppController extends ChangeNotifier {
|
||||||
// 88: SFTP 서버 (sftp:// 경로)
|
// 88: SFTP 서버 (sftp:// 경로)
|
||||||
SftpRegistry.configure(settings.sftpServers);
|
SftpRegistry.configure(settings.sftpServers);
|
||||||
// 88: Android SMB 서버 (smb:// 경로)
|
// 88: Android SMB 서버 (smb:// 경로)
|
||||||
|
SmbRegistry.requireSecure = settings.smbRequireSecure;
|
||||||
SmbRegistry.configure(settings.smbServers);
|
SmbRegistry.configure(settings.smbServers);
|
||||||
FtpRegistry.configure(settings.ftpServers);
|
FtpRegistry.configure(settings.ftpServers);
|
||||||
outputRootOverride =
|
outputRootOverride =
|
||||||
|
|
|
||||||
|
|
@ -602,6 +602,10 @@ class AppSettings {
|
||||||
/// 88: Android SMB 서버 (smbj). 비밀번호는 안전 저장소에만. Windows 는 운영체제의 \서버\공유 를 쓴다
|
/// 88: Android SMB 서버 (smbj). 비밀번호는 안전 저장소에만. Windows 는 운영체제의 \서버\공유 를 쓴다
|
||||||
List<SmbServer> smbServers = [];
|
List<SmbServer> smbServers = [];
|
||||||
|
|
||||||
|
/// 보안 검토 6 (관리자 결정 10/18): SMB 서명이나 암호화가 되지 않는 연결은 막는다. 처음 끔 (오래된 NAS 가 붙지 않을 수 있어서).
|
||||||
|
/// 꺼 두어도 서명 · 암호화가 안 되는 서버는 탐색기 탭에 ⚠
|
||||||
|
bool smbRequireSecure = false;
|
||||||
|
|
||||||
/// l: FTP · FTPS 서버. 비밀번호는 안전 저장소에만 (설정 파일에는 평문으로 쓰지 않는다)
|
/// l: FTP · FTPS 서버. 비밀번호는 안전 저장소에만 (설정 파일에는 평문으로 쓰지 않는다)
|
||||||
List<FtpServer> ftpServers = [];
|
List<FtpServer> ftpServers = [];
|
||||||
|
|
||||||
|
|
@ -899,6 +903,7 @@ class AppSettings {
|
||||||
// 새 비밀 값은 안전 저장소에 못 써도 평문으로 쓰지 않는다 (사용자 결정 54) - plainSecrets 여도 넣지 않음
|
// 새 비밀 값은 안전 저장소에 못 써도 평문으로 쓰지 않는다 (사용자 결정 54) - plainSecrets 여도 넣지 않음
|
||||||
'sftpServers': [for (final x in sftpServers) x.toJson()],
|
'sftpServers': [for (final x in sftpServers) x.toJson()],
|
||||||
'smbServers': [for (final x in smbServers) x.toJson()],
|
'smbServers': [for (final x in smbServers) x.toJson()],
|
||||||
|
'smbRequireSecure': smbRequireSecure,
|
||||||
'ftpServers': [for (final x in ftpServers) x.toJson()],
|
'ftpServers': [for (final x in ftpServers) x.toJson()],
|
||||||
'rsyncOptions': rsyncOptions,
|
'rsyncOptions': rsyncOptions,
|
||||||
'robocopyOptions': robocopyOptions,
|
'robocopyOptions': robocopyOptions,
|
||||||
|
|
@ -1158,6 +1163,7 @@ class AppSettings {
|
||||||
for (final x in (j['sftpServers'] as List?) ?? const [])
|
for (final x in (j['sftpServers'] as List?) ?? const [])
|
||||||
if (x is Map) SftpServer.fromJson(x),
|
if (x is Map) SftpServer.fromJson(x),
|
||||||
]
|
]
|
||||||
|
..smbRequireSecure = j['smbRequireSecure'] is bool ? j['smbRequireSecure'] as bool : false
|
||||||
..smbServers = [
|
..smbServers = [
|
||||||
for (final x in (j['smbServers'] as List?) ?? const [])
|
for (final x in (j['smbServers'] as List?) ?? const [])
|
||||||
if (x is Map) SmbServer.fromJson(x),
|
if (x is Map) SmbServer.fromJson(x),
|
||||||
|
|
|
||||||
|
|
@ -83,7 +83,7 @@ class SmbServer {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
enum SmbFailure { unreachable, auth, noPassword, noShare, smb1, permission, notFound, other }
|
enum SmbFailure { unreachable, auth, noPassword, noShare, smb1, permission, notFound, insecure, other }
|
||||||
|
|
||||||
/// SMB 오류 - WebDAV 오류와 같은 곳에서 보이도록 [DavException] 을 잇는다
|
/// SMB 오류 - WebDAV 오류와 같은 곳에서 보이도록 [DavException] 을 잇는다
|
||||||
class SmbException extends DavException {
|
class SmbException extends DavException {
|
||||||
|
|
@ -113,6 +113,8 @@ class SmbRemote implements RemoteClient {
|
||||||
'user': server.user.trim(),
|
'user': server.user.trim(),
|
||||||
'domain': server.domain.trim(),
|
'domain': server.domain.trim(),
|
||||||
'password': server.password,
|
'password': server.password,
|
||||||
|
// 보안 검토 6: 서명 · 암호화가 안 되는 연결은 막기 (설정)
|
||||||
|
'requireSecure': SmbRegistry.requireSecure,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// 서버 기준 경로 → 공유 안의 경로 (시작 폴더 포함)
|
/// 서버 기준 경로 → 공유 안의 경로 (시작 폴더 포함)
|
||||||
|
|
@ -136,7 +138,16 @@ class SmbRemote implements RemoteClient {
|
||||||
throw SmbException(trf('서버 주소를 찾지 못했습니다 (DNS): {0}', [e]), SmbFailure.unreachable);
|
throw SmbException(trf('서버 주소를 찾지 못했습니다 (DNS): {0}', [e]), SmbFailure.unreachable);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
return await channel.invokeMethod<T>(method, {..._cfg, 'host': host, ...args});
|
final out = await channel.invokeMethod<T>(method, {..._cfg, 'host': host, ...args});
|
||||||
|
// 보안 검토 6: 처음 연결이 되면 서명 · 암호화되는지 한 번 물어 둔다 (탐색기 탭의 ⚠)
|
||||||
|
if (!_checked && method != 'info') {
|
||||||
|
_checked = true;
|
||||||
|
try {
|
||||||
|
final m = await channel.invokeMethod<Map<Object?, Object?>>('info', {..._cfg, 'host': host});
|
||||||
|
SmbRegistry._secure[server.id] = m?['encrypted'] == true || m?['signed'] == true;
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
} on PlatformException catch (e) {
|
} on PlatformException catch (e) {
|
||||||
throw _explain(e, rel);
|
throw _explain(e, rel);
|
||||||
} on MissingPluginException {
|
} on MissingPluginException {
|
||||||
|
|
@ -144,9 +155,14 @@ class SmbRemote implements RemoteClient {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool _checked = false;
|
||||||
|
|
||||||
SmbException _explain(PlatformException e, String rel) {
|
SmbException _explain(PlatformException e, String rel) {
|
||||||
final s = server;
|
final s = server;
|
||||||
return switch (e.code) {
|
return switch (e.code) {
|
||||||
|
'insecure' => SmbException(
|
||||||
|
tr('서명 · 암호화가 되지 않는 연결이라 열지 않았습니다 (설정 "SMB 서명 · 암호화 필수"). NAS 에서 SMB 서명이나 SMB3 암호화를 켜거나, 설정을 끄세요'),
|
||||||
|
SmbFailure.insecure),
|
||||||
'unreachable' => SmbException(
|
'unreachable' => SmbException(
|
||||||
trf('서버 ({0}) 에 연결할 수 없습니다. 서버가 켜져 있는지, 집 밖이면 Tailscale 이 켜져 있는지 확인하세요', [s.host]), SmbFailure.unreachable),
|
trf('서버 ({0}) 에 연결할 수 없습니다. 서버가 켜져 있는지, 집 밖이면 Tailscale 이 켜져 있는지 확인하세요', [s.host]), SmbFailure.unreachable),
|
||||||
'auth' => SmbException(tr('아이디나 비밀번호가 맞지 않습니다'), SmbFailure.auth),
|
'auth' => SmbException(tr('아이디나 비밀번호가 맞지 않습니다'), SmbFailure.auth),
|
||||||
|
|
@ -168,10 +184,12 @@ class SmbRemote implements RemoteClient {
|
||||||
size: m['dir'] == true ? 0 : (m['size'] as num?)?.toInt() ?? 0,
|
size: m['dir'] == true ? 0 : (m['size'] as num?)?.toInt() ?? 0,
|
||||||
modified: DateTime.fromMillisecondsSinceEpoch((m['mtime'] as num?)?.toInt() ?? 0));
|
modified: DateTime.fromMillisecondsSinceEpoch((m['mtime'] as num?)?.toInt() ?? 0));
|
||||||
|
|
||||||
/// 연결이 암호화되는지 · 어떤 SMB 판인지 (88-4: 암호화되지 않으면 화면이 경고)
|
/// 연결이 암호화 · 서명되는지 · 어떤 SMB 판인지 (88-4: 암호화되지 않으면 화면이 경고)
|
||||||
Future<({bool encrypted, String dialect})> info() async {
|
Future<({bool encrypted, bool signed, String dialect})> info() async {
|
||||||
final m = await _call<Map<Object?, Object?>>('info', const {}, '/');
|
final m = await _call<Map<Object?, Object?>>('info', const {}, '/');
|
||||||
return (encrypted: m?['encrypted'] == true, dialect: '${m?['dialect'] ?? ''}');
|
final encrypted = m?['encrypted'] == true, signed = m?['signed'] == true;
|
||||||
|
SmbRegistry._secure[server.id] = encrypted || signed;
|
||||||
|
return (encrypted: encrypted, signed: signed, dialect: '${m?['dialect'] ?? ''}');
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -299,14 +317,34 @@ class SmbRegistry {
|
||||||
static final _clients = <String, SmbRemote>{};
|
static final _clients = <String, SmbRemote>{};
|
||||||
static final _servers = <String, SmbServer>{};
|
static final _servers = <String, SmbServer>{};
|
||||||
|
|
||||||
|
/// 보안 검토 6: 서버 id → 지난 연결이 서명이나 암호화되었는지 (모르면 없음)
|
||||||
|
static final _secure = <String, bool>{};
|
||||||
|
|
||||||
|
/// 서명 · 암호화가 안 된 연결이었는지 (탐색기 탭의 ⚠). 아직 연결하지 않았으면 false
|
||||||
|
static bool insecure(String id) => _secure[id] == false;
|
||||||
|
|
||||||
|
/// 설정 "SMB 서명 · 암호화 필수" - 바뀌면 열려 있던 연결을 닫는다 (다음 연결부터 따르게)
|
||||||
|
static bool _requireSecure = false;
|
||||||
|
static bool get requireSecure => _requireSecure;
|
||||||
|
static set requireSecure(bool v) {
|
||||||
|
if (v == _requireSecure) return;
|
||||||
|
_requireSecure = v;
|
||||||
|
for (final c in _clients.values) {
|
||||||
|
c.close();
|
||||||
|
}
|
||||||
|
_clients.clear();
|
||||||
|
}
|
||||||
|
|
||||||
static void configure(List<SmbServer> servers) {
|
static void configure(List<SmbServer> servers) {
|
||||||
final keep = {for (final s in servers) s.id: s};
|
final keep = {for (final s in servers) s.id: s};
|
||||||
for (final id in _clients.keys.toList()) {
|
for (final id in _clients.keys.toList()) {
|
||||||
final old = _servers[id], now = keep[id];
|
final old = _servers[id], now = keep[id];
|
||||||
if (now == null || old == null || old.password != now.password || jsonEncode(old.toJson()) != jsonEncode(now.toJson())) {
|
if (now == null || old == null || old.password != now.password || jsonEncode(old.toJson()) != jsonEncode(now.toJson())) {
|
||||||
_clients.remove(id)?.close();
|
_clients.remove(id)?.close();
|
||||||
|
_secure.remove(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
_secure.removeWhere((id, _) => !keep.containsKey(id));
|
||||||
_servers
|
_servers
|
||||||
..clear()
|
..clear()
|
||||||
..addAll(keep);
|
..addAll(keep);
|
||||||
|
|
|
||||||
|
|
@ -103,7 +103,8 @@ class DavPath {
|
||||||
String get mark => isSftp
|
String get mark => isSftp
|
||||||
? '🔑'
|
? '🔑'
|
||||||
: isSmb
|
: isSmb
|
||||||
? '🗄'
|
// 보안 검토 6: 서명 · 암호화가 안 된 연결이면 ⚠
|
||||||
|
? (SmbRegistry.insecure(server) ? '⚠' : '🗄')
|
||||||
: isFtp
|
: isFtp
|
||||||
? (FtpRegistry.server(server)?.tls ?? true ? '📂' : '⚠')
|
? (FtpRegistry.server(server)?.tls ?? true ? '📂' : '⚠')
|
||||||
: '☁';
|
: '☁';
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,18 @@ class SmbSettings extends StatelessWidget {
|
||||||
label: Text(tr('추가')),
|
label: Text(tr('추가')),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
// 보안 검토 6 (관리자 결정 10/18): 처음 끔
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(left: 16),
|
||||||
|
child: SwitchListTile(
|
||||||
|
dense: true,
|
||||||
|
title: Text(tr('SMB 서명 · 암호화 필수')),
|
||||||
|
subtitle: Text(tr('서명도 암호화도 되지 않는 연결은 열지 않습니다 (중간에서 내용을 보거나 바꾸지 못하게). '
|
||||||
|
'오래된 NAS 는 붙지 않을 수 있습니다. 꺼 두어도 그런 서버는 탐색기 탭에 ⚠ 를 보입니다')),
|
||||||
|
value: c.settings.smbRequireSecure,
|
||||||
|
onChanged: (v) => c.updateSettings((x) => x.smbRequireSecure = v),
|
||||||
|
),
|
||||||
|
),
|
||||||
for (final s in servers)
|
for (final s in servers)
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(left: 16),
|
padding: const EdgeInsets.only(left: 16),
|
||||||
|
|
@ -150,8 +162,12 @@ Future<SmbServer?> editSmbServer(BuildContext context, AppController c, {SmbServ
|
||||||
result = trf('연결됨: 항목 {0}개{1}', [items.length, free == null ? '' : ' · ${tr('남은 용량')} ${(free / (1 << 30)).toStringAsFixed(1)}GB']);
|
result = trf('연결됨: 항목 {0}개{1}', [items.length, free == null ? '' : ' · ${tr('남은 용량')} ${(free / (1 << 30)).toStringAsFixed(1)}GB']);
|
||||||
// 88-4: 암호화는 허용 (서버가 되면 SMB3 암호화) - 안 되면 경고
|
// 88-4: 암호화는 허용 (서버가 되면 SMB3 암호화) - 안 되면 경고
|
||||||
if (!info.encrypted) {
|
if (!info.encrypted) {
|
||||||
warning = trf('이 연결은 암호화되지 않습니다 ({0}). 같은 네트워크의 다른 사람이 파일 내용을 볼 수 있으니, '
|
warning = info.signed
|
||||||
'NAS 설정에서 SMB3 암호화를 켜는 것이 좋습니다', [info.dialect]);
|
? trf('이 연결은 암호화되지 않습니다 ({0}). 같은 네트워크의 다른 사람이 파일 내용을 볼 수 있으니, '
|
||||||
|
'NAS 설정에서 SMB3 암호화를 켜는 것이 좋습니다', [info.dialect])
|
||||||
|
// 보안 검토 6: 서명도 안 되면 중간에서 바꿀 수도 있다
|
||||||
|
: trf('이 연결은 서명도 암호화도 되지 않습니다 ({0}). 같은 네트워크의 다른 사람이 파일 내용을 보거나 바꿀 수 있으니, '
|
||||||
|
'NAS 설정에서 SMB 서명이나 SMB3 암호화를 켜는 것이 좋습니다', [info.dialect]);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
passed = false;
|
passed = false;
|
||||||
|
|
|
||||||
|
|
@ -32,8 +32,10 @@ void main() {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
Future<void> settle(WidgetTester tester, bool Function() done, {int rounds = 300}) async {
|
// 횟수가 아니라 실제 시간으로 기다린다 (전체 시험이 함께 돌아 무거우면 paramiko 서버의 키 교환이 몇십 초 걸린다 - 관리자 지적 10/18)
|
||||||
for (var i = 0; i < rounds; i++) {
|
Future<void> settle(WidgetTester tester, bool Function() done, {Duration limit = const Duration(seconds: 90)}) async {
|
||||||
|
final end = DateTime.now().add(limit);
|
||||||
|
while (DateTime.now().isBefore(end)) {
|
||||||
await tester.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 30)));
|
await tester.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 30)));
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
if (done()) break;
|
if (done()) break;
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,8 @@ class _FakeSmb {
|
||||||
final calls = <String>[];
|
final calls = <String>[];
|
||||||
String? failWith;
|
String? failWith;
|
||||||
bool encrypted = true;
|
bool encrypted = true;
|
||||||
|
bool signed = false;
|
||||||
|
Map<Object?, Object?> lastArgs = const {};
|
||||||
final _open = <int, (String, bool)>{};
|
final _open = <int, (String, bool)>{};
|
||||||
var _next = 1;
|
var _next = 1;
|
||||||
|
|
||||||
|
|
@ -25,10 +27,11 @@ class _FakeSmb {
|
||||||
Future<Object?> handle(MethodCall call) async {
|
Future<Object?> handle(MethodCall call) async {
|
||||||
calls.add(call.method);
|
calls.add(call.method);
|
||||||
final a = (call.arguments as Map?) ?? const {};
|
final a = (call.arguments as Map?) ?? const {};
|
||||||
|
lastArgs = a;
|
||||||
if (failWith != null && call.method == 'list') throw PlatformException(code: failWith!, message: 'fake $failWith');
|
if (failWith != null && call.method == 'list') throw PlatformException(code: failWith!, message: 'fake $failWith');
|
||||||
switch (call.method) {
|
switch (call.method) {
|
||||||
case 'info':
|
case 'info':
|
||||||
return {'encrypted': encrypted, 'dialect': 'SMB_3_1_1'};
|
return {'encrypted': encrypted, 'signed': signed, 'dialect': 'SMB_3_1_1'};
|
||||||
case 'list':
|
case 'list':
|
||||||
final dir = _key(a['path']);
|
final dir = _key(a['path']);
|
||||||
final prefix = dir.isEmpty ? '' : '$dir\\';
|
final prefix = dir.isEmpty ? '' : '$dir\\';
|
||||||
|
|
@ -189,4 +192,39 @@ void main() {
|
||||||
expect(parseSmbAddress('CIFS://x'), ('x', '', ''));
|
expect(parseSmbAddress('CIFS://x'), ('x', '', ''));
|
||||||
expect(parseSmbAddress('/storage/x'), isNull);
|
expect(parseSmbAddress('/storage/x'), isNull);
|
||||||
});
|
});
|
||||||
|
test('보안 검토 6: 서명 · 암호화가 안 된 연결은 탐색기 탭에 ⚠ · 설정 "필수" 는 다리에 넘기고 막힌 이유를 알림', () async {
|
||||||
|
expect(AppSettings().smbRequireSecure, isFalse, reason: '처음 끔 (오래된 NAS)');
|
||||||
|
expect(AppSettings.fromJson(const {'smbRequireSecure': true}).smbRequireSecure, isTrue);
|
||||||
|
expect(AppSettings.fromJson(const {'smbRequireSecure': 'x'}).smbRequireSecure, isFalse);
|
||||||
|
SmbRegistry.configure(const [server]);
|
||||||
|
expect(DavPath.parse('smb://m1/').mark, '🗄', reason: '연결 전에는 모름');
|
||||||
|
fake
|
||||||
|
..encrypted = false
|
||||||
|
..signed = false;
|
||||||
|
await SmbRegistry.client('m1').list('/');
|
||||||
|
expect(fake.calls, ['list', 'info'], reason: '처음 한 번만 물어 둔다');
|
||||||
|
expect(SmbRegistry.insecure('m1'), isTrue);
|
||||||
|
expect(DavPath.parse('smb://m1/').mark, '⚠');
|
||||||
|
expect(fake.lastArgs['requireSecure'], isFalse);
|
||||||
|
await SmbRegistry.client('m1').list('/');
|
||||||
|
expect(fake.calls, ['list', 'info', 'list']);
|
||||||
|
// 서명만 되어도 ⚠ 는 없다
|
||||||
|
SmbRegistry.configure(const []);
|
||||||
|
SmbRegistry.configure(const [server]);
|
||||||
|
fake.signed = true;
|
||||||
|
await SmbRegistry.client('m1').list('/');
|
||||||
|
expect(SmbRegistry.insecure('m1'), isFalse);
|
||||||
|
// 설정 "필수" 를 켜면 다리에 넘기고, 다리가 막으면 이유를 알린다
|
||||||
|
SmbRegistry.requireSecure = true;
|
||||||
|
addTearDown(() => SmbRegistry.requireSecure = false);
|
||||||
|
fake.failWith = 'insecure';
|
||||||
|
Object? e;
|
||||||
|
try {
|
||||||
|
await SmbRegistry.client('m1').list('/');
|
||||||
|
} catch (x) {
|
||||||
|
e = x;
|
||||||
|
}
|
||||||
|
expect(fake.lastArgs['requireSecure'], isTrue);
|
||||||
|
expect(e, isA<SmbException>().having((x) => x.kind, 'kind', SmbFailure.insecure).having((x) => x.message, 'm', contains('SMB 서명 · 암호화 필수')));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -41,8 +41,19 @@ class SftpTestServer {
|
||||||
]);
|
]);
|
||||||
final lines = pr.stdout.transform(utf8.decoder).transform(const LineSplitter()).asBroadcastStream();
|
final lines = pr.stdout.transform(utf8.decoder).transform(const LineSplitter()).asBroadcastStream();
|
||||||
pr.stderr.drain<void>();
|
pr.stderr.drain<void>();
|
||||||
final port = int.parse((await lines.firstWhere((l) => l.startsWith('PORT '))).split(' ')[1]);
|
final port = int.parse((await lines.firstWhere((l) => l.startsWith('PORT ')).timeout(const Duration(seconds: 60))).split(' ')[1]);
|
||||||
final fp = (await lines.firstWhere((l) => l.startsWith('FINGERPRINT '))).split(' ')[1];
|
final fp = (await lines.firstWhere((l) => l.startsWith('FINGERPRINT ')).timeout(const Duration(seconds: 30))).split(' ')[1];
|
||||||
|
// 무거울 때도 포트가 연결을 받는 것을 본 뒤에 시작한다 (관리자 지적 10/18)
|
||||||
|
final end = DateTime.now().add(const Duration(seconds: 30));
|
||||||
|
while (true) {
|
||||||
|
try {
|
||||||
|
(await Socket.connect('127.0.0.1', port, timeout: const Duration(seconds: 2))).destroy();
|
||||||
|
break;
|
||||||
|
} on SocketException {
|
||||||
|
if (DateTime.now().isAfter(end)) rethrow;
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 200));
|
||||||
|
}
|
||||||
|
}
|
||||||
return SftpTestServer._(pr, port, fp);
|
return SftpTestServer._(pr, port, fp);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue