encrypted config handling

Signed-off-by: FTCHD <144691102+FTCHD@users.noreply.github.com>
This commit is contained in:
FTCHD
2025-09-12 19:15:39 +02:00
parent 9513083754
commit d083379b25
5 changed files with 374 additions and 90 deletions
+93 -52
View File
@@ -1,8 +1,8 @@
import * as Sentry from '@sentry/browser'
import { invoke } from '@tauri-apps/api/core'
import { BaseDirectory, appLocalDataDir, sep } from '@tauri-apps/api/path'
import { BaseDirectory, appLocalDataDir, appLogDir, sep } from '@tauri-apps/api/path'
import { tempDir } from '@tauri-apps/api/path'
import { ask, message } from '@tauri-apps/plugin-dialog'
import { message } from '@tauri-apps/plugin-dialog'
import { copyFile, exists, mkdir, readTextFile, remove } from '@tauri-apps/plugin-fs'
import { writeFile } from '@tauri-apps/plugin-fs'
import { fetch } from '@tauri-apps/plugin-http'
@@ -49,43 +49,6 @@ export async function initRclone(args: string[]) {
}
}
if (configFiles.length === 0) {
if (system) {
let isEncrypted = false
// Detect if the config is encrypted
try {
const configContent = await readTextFile(defaultPath)
isEncrypted = configContent.includes('RCLONE_ENCRYPT_V0:')
} catch (error) {
console.log('[initRclone] could not read config file, asking user:', error)
isEncrypted = await ask(
'Is your configuration encrypted? Press "No" if you\'re unsure or using the default config file.',
{
title: 'Config file found',
kind: 'info',
okLabel: 'Yes',
cancelLabel: 'No',
}
)
}
if (isEncrypted) {
await ask(
'Encrypted config files cannot be imported during the initial setup. Use a blank conf file and import the encrypted configuration later in Settings.',
{
title: 'Not supported yet',
kind: 'error',
okLabel: 'OK',
cancelLabel: '',
}
)
await exit(0)
return
}
}
}
configFiles = configFiles.filter((config) => config.id !== 'default')
configFiles.unshift({
id: 'default',
@@ -113,6 +76,8 @@ export async function initRclone(args: string[]) {
''
)
console.log('[initRclone] configFolderPath', configFolderPath)
if (activeConfigFile.sync) {
if (!(await exists(configFolderPath + sep() + 'rclone.conf'))) {
await message('The config file could not be found. Switching to the default config.', {
@@ -120,6 +85,7 @@ export async function initRclone(args: string[]) {
kind: 'error',
okLabel: 'OK',
})
activeConfigFile = configFiles[0]
configFolderPath = (await getConfigPath({ id: 'default', validate: true })).replace(
/\/rclone\.conf$/,
''
@@ -128,19 +94,71 @@ export async function initRclone(args: string[]) {
}
}
const extraParams =
activeConfigFile.id === 'default'
? undefined
: {
env: {
...(activeConfigFile.isEncrypted
? activeConfigFile.passCommand
? { RCLONE_CONFIG_PASS_COMMAND: activeConfigFile.passCommand }
: { RCLONE_CONFIG_PASS: activeConfigFile.pass! }
: {}),
RCLONE_CONFIG_DIR: configFolderPath,
},
}
let password: string | null = activeConfigFile.pass || activeConfigFile.passCommand || null
try {
const configContent = await readTextFile(`${configFolderPath}${sep()}rclone.conf`)
const isEncrypted = configContent.includes('RCLONE_ENCRYPT_V0:')
if (isEncrypted && !password) {
password = await promptForConfigPassword(activeConfigFile.label)
console.log('[initRclone] password', password)
if (!password) {
await message('Password is required for encrypted configurations.', {
title: 'Password Required',
kind: 'error',
okLabel: 'OK',
})
await exit(0)
return
}
if (!activeConfigFile.isEncrypted) {
const updatedConfigFile = { ...activeConfigFile, isEncrypted: true }
const updatedConfigFiles = configFiles.map((config) =>
config.id === activeConfigFile!.id ? updatedConfigFile : config
)
usePersistedStore.setState({
configFiles: updatedConfigFiles,
activeConfigFile: updatedConfigFile,
})
// Update activeConfigFile reference for the rest of the function
activeConfigFile = updatedConfigFile
}
}
} catch (error) {
console.log('[initRclone] could not read config file', error)
const appLogDirPath = await appLogDir()
await message(
'Could not read config file, please file an issue on GitHub.\n\nLogs: ' + appLogDirPath,
{
title: 'Error',
kind: 'error',
okLabel: 'OK',
}
)
await exit(0)
return
}
const extraParams: { env: Record<string, string> } = {
env: {},
}
if (activeConfigFile.isEncrypted) {
extraParams.env.RCLONE_ASK_PASSWORD = 'false'
if (activeConfigFile.passCommand) {
extraParams.env.RCLONE_CONFIG_PASS_COMMAND = activeConfigFile.passCommand
} else {
extraParams.env.RCLONE_CONFIG_PASS = activeConfigFile.pass || password!
}
}
if (activeConfigFile.id !== 'default') {
extraParams.env.RCLONE_CONFIG_DIR = configFolderPath
}
console.log('[initRclone] extraParams', extraParams)
if (system) {
console.log('[initRclone] running system rclone')
@@ -301,3 +319,26 @@ export async function provisionRclone() {
return true
}
/**
* Prompts the user for a password for an encrypted configuration
* @param configLabel - The label of the configuration file
* @returns Promise<string | null> - The password entered by the user, or null if cancelled
*/
async function promptForConfigPassword(configLabel: string): Promise<string | null> {
try {
const result = (await invoke('prompt_password', {
title: 'Rclone UI',
message: `Please enter the password for the encrypted configuration "${configLabel}".`,
})) as string | null
if (typeof result === 'string') {
return result.trim()
}
return null
} catch (error) {
console.error('[promptForConfigPassword] Error prompting for password:', error)
return null
}
}
+144 -1
View File
@@ -69,6 +69,149 @@ fn get_uid() -> String {
return machine_uid::get().unwrap();
}
#[tauri::command]
async fn prompt_password(title: String, message: String) -> Result<Option<String>, String> {
#[cfg(target_os = "macos")]
{
use std::process::Command;
let script = format!(
r#"display dialog "{}" with title "{}" default answer "" with hidden answer"#,
message.replace("\"", "\\\""),
title.replace("\"", "\\\"")
);
let output = Command::new("osascript")
.arg("-e")
.arg(&script)
.output()
.map_err(|e| e.to_string())?;
if output.status.success() {
let result = String::from_utf8_lossy(&output.stdout);
// Parse AppleScript result: "text returned:password, button returned:OK"
if let Some(password_part) = result.split("text returned:").nth(1) {
if let Some(password) = password_part.split(", button returned:").next() {
return Ok(Some(password.to_string()));
}
}
}
Ok(None)
}
#[cfg(target_os = "windows")]
{
use std::process::Command;
// Use PowerShell to create a credential dialog on Windows
let powershell_script = format!(
r#"
Add-Type -AssemblyName System.Windows.Forms
Add-Type -AssemblyName System.Drawing
$form = New-Object System.Windows.Forms.Form
$form.Text = '{}'
$form.Size = New-Object System.Drawing.Size(350, 200)
$form.StartPosition = 'CenterScreen'
$form.FormBorderStyle = 'FixedDialog'
$form.MaximizeBox = $false
$form.MinimizeBox = $false
$form.TopMost = $true
$label = New-Object System.Windows.Forms.Label
$label.Location = New-Object System.Drawing.Point(10, 20)
$label.Size = New-Object System.Drawing.Size(320, 40)
$label.Text = '{}'
$form.Controls.Add($label)
$textBox = New-Object System.Windows.Forms.TextBox
$textBox.Location = New-Object System.Drawing.Point(10, 70)
$textBox.Size = New-Object System.Drawing.Size(320, 20)
$textBox.UseSystemPasswordChar = $true
$form.Controls.Add($textBox)
$okButton = New-Object System.Windows.Forms.Button
$okButton.Location = New-Object System.Drawing.Point(175, 110)
$okButton.Size = New-Object System.Drawing.Size(75, 23)
$okButton.Text = 'OK'
$okButton.DialogResult = [System.Windows.Forms.DialogResult]::OK
$form.AcceptButton = $okButton
$form.Controls.Add($okButton)
$cancelButton = New-Object System.Windows.Forms.Button
$cancelButton.Location = New-Object System.Drawing.Point(255, 110)
$cancelButton.Size = New-Object System.Drawing.Size(75, 23)
$cancelButton.Text = 'Cancel'
$cancelButton.DialogResult = [System.Windows.Forms.DialogResult]::Cancel
$form.CancelButton = $cancelButton
$form.Controls.Add($cancelButton)
$form.Add_Shown({{$textBox.Select()}})
$result = $form.ShowDialog()
if ($result -eq [System.Windows.Forms.DialogResult]::OK) {{
$textBox.Text
}}
"#,
title.replace("'", "''"),
message.replace("'", "''")
);
let output = Command::new("powershell")
.args(&["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", &powershell_script])
.output()
.map_err(|e| e.to_string())?;
if output.status.success() {
let result = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !result.is_empty() {
return Ok(Some(result));
}
}
Ok(None)
}
#[cfg(target_os = "linux")]
{
// Try different methods for Linux
// First try zenity (most common)
if let Ok(output) = std::process::Command::new("zenity")
.args(&["--password", "--title", &title, "--text", &message])
.output()
{
if output.status.success() {
let result = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !result.is_empty() {
return Ok(Some(result));
}
}
}
// Fallback to kdialog (KDE)
if let Ok(output) = std::process::Command::new("kdialog")
.args(&["--password", &message, "--title", &title])
.output()
{
if output.status.success() {
let result = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !result.is_empty() {
return Ok(Some(result));
}
}
}
Err("No suitable password dialog found. Please install zenity or kdialog.".to_string())
}
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
{
Err("Password input not supported on this platform".to_string())
}
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let client = sentry::init((
@@ -106,7 +249,7 @@ pub fn run() {
.plugin(tauri_plugin_log::Builder::new().build())
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_opener::init())
.invoke_handler(tauri::generate_handler![unzip_file, get_arch, get_uid])
.invoke_handler(tauri::generate_handler![unzip_file, get_arch, get_uid, prompt_password])
.setup(|_app| Ok(()))
// .setup(|app| {
// if cfg!(debug_assertions) {
+59 -13
View File
@@ -5,6 +5,7 @@ import {
DrawerFooter,
DrawerHeader,
Input,
Switch,
Textarea,
} from '@heroui/react'
import { Button } from '@heroui/react'
@@ -28,6 +29,8 @@ export default function ConfigCreateDrawer({
label: 'New Config',
})
const [configContent, setConfigContent] = useState<string | null>(null)
const [isPasswordCommand, setIsPasswordCommand] = useState(false)
const [isSaving, setIsSaving] = useState(false)
const isEncrypted = useMemo(() => {
@@ -38,8 +41,18 @@ export default function ConfigCreateDrawer({
async ({
label,
pass,
passCommand,
content,
}: { label?: string; pass?: string; content: string | null }) => {
isPasswordCommand,
isEncrypted,
}: {
label?: string
pass?: string
passCommand?: string
content: string | null
isPasswordCommand: boolean
isEncrypted: boolean
}) => {
try {
if (!label) {
throw new Error('Label is required')
@@ -49,8 +62,8 @@ export default function ConfigCreateDrawer({
throw new Error('Content is required')
}
if (!pass && content.includes('RCLONE_ENCRYPT_V0:')) {
throw new Error('Password is required for encrypted configs')
if (isEncrypted && isPasswordCommand && !passCommand) {
throw new Error('Password command is required for encrypted configs')
}
setIsSaving(true)
@@ -68,10 +81,10 @@ export default function ConfigCreateDrawer({
usePersistedStore.getState().addConfigFile({
id: generatedId,
label,
pass,
isEncrypted: content.includes('RCLONE_ENCRYPT_V0:'),
pass: isPasswordCommand ? undefined : pass,
passCommand: isPasswordCommand ? passCommand : undefined,
isEncrypted: isEncrypted,
sync: undefined,
passCommand: undefined,
})
onClose()
@@ -113,7 +126,11 @@ export default function ConfigCreateDrawer({
handleCreate({
label: config.label,
pass: config.pass,
passCommand: config.passCommand,
content: configContent,
isPasswordCommand: isPasswordCommand,
isEncrypted:
configContent?.includes('RCLONE_ENCRYPT_V0:') || false,
})
}}
>
@@ -138,20 +155,49 @@ export default function ConfigCreateDrawer({
{isEncrypted && (
<Input
label="Password"
label={
<div className="flex items-center gap-1.5">
<p className="text-medium">Password</p>
<Switch
size="sm"
isSelected={isPasswordCommand}
onValueChange={() =>
setIsPasswordCommand(!isPasswordCommand)
}
color="primary"
>
Command
</Switch>
</div>
}
labelPlacement="outside"
placeholder="Enter the password for your config file"
type="password"
value={config.pass}
autoComplete="off"
placeholder={
isPasswordCommand
? 'Enter the password command for your config file'
: 'Leave blank to be prompted on every startup'
}
type={isPasswordCommand ? 'text' : 'password'}
value={isPasswordCommand ? config.passCommand : config.pass}
autoCapitalize="off"
autoComplete="off"
autoCorrect="off"
spellCheck="false"
onValueChange={(value) => {
setConfig({ ...config, pass: value })
setConfig({
...config,
...(isPasswordCommand
? { passCommand: value }
: { pass: value }),
})
}}
isClearable={true}
onClear={() => {
setConfig({ ...config, pass: '' })
setConfig({
...config,
...(isPasswordCommand
? { passCommand: '' }
: { pass: '' }),
})
}}
size="lg"
/>
+68 -13
View File
@@ -5,6 +5,7 @@ import {
DrawerFooter,
DrawerHeader,
Input,
Switch,
Textarea,
} from '@heroui/react'
import { Button } from '@heroui/react'
@@ -28,8 +29,10 @@ export default function ConfigEditDrawer({
const [configLabel, setConfigLabel] = useState<string | null>(null)
const [configPass, setConfigPass] = useState<string | null>(null)
const [configPassCommand, setConfigPassCommand] = useState<string | null>(null)
const [configContent, setConfigContent] = useState<string | null>(null)
const [isPasswordCommand, setIsPasswordCommand] = useState(false)
const [isSaving, setIsSaving] = useState(false)
const isEncrypted = useMemo(() => {
@@ -37,7 +40,21 @@ export default function ConfigEditDrawer({
}, [configContent])
const handleUpdate = useCallback(
async ({ label, pass, content }: { label?: string; pass?: string; content?: string }) => {
async ({
label,
pass,
content,
passCommand,
isPasswordCommand,
isEncrypted,
}: {
label?: string
pass?: string
content?: string
passCommand?: string
isPasswordCommand?: boolean
isEncrypted?: boolean
}) => {
if (!id) return
try {
@@ -49,8 +66,8 @@ export default function ConfigEditDrawer({
throw new Error('Content is required')
}
if (content.includes('RCLONE_ENCRYPT_V0:') && !pass) {
throw new Error('Password is required for encrypted configs')
if (isEncrypted && isPasswordCommand && !passCommand) {
throw new Error('Password command is required for encrypted configs')
}
setIsSaving(true)
@@ -60,8 +77,9 @@ export default function ConfigEditDrawer({
usePersistedStore.getState().updateConfigFile(id, {
label,
pass: pass || undefined,
isEncrypted: content.includes('RCLONE_ENCRYPT_V0:'),
pass: isPasswordCommand ? undefined : pass,
passCommand: isPasswordCommand ? passCommand : undefined,
isEncrypted: isEncrypted,
})
onClose()
@@ -92,6 +110,8 @@ export default function ConfigEditDrawer({
setConfigContent(text)
setConfigLabel(initialConfig.label)
setConfigPass(initialConfig.pass || null)
setConfigPassCommand(initialConfig.passCommand || null)
setIsPasswordCommand(initialConfig.passCommand !== null)
}, [initialConfig])
useEffect(() => {
@@ -103,6 +123,8 @@ export default function ConfigEditDrawer({
setConfigContent(null)
setConfigLabel(null)
setConfigPass(null)
setConfigPassCommand(null)
setIsPasswordCommand(false)
}
}, [isOpen, initializeConfig, configLabel, configContent])
@@ -134,6 +156,10 @@ export default function ConfigEditDrawer({
label: configLabel || undefined,
pass: configPass || undefined,
content: configContent || undefined,
passCommand: configPassCommand || undefined,
isPasswordCommand: isPasswordCommand,
isEncrypted:
configContent?.includes('RCLONE_ENCRYPT_V0:') || false,
})
}}
>
@@ -159,21 +185,50 @@ export default function ConfigEditDrawer({
{isEncrypted && (
<Input
name="label"
label="Password"
label={
<div className="flex items-center gap-1.5">
<p className="text-medium">Password</p>
<Switch
size="sm"
isSelected={isPasswordCommand}
onValueChange={() =>
setIsPasswordCommand(!isPasswordCommand)
}
color="primary"
>
Command
</Switch>
</div>
}
labelPlacement="outside"
placeholder="Enter the password for your config file"
type="password"
value={configPass || ''}
autoComplete="off"
placeholder={
isPasswordCommand
? 'Enter the password command for your config file'
: 'Leave blank to be prompted on every startup'
}
type={isPasswordCommand ? 'text' : 'password'}
value={
(isPasswordCommand ? configPassCommand : configPass) ||
''
}
autoCapitalize="off"
autoComplete="off"
autoCorrect="off"
spellCheck="false"
onValueChange={(value) => {
setConfigPass(value)
if (isPasswordCommand) {
setConfigPassCommand(value)
} else {
setConfigPass(value)
}
}}
isClearable={true}
onClear={() => {
setConfigPass(null)
if (isPasswordCommand) {
setConfigPassCommand(null)
} else {
setConfigPass(null)
}
}}
size="lg"
/>
+10 -11
View File
@@ -38,12 +38,14 @@ export default function ConfigSyncDrawer({
isEncrypted,
passCommand,
sync,
isPasswordCommand,
}: {
label?: string
sync?: string
isEncrypted?: boolean
pass?: string
passCommand?: string
isPasswordCommand?: boolean
}) => {
try {
if (!label) {
@@ -54,10 +56,8 @@ export default function ConfigSyncDrawer({
throw new Error('Path is required')
}
if (isEncrypted) {
if (!pass && !passCommand) {
throw new Error('Password is required for encrypted configs')
}
if (isEncrypted && isPasswordCommand && !passCommand) {
throw new Error('Password command is required for encrypted configs')
}
setIsSaving(true)
@@ -68,8 +68,8 @@ export default function ConfigSyncDrawer({
id: generatedId,
label,
isEncrypted: isEncrypted || false,
pass,
passCommand,
pass: isPasswordCommand ? undefined : pass,
passCommand: isPasswordCommand ? passCommand : undefined,
sync,
})
@@ -111,12 +111,11 @@ export default function ConfigSyncDrawer({
e.preventDefault()
handleCreate({
label: config.label,
pass: isPasswordCommand ? undefined : config.pass,
pass: config.pass,
isEncrypted: config.isEncrypted,
passCommand: isPasswordCommand
? config.passCommand
: undefined,
passCommand: config.passCommand,
sync: config.sync,
isPasswordCommand: isPasswordCommand,
})
}}
>
@@ -172,7 +171,7 @@ export default function ConfigSyncDrawer({
placeholder={
isPasswordCommand
? 'Enter the password command for your config file'
: 'Enter the password for your config file'
: 'Leave blank to be prompted on every startup'
}
type={isPasswordCommand ? 'text' : 'password'}
value={isPasswordCommand ? config.passCommand : config.pass}