diff --git a/gocryptfs/README.md b/gocryptfs/README.md new file mode 100644 index 0000000..cc4368f --- /dev/null +++ b/gocryptfs/README.md @@ -0,0 +1,105 @@ +# Gocryptfs + +Mount, unmount, initialize, and auto-mount [gocryptfs](https://github.com/rfjakob/gocryptfs) encrypted volumes from Noctalia — bar status, manager panel, and optional auto-mount after login. + +## Plugin + +| Field | Value | +| --- | --- | +| ID | `davemhammer/gocryptfs` | +| Entries | Bar widget: `status`; panel: `manager`; service: `service` | + +## Requirements + +Install these on `PATH` (declared in `plugin.toml` `dependencies`): + +- `gocryptfs` — mount and `gocryptfs -init` +- `fusermount3` or `fusermount` — FUSE unmount (first found wins) +- `keyctl` — kernel user-keyring session cache for remembered passwords (package `keyutils`) +- `secret-tool` — Freedesktop Secret Service client for reboot-persistent passwords (package `libsecret` / `libsecret-tools`) +- `chmod` — mode bits on short-lived temp password files +- `xdg-open` — open the mount point in the file manager +- `cat` — read `/proc/mounts` for mount status + +**Desktop keyring:** persistent “Remember” needs a Secret Service backend (GNOME Keyring, KeePassXC as Secret Service, etc.) running and unlocked after login. If only `keyctl` is available, remember still works for the current login session. + +## Usage + +Add the **status** bar widget from Settings → Bar (`davemhammer/gocryptfs:status`). + +- **Left-click** — open the manager panel +- **Right-click** — refresh mount status + +In the panel you can: + +- Select a volume → **Mount** / **Unmount** / **Open** (file manager via `xdg-open`) +- **Edit → Remember / Forget** — store or clear the volume password (desktop keyring + session cache) +- **Add** an existing cipher directory, or **Init** a new one (`gocryptfs -init`) +- Mount with a remembered keyring password, an optional advanced passfile path, or a one-shot password prompt (optional “also remember”) + +```sh +noctalia msg panel-toggle davemhammer/gocryptfs:manager +``` + +### Auto-mount on login + +Requires all of: + +1. Plugin setting **Auto-mount on login** (default on) +2. Per-volume **Auto-mount** enabled +3. A remembered keyring password (**Remember**) or an advanced passfile path + +After reboot, the desktop keyring must unlock (normal login) so `secret-tool` can supply the password. The kernel session key is refilled automatically on mount. + +## Settings + +| Setting | Type | Default | Description | +| --- | --- | --- | --- | +| `refresh_interval` | `int` | `3` | Seconds between `/proc/mounts` polls. | +| `notify_on_action` | `bool` | `true` | Notify after mount, unmount, init, remember, and forget. | +| `create_mountpoint` | `bool` | `true` | Create the mount directory if missing before mount. | +| `auto_mount` | `bool` | `true` | Global switch: on service start, queue volumes that have auto-mount + keyring/passfile. | +| `show_count` | `bool` (widget) | `true` | Show `mounted/total` on the bar. | +| `glyph_color` | `select` (widget) | `on_surface` | Lock icon color when nothing is mounted. | +| `mounted_color` | `select` (widget) | `tertiary` | Icon/dot color when at least one volume is mounted. | +| `unmounted_color` | `select` (widget) | `on_surface_variant` | Status-dot color when nothing is mounted. | + +## IPC + +```sh +noctalia msg panel-toggle davemhammer/gocryptfs:manager +noctalia msg plugin davemhammer/gocryptfs:service all refresh +noctalia msg plugin davemhammer/gocryptfs:service all reload +noctalia msg plugin davemhammer/gocryptfs:service all automount +``` + +- `refresh` — re-read `/proc/mounts` and refresh the snapshot +- `reload` — reload `volumes.json` from the plugin data dir, clear the auto-mount queue, then refresh +- `automount` — reset the auto-mount schedule and refresh (eligible volumes are queued again on the next status pass) + +## Notes + +### Data and filesystem + +- Volume definitions live under the plugin data directory as `volumes.json` (not inside the cipher directory). +- With **Create mount points** on, the service may `mkdir` the configured mount path before mounting. +- Cipher, mount, and passfile paths reject empty values, NUL, and `..` segments. Symlinks on those paths are followed by design (user-chosen paths). + +### Secrets (no long-lived plaintext under plugin data) + +**Remember password** does **not** write a long-lived password file under the plugin data dir. It stores the secret in: + +1. **Desktop keyring** via `secret-tool` — attributes `service=noctalia-gocryptfs`, `volume-id=`. Survives reboot while the login keyring is unlocked. +2. **Kernel session keyring** via `keyctl` — description `noctalia-gocryptfs:`. Fast cache for this login only; cleared on reboot/logout. + +On mount / auto-mount, the service prefers the session key; if missing, it hydrates from `secret-tool` into `keyctl`, then runs `gocryptfs -extpass keyctl pipe `. Fallback: `gocryptfs -extpass secret-tool lookup …`. + +- One-shot typed passwords use a short-lived file under `/dev/shm` (tmpfs) when available, then delete it. +- Optional **advanced** passfile paths remain supported for users who manage their own files (plaintext by user choice; not recommended). +- **Forget** and volume remove clear both the desktop keyring entry and the session key. +- Passwords are not logged. + +### Processes and network + +- Spawns: `gocryptfs`, `fusermount3` or `fusermount`, `keyctl`, `secret-tool`, `chmod`, `cat` (`/proc/mounts`), `xdg-open`. +- **Network:** none. diff --git a/gocryptfs/panel.luau b/gocryptfs/panel.luau new file mode 100644 index 0000000..d75cfda --- /dev/null +++ b/gocryptfs/panel.luau @@ -0,0 +1,878 @@ +--!nonstrict +-- Gocryptfs manager panel: list, mount/unmount, add/edit, init, password prompt. + +local STATE_KEY = "gocrypt_snapshot" +local COMMAND_KEY = "gocrypt_command" +local RESULT_KEY = "gocrypt_action_result" + +local snapshot = noctalia.state.get(STATE_KEY) or { + available = false, + loading = true, + busy = false, + volumes = {}, + mountedCount = 0, + totalCount = 0, + error = "", + updatedAt = 0, + revision = 0, +} + +local selectedId = "" +local requestCounter = 0 +local feedback = "" +local feedbackError = false +local dirty = true + +-- views: "list" | "form" | "password" +local view = "list" +local formMode = "add" -- add | edit | init +local formGeneration = 0 +local formName = "" +local formCipher = "" +local formMount = "" +local formPassfile = "" +local formAllowOther = false +local formReadOnly = false +local formAutoMount = false +local formPlaintextNames = false +local formAesSiv = false +local formSavePassfile = true +local formPassword = "" +local formPasswordConfirm = "" +local formPasswordKey = 0 +local formError = "" +local formEditId = "" + +local passwordVolumeId = "" +local passwordVolumeName = "" +local passwordValue = "" +local passwordKey = 0 +local passwordError = "" +-- "mount" | "store_keyring" +local passwordMode = "mount" +local passwordRememberKeyring = true + +local render + +local function tr(key, subst) + return noctalia.tr(key, subst) +end + +local function nextRequestId() + requestCounter += 1 + return `panel-{requestCounter}` +end + +local function sendCommand(action, values) + local command = { + action = action, + requestId = nextRequestId(), + } + if type(values) == "table" then + for key, value in pairs(values) do + command[key] = value + end + end + noctalia.state.set(COMMAND_KEY, command) + return command.requestId +end + +local function selectedVolume() + if selectedId == "" then + return nil + end + for _, vol in ipairs(snapshot.volumes or {}) do + if vol.id == selectedId then + return vol + end + end + return nil +end + +local function resetForm() + formName = "" + formCipher = "" + formMount = "" + formPassfile = "" + formAllowOther = false + formReadOnly = false + formAutoMount = true + formPlaintextNames = false + formAesSiv = false + formSavePassfile = true + formPassword = "" + formPasswordConfirm = "" + formPasswordKey += 1 + formError = "" + formEditId = "" + formGeneration += 1 +end + +local function fillFormFrom(vol) + formName = tostring(vol.name or "") + formCipher = tostring(vol.cipherDir or "") + formMount = tostring(vol.mountPoint or "") + formPassfile = tostring(vol.passfile or "") + formAllowOther = vol.allowOther == true + formReadOnly = vol.readOnly == true + formAutoMount = vol.autoMount == true + formPlaintextNames = false + formAesSiv = false + formSavePassfile = false + formPassword = "" + formPasswordConfirm = "" + formPasswordKey += 1 + formError = "" + formEditId = tostring(vol.id or "") + formGeneration += 1 +end + +local function statusColor(vol) + if vol.mounted then + return "tertiary" + end + if vol.cipherExists == false or vol.initialized == false then + return "error" + end + return "on_surface_variant" +end + +local function volumeCard(vol) + local selected = vol.id == selectedId + local statusText = vol.mounted and tr("panel.status.mounted") or tr("panel.status.unmounted") + local summary = `{vol.name} · {statusText} · {vol.mountPoint}` + + return ui.button({ + key = vol.id, + text = summary, + glyph = vol.mounted and "lock-open" or "lock", + contentAlign = "start", + variant = selected and "primary" or "outline", + selected = selected, + onClick = function() + selectedId = vol.id + feedback = "" + render() + end, + }) +end + +local function selectionToolbar() + local vol = selectedVolume() + if vol == nil then + return ui.label({ text = tr("panel.select_hint"), color = "on_surface_variant" }) + end + + local mounted = vol.mounted == true + local busy = snapshot.busy == true + local hasPassfile = type(vol.passfile) == "string" and vol.passfile ~= "" + local useKeyring = vol.useKeyring == true + local hints = {} + if useKeyring then + table.insert(hints, tr("panel.keyring_hint")) + elseif hasPassfile then + table.insert(hints, tr("panel.passfile_hint")) + end + if vol.autoMount == true and (useKeyring or hasPassfile) then + table.insert(hints, tr("panel.automount_hint")) + end + + local buttons = { + ui.button({ + text = mounted and tr("actions.unmount") or tr("actions.mount"), + glyph = mounted and "lock" or "lock-open", + variant = "primary", + enabled = not busy and snapshot.available == true, + onClick = "onToggleMount", + }), + ui.button({ + text = tr("actions.open"), + glyph = "folder-open", + variant = "outline", + enabled = not busy, + onClick = "onOpenMount", + }), + ui.button({ + text = tr("actions.edit"), + glyph = "edit", + variant = "outline", + enabled = not busy and not mounted, + onClick = "onEdit", + }), + ui.button({ + text = tr("actions.remove"), + glyph = "trash", + variant = "destructive", + enabled = not busy and not mounted, + onClick = "onRemove", + }), + } + + return ui.column({ gap = 4, padding = 10, fill = "surface_variant/0.45", radius = 10 }, { + ui.row({ gap = 8, align = "center" }, { + ui.glyph({ name = mounted and "lock-open" or "lock", size = 18, color = statusColor(vol) }), + ui.label({ text = tostring(vol.name), fontWeight = "bold", flexGrow = 1, maxLines = 1 }), + ui.label({ + text = mounted and tr("panel.status.mounted") or tr("panel.status.unmounted"), + color = statusColor(vol), + fontSize = 12, + }), + }), + ui.label({ + text = tr("panel.cipher", { path = vol.cipherDir }), + color = "on_surface_variant", + fontSize = 12, + maxLines = 1, + }), + ui.label({ + text = tr("panel.mountpoint", { path = vol.mountPoint }), + color = "on_surface_variant", + fontSize = 12, + maxLines = 1, + }), + ui.label({ + text = table.concat(hints, " · "), + color = "on_surface_variant", + fontSize = 11, + visible = #hints > 0, + }), + ui.row({ gap = 6, align = "center" }, buttons), + }) +end + +local function volumeList() + local vols = snapshot.volumes or {} + if #vols == 0 then + return ui.column({ align = "center", padding = 24, gap = 8 }, { + ui.glyph({ name = "lock", size = 42, color = "on_surface_variant" }), + ui.label({ text = tr("panel.empty"), color = "on_surface_variant", textAlign = "center" }), + }) + end + local rows = {} + for _, vol in ipairs(vols) do + table.insert(rows, volumeCard(vol)) + end + return ui.column({ gap = 8 }, rows) +end + +local function formTitle() + if formMode == "edit" then + return tr("panel.edit_title") + end + if formMode == "init" then + return tr("panel.init_title") + end + return tr("panel.add_title") +end + +local function formView() + local isInit = formMode == "init" + local children = { + ui.row({ gap = 8, align = "center" }, { + ui.label({ text = formTitle(), fontSize = 16, fontWeight = "bold", flexGrow = 1 }), + ui.button({ glyph = "close", onClick = "onCancelForm" }), + }), + ui.label({ + text = isInit and tr("panel.init_help") or "", + color = "on_surface_variant", + fontSize = 12, + visible = isInit, + }), + ui.label({ text = tr("panel.field.name"), color = "on_surface_variant" }), + ui.input({ + key = `form-name-{formGeneration}`, + value = formName, + placeholder = tr("panel.field.name_placeholder"), + onChange = "onFormName", + }), + ui.label({ text = tr("panel.field.cipher"), color = "on_surface_variant" }), + ui.input({ + key = `form-cipher-{formGeneration}`, + value = formCipher, + placeholder = tr("panel.field.cipher_placeholder"), + onChange = "onFormCipher", + }), + ui.label({ text = tr("panel.field.mount"), color = "on_surface_variant" }), + ui.input({ + key = `form-mount-{formGeneration}`, + value = formMount, + placeholder = tr("panel.field.mount_placeholder"), + onChange = "onFormMount", + }), + } + + if isInit then + table.insert(children, ui.label({ text = tr("panel.field.password"), color = "on_surface_variant" })) + table.insert(children, ui.input({ + key = `form-pw-{formGeneration}-{formPasswordKey}`, + value = "", + placeholder = tr("panel.password_placeholder"), + password = true, + onChange = "onFormPassword", + })) + table.insert(children, ui.label({ text = tr("panel.field.password_confirm"), color = "on_surface_variant" })) + table.insert(children, ui.input({ + key = `form-pw2-{formGeneration}-{formPasswordKey}`, + value = "", + placeholder = tr("panel.field.password_confirm_placeholder"), + password = true, + onChange = "onFormPasswordConfirm", + })) + table.insert(children, ui.row({ gap = 10, align = "center" }, { + ui.toggle({ checked = formPlaintextNames, onChange = "onFormPlaintextNames" }), + ui.label({ text = tr("panel.field.plaintextnames"), flexGrow = 1 }), + })) + table.insert(children, ui.row({ gap = 10, align = "center" }, { + ui.toggle({ checked = formAesSiv, onChange = "onFormAesSiv" }), + ui.label({ text = tr("panel.field.aessiv"), flexGrow = 1 }), + })) + table.insert(children, ui.row({ gap = 10, align = "center" }, { + ui.toggle({ checked = formSavePassfile, onChange = "onFormSavePassfile" }), + ui.label({ text = tr("panel.field.save_passfile"), flexGrow = 1 }), + })) + table.insert(children, ui.row({ gap = 10, align = "center", visible = formSavePassfile }, { + ui.toggle({ checked = formAutoMount, onChange = "onFormAutoMount" }), + ui.label({ text = tr("panel.field.auto_mount"), flexGrow = 1 }), + })) + else + -- Edit existing volume: keyring remember/forget + advanced passfile + local editVol = selectedVolume() + local editHasKeyring = editVol and editVol.useKeyring == true + table.insert(children, ui.label({ + text = tr("panel.field.keyring_section"), + color = "on_surface_variant", + fontWeight = "bold", + })) + table.insert(children, ui.label({ + text = editHasKeyring and tr("panel.keyring_hint") or tr("panel.keyring_not_set"), + color = editHasKeyring and "tertiary" or "on_surface_variant", + fontSize = 12, + })) + table.insert(children, ui.row({ gap = 8, align = "center" }, { + ui.button({ + text = tr("actions.remember"), + glyph = "key", + variant = "outline", + enabled = snapshot.busy ~= true, + onClick = "onRememberKeyring", + }), + ui.button({ + text = tr("actions.forget"), + glyph = "key-off", + variant = "destructive", + enabled = snapshot.busy ~= true and editHasKeyring == true, + onClick = "onForgetKeyring", + }), + })) + table.insert(children, ui.label({ + text = tr("panel.keyring_help"), + color = "on_surface_variant", + fontSize = 11, + maxLines = 4, + })) + table.insert(children, ui.label({ text = tr("panel.field.passfile"), color = "on_surface_variant" })) + table.insert(children, ui.input({ + key = `form-passfile-{formGeneration}`, + value = formPassfile, + placeholder = tr("panel.field.passfile_placeholder"), + onChange = "onFormPassfile", + })) + table.insert(children, ui.row({ gap = 10, align = "center" }, { + ui.toggle({ checked = formAutoMount, onChange = "onFormAutoMount" }), + ui.label({ text = tr("panel.field.auto_mount"), flexGrow = 1 }), + })) + table.insert(children, ui.label({ + text = tr("panel.field.auto_mount_help"), + color = "on_surface_variant", + fontSize = 11, + })) + end + + table.insert(children, ui.row({ gap = 10, align = "center" }, { + ui.toggle({ checked = formAllowOther, onChange = "onFormAllowOther" }), + ui.label({ text = tr("panel.field.allow_other"), flexGrow = 1 }), + })) + table.insert(children, ui.row({ gap = 10, align = "center" }, { + ui.toggle({ checked = formReadOnly, onChange = "onFormReadOnly" }), + ui.label({ text = tr("panel.field.read_only"), flexGrow = 1 }), + })) + table.insert(children, ui.label({ text = formError, color = "error", visible = formError ~= "" })) + table.insert(children, ui.row({ justify = "end", gap = 8 }, { + ui.button({ text = tr("actions.cancel"), variant = "outline", onClick = "onCancelForm" }), + ui.button({ + text = isInit and tr("actions.init") or tr("actions.save"), + glyph = isInit and "shield-lock" or "check", + variant = "primary", + enabled = snapshot.busy ~= true, + onClick = "onSaveForm", + }), + })) + + return ui.scroll({ flexGrow = 1, gap = 10 }, children) +end + +local function passwordView() + local isStore = passwordMode == "store_keyring" + local title = isStore + and tr("panel.keyring_title", { name = passwordVolumeName }) + or tr("panel.password_title", { name = passwordVolumeName }) + local confirmText = isStore and tr("actions.remember") or tr("actions.mount") + local confirmGlyph = isStore and "key" or "lock-open" + local children = { + ui.row({ gap = 8, align = "center" }, { + ui.label({ + text = title, + fontSize = 16, + fontWeight = "bold", + flexGrow = 1, + }), + ui.button({ glyph = "close", onClick = "onCancelPassword" }), + }), + ui.label({ + text = isStore and tr("panel.keyring_label") or tr("panel.password_label"), + color = "on_surface_variant", + }), + ui.input({ + key = "pw_" .. tostring(passwordKey), + value = "", + placeholder = tr("panel.password_placeholder"), + password = true, + focus = true, + onChange = "onPasswordChange", + onSubmit = "onConfirmPassword", + }), + } + if not isStore then + table.insert(children, ui.row({ gap = 10, align = "center" }, { + ui.toggle({ checked = passwordRememberKeyring, onChange = "onPasswordRemember" }), + ui.label({ text = tr("panel.remember_keyring"), flexGrow = 1 }), + })) + else + table.insert(children, ui.label({ + text = tr("panel.keyring_help"), + color = "on_surface_variant", + fontSize = 11, + maxLines = 4, + })) + end + table.insert(children, ui.label({ text = passwordError, color = "error", visible = passwordError ~= "" })) + table.insert(children, ui.row({ justify = "end", gap = 8 }, { + ui.button({ text = tr("actions.cancel"), variant = "outline", onClick = "onCancelPassword" }), + ui.button({ + text = confirmText, + glyph = confirmGlyph, + variant = "primary", + enabled = snapshot.busy ~= true, + onClick = "onConfirmPassword", + }), + })) + return ui.column({ flexGrow = 1, gap = 12 }, children) +end + +local function listView() + local statusRows = {} + if snapshot.loading == true then + table.insert(statusRows, ui.label({ text = tr("panel.loading"), color = "on_surface_variant" })) + end + if snapshot.busy == true then + table.insert(statusRows, ui.label({ text = tr("panel.busy"), color = "primary" })) + end + if type(snapshot.error) == "string" and snapshot.error ~= "" then + table.insert(statusRows, ui.label({ text = snapshot.error, color = "error", maxLines = 2 })) + end + if feedback ~= "" then + table.insert(statusRows, ui.label({ + text = feedback, + color = feedbackError and "error" or "tertiary", + maxLines = 2, + })) + end + + return ui.column({ flexGrow = 1, gap = 10 }, { + selectionToolbar(), + ui.column({ gap = 3 }, statusRows), + ui.scroll({ flexGrow = 1, gap = 8 }, { volumeList() }), + }) +end + +render = function() + dirty = false + + local content + if view == "form" then + content = formView() + elseif view == "password" then + content = passwordView() + else + content = listView() + end + + panel.render(ui.column({ flexGrow = 1, gap = 10 }, { + ui.row({ align = "center", gap = 8 }, { + ui.glyph({ + name = "lock", + size = 24, + color = snapshot.available and "primary" or "on_surface_variant", + }), + ui.column({ flexGrow = 1, gap = 0 }, { + ui.label({ text = tr("title"), fontSize = 18, fontWeight = "bold" }), + ui.label({ + text = tr("panel.subtitle"), + fontSize = 11, + color = "on_surface_variant", + }), + }), + ui.button({ + text = tr("actions.init"), + glyph = "shield-lock", + variant = "outline", + visible = view == "list", + onClick = "onInit", + }), + ui.button({ + text = tr("actions.add"), + glyph = "plus", + variant = "outline", + visible = view == "list", + onClick = "onAdd", + }), + ui.button({ + text = tr("actions.refresh"), + glyph = "refresh", + variant = "outline", + visible = view == "list", + onClick = "onRefresh", + }), + ui.button({ glyph = "close", onClick = "onCloseClicked" }), + }), + content, + ui.label({ + text = (snapshot.updatedAt or 0) > 0 + and tr("panel.updated", { time = noctalia.formatTime("%H:%M:%S", snapshot.updatedAt) }) + or "", + color = "on_surface_variant", + fontSize = 11, + visible = view == "list", + }), + })) +end + +noctalia.state.watch(STATE_KEY, function(value) + if type(value) ~= "table" then + return + end + -- Always re-render on snapshot publish. Mounted flags live inside + -- volumes[] and must update the list immediately after mount/unmount. + snapshot = value + if selectedId ~= "" and selectedVolume() == nil then + selectedId = "" + end + dirty = true +end) + +noctalia.state.watch(RESULT_KEY, function(result) + if type(result) ~= "table" then + return + end + if type(result.requestId) ~= "string" or not result.requestId:match("^panel%-") then + return + end + feedback = tostring(result.message or "") + feedbackError = result.ok ~= true + if result.ok == true and ( + result.action == "add_volume" + or result.action == "update_volume" + or result.action == "init_volume" + ) then + view = "list" + resetForm() + end + if result.ok == true and result.action == "mount" then + view = "list" + passwordValue = "" + passwordError = "" + end + if result.ok ~= true and result.action == "mount" and view == "password" then + passwordError = feedback + passwordKey += 1 + passwordValue = "" + end + if result.ok ~= true and result.action == "init_volume" and view == "form" then + formError = feedback + formPassword = "" + formPasswordConfirm = "" + formPasswordKey += 1 + end + dirty = true +end) + +panel.setWantsSecondTicks(true) + +function onOpen(_context) + view = "list" + feedback = "" + sendCommand("refresh") + render() +end + +function update() + if dirty then + render() + end +end + +function onCloseClicked() + panel.close() +end + +function onRefresh() + sendCommand("refresh") +end + +function onAdd() + formMode = "add" + resetForm() + formAutoMount = false + view = "form" + render() +end + +function onInit() + formMode = "init" + resetForm() + formAutoMount = true + formSavePassfile = true + view = "form" + render() +end + +function onEdit() + local vol = selectedVolume() + if not vol then + return + end + formMode = "edit" + fillFormFrom(vol) + view = "form" + render() +end + +function onCancelForm() + view = "list" + formError = "" + formPassword = "" + formPasswordConfirm = "" + render() +end + +function onFormName(value) formName = value end +function onFormCipher(value) formCipher = value end +function onFormMount(value) formMount = value end +function onFormPassfile(value) formPassfile = value end +function onFormPassword(value) formPassword = if type(value) == "string" then value else "" end +function onFormPasswordConfirm(value) formPasswordConfirm = if type(value) == "string" then value else "" end + +function onFormAllowOther(value) + formAllowOther = value == "true" + render() +end +function onFormReadOnly(value) + formReadOnly = value == "true" + render() +end +function onFormAutoMount(value) + formAutoMount = value == "true" + render() +end +function onFormPlaintextNames(value) + formPlaintextNames = value == "true" + render() +end +function onFormAesSiv(value) + formAesSiv = value == "true" + render() +end +function onFormSavePassfile(value) + formSavePassfile = value == "true" + if not formSavePassfile then + formAutoMount = false + end + render() +end + +function onSaveForm() + local name = noctalia.string.trim(formName) + local cipher = noctalia.string.trim(formCipher) + local mount = noctalia.string.trim(formMount) + if name == "" or cipher == "" or mount == "" then + formError = tr("panel.field.required") + render() + return + end + + if formMode == "init" then + if formPassword == "" then + formError = tr("panel.password_required") + render() + return + end + if formPassword ~= formPasswordConfirm then + formError = tr("panel.field.password_mismatch") + formPassword = "" + formPasswordConfirm = "" + formPasswordKey += 1 + render() + return + end + formError = "" + sendCommand("init_volume", { + name = name, + cipherDir = cipher, + mountPoint = mount, + password = formPassword, + plaintextNames = formPlaintextNames, + aesSiv = formAesSiv, + savePassfile = formSavePassfile, + autoMount = formAutoMount and formSavePassfile, + allowOther = formAllowOther, + readOnly = formReadOnly, + }) + formPassword = "" + formPasswordConfirm = "" + formPasswordKey += 1 + render() + return + end + + formError = "" + local payload = { + name = name, + cipherDir = cipher, + mountPoint = mount, + passfile = noctalia.string.trim(formPassfile), + allowOther = formAllowOther, + readOnly = formReadOnly, + autoMount = formAutoMount, + } + if formMode == "edit" then + payload.id = formEditId + sendCommand("update_volume", payload) + else + sendCommand("add_volume", payload) + end + render() +end + +function onRemove() + local vol = selectedVolume() + if not vol then + return + end + sendCommand("remove_volume", { id = vol.id }) +end + +-- Named onOpenMount so it does not override the panel lifecycle onOpen(). +function onOpenMount() + local vol = selectedVolume() + if not vol then + return + end + sendCommand("open", { id = vol.id }) +end + +local function openPasswordPrompt(vol, mode) + passwordVolumeId = vol.id + passwordVolumeName = vol.name + passwordValue = "" + passwordError = "" + passwordMode = mode or "mount" + passwordRememberKeyring = true + passwordKey += 1 + view = "password" + render() +end + +function onToggleMount() + local vol = selectedVolume() + if not vol then + return + end + if vol.mounted then + sendCommand("unmount", { id = vol.id }) + return + end + -- Prefer keyring or existing passfile without prompting. + if vol.useKeyring == true or (type(vol.passfile) == "string" and vol.passfile ~= "") then + sendCommand("mount", { id = vol.id }) + return + end + openPasswordPrompt(vol, "mount") +end + +function onRememberKeyring() + local vol = selectedVolume() + if not vol then + return + end + -- Keep edit context: after save, return to list via cancel/result + openPasswordPrompt(vol, "store_keyring") +end + +function onForgetKeyring() + local vol = selectedVolume() + if not vol then + return + end + sendCommand("forget_keyring", { id = vol.id }) + -- Stay on edit form; snapshot watch will refresh useKeyring flag + render() +end + +function onPasswordChange(value) + passwordValue = if type(value) == "string" then value else "" +end + +function onPasswordRemember(value) + passwordRememberKeyring = value == true or value == "true" +end + +function onCancelPassword() + passwordValue = "" + passwordError = "" + passwordMode = "mount" + view = "list" + render() +end + +function onConfirmPassword() + if snapshot.busy then + return + end + if passwordValue == "" then + passwordError = tr("panel.password_required") + render() + return + end + local pw = passwordValue + local mode = passwordMode + local id = passwordVolumeId + passwordValue = "" + passwordError = "" + passwordMode = "mount" + if mode == "store_keyring" then + sendCommand("store_keyring", { + id = id, + password = pw, + enableAutoMount = true, + }) + else + sendCommand("mount", { + id = id, + password = pw, + storeKeyring = passwordRememberKeyring == true, + }) + end + passwordKey += 1 + view = "list" + render() +end diff --git a/gocryptfs/plugin.toml b/gocryptfs/plugin.toml new file mode 100644 index 0000000..02e2fcf --- /dev/null +++ b/gocryptfs/plugin.toml @@ -0,0 +1,106 @@ +# Manage gocryptfs encrypted volumes: mount, unmount, and status. + +id = "davemhammer/gocryptfs" +name = "Gocryptfs" +version = "1.3.0" +plugin_api = 10 +author = "davemhammer" +license = "MIT" +dependencies = ["gocryptfs", "fusermount3", "fusermount", "chmod", "xdg-open", "cat", "keyctl", "secret-tool"] +tags = ["privacy", "utility", "bar", "panel", "service"] +icon = "lock" +description = "Mount, unmount, and auto-mount gocryptfs encrypted volumes from Noctalia." + +[[setting]] +key = "refresh_interval" +type = "int" +label_key = "settings.refresh_interval.label" +description_key = "settings.refresh_interval.description" +default = 3 +min = 1 +max = 60 + +[[setting]] +key = "notify_on_action" +type = "bool" +label_key = "settings.notify_on_action.label" +description_key = "settings.notify_on_action.description" +default = true + +[[setting]] +key = "create_mountpoint" +type = "bool" +label_key = "settings.create_mountpoint.label" +description_key = "settings.create_mountpoint.description" +default = true + +[[setting]] +key = "auto_mount" +type = "bool" +label_key = "settings.auto_mount.label" +description_key = "settings.auto_mount.description" +default = true + +[[widget]] +id = "status" +entry = "widget.luau" + + [[widget.setting]] + key = "show_count" + type = "bool" + label_key = "settings.show_count.label" + description_key = "settings.show_count.description" + default = true + + [[widget.setting]] + key = "glyph_color" + type = "select" + label_key = "settings.glyph_color.label" + description_key = "settings.glyph_color.description" + default = "on_surface" + options = [ + { value = "on_surface", label_key = "colors.default" }, + { value = "primary", label_key = "colors.primary" }, + { value = "secondary", label_key = "colors.secondary" }, + { value = "tertiary", label_key = "colors.tertiary" } + ] + + [[widget.setting]] + key = "mounted_color" + type = "select" + label_key = "settings.mounted_color.label" + description_key = "settings.mounted_color.description" + default = "tertiary" + options = [ + { value = "primary", label_key = "colors.primary" }, + { value = "secondary", label_key = "colors.secondary" }, + { value = "tertiary", label_key = "colors.tertiary" } + ] + + [[widget.setting]] + key = "unmounted_color" + type = "select" + label_key = "settings.unmounted_color.label" + description_key = "settings.unmounted_color.description" + default = "on_surface_variant" + options = [ + { value = "error", label_key = "colors.error" }, + { value = "on_surface_variant", label_key = "colors.muted" }, + { value = "primary", label_key = "colors.primary" }, + { value = "tertiary", label_key = "colors.tertiary" } + ] + +[[panel]] +id = "manager" +entry = "panel.luau" +width = 540 +height = 620 +placement = "floating" +position = "center" +open_near_click = true +keyboard_focus = "exclusive" +dismiss_on_outside_click = true + +[[service]] +id = "service" +entry = "service.luau" diff --git a/gocryptfs/service.luau b/gocryptfs/service.luau new file mode 100644 index 0000000..eab4d7d --- /dev/null +++ b/gocryptfs/service.luau @@ -0,0 +1,1609 @@ +--!nonstrict +-- Gocryptfs backend. Owns volume config, mount status, init, and mount/unmount. +-- Other entries talk to this service through noctalia.state only. + +local VOLUMES_FILE = "volumes.json" +local STATE_KEY = "gocrypt_snapshot" +local COMMAND_KEY = "gocrypt_command" +local RESULT_KEY = "gocrypt_action_result" + +local snapshot = { + available = false, + loading = true, + busy = false, + volumes = {}, + mountedCount = 0, + totalCount = 0, + error = "", + updatedAt = 0, + revision = 0, +} + +local refreshGeneration = 0 +local refreshPending = false +local refreshAgain = false +local actionBusy = false +local dataSignature = "" +local volumes = {} -- array of volume tables +local nextId = 1 +local autoMountScheduled = false +local autoMountQueue = {} + +local function trim(value) + return noctalia.string.trim(tostring(value or "")) +end + +local function shellQuote(value) + return "'" .. tostring(value):gsub("'", "'\\''") .. "'" +end + +local function shellCommand(args) + local quoted = {} + for _, value in ipairs(args) do + table.insert(quoted, shellQuote(value)) + end + return table.concat(quoted, " ") +end + +local function expand(path) + return noctalia.expandPath(trim(path)) +end + +-- Reject path traversal after expand. Volume paths are user-chosen (often absolute); +-- symlinks are followed by design. Empty paths, NUL, and ".." segments are refused. +local function isSafeFsPath(path) + path = expand(path) + if path == "" then + return false, "empty path" + end + if path:find("\0", 1, true) then + return false, "invalid path" + end + -- Reject .. as a path segment (//foo/../bar, /tmp/../etc, relative ../x, etc.) + for seg in (path:gsub("\\", "/") .. "/"):gmatch("([^/]*)/") do + if seg == ".." then + return false, "path must not contain .." + end + end + return true, path +end + +local function volumesPath() + local dir = noctalia.pluginDataDir() + if not dir then + return nil + end + return dir .. "/" .. VOLUMES_FILE +end + +local function passfilesDir() + local dir = noctalia.pluginDataDir() + if not dir then + return nil + end + local path = dir .. "/passfiles" + noctalia.mkdirAll(path) + return path +end + +local function newVolumeId() + local id = "vol-" .. tostring(os.time()) .. "-" .. tostring(nextId) + nextId += 1 + return id +end + +local function normalizeVolume(raw) + if type(raw) ~= "table" then + return nil + end + local name = trim(raw.name) + local cipherDir = trim(raw.cipherDir or raw.cipher_dir) + local mountPoint = trim(raw.mountPoint or raw.mount_point) + if name == "" or cipherDir == "" or mountPoint == "" then + return nil + end + local passfile = trim(raw.passfile) + local useKeyring = raw.useKeyring == true or raw.use_keyring == true + local autoMount = raw.autoMount + if autoMount == nil then + autoMount = raw.auto_mount + end + if autoMount == nil then + -- default: auto-mount when keyring or passfile is configured + autoMount = useKeyring or passfile ~= "" + else + autoMount = autoMount == true + end + return { + id = trim(raw.id) ~= "" and trim(raw.id) or newVolumeId(), + name = name, + cipherDir = cipherDir, + mountPoint = mountPoint, + passfile = passfile, + useKeyring = useKeyring, + allowOther = raw.allowOther == true or raw.allow_other == true, + readOnly = raw.readOnly == true or raw.read_only == true, + autoMount = autoMount, + } +end + +local function loadVolumes() + local path = volumesPath() + volumes = {} + if not path then + return + end + local raw = noctalia.readFile(path) + if not raw or raw == "" then + return + end + local decoded, err = noctalia.json.decode(raw) + if type(decoded) ~= "table" then + noctalia.log(`gocryptfs: could not parse volumes.json: {err or "unknown"}`) + return + end + local list = decoded.volumes + if type(list) ~= "table" then + return + end + for _, item in ipairs(list) do + local vol = normalizeVolume(item) + if vol then + table.insert(volumes, vol) + end + end +end + +local function saveVolumes() + local path = volumesPath() + if not path then + return false, "no plugin data dir" + end + local payload = { volumes = volumes } + local encoded, err = noctalia.json.encode(payload, true) + if not encoded then + return false, err or "encode failed" + end + local ok, writeErr = noctalia.writeFile(path, encoded) + if not ok then + return false, writeErr or "write failed" + end + return true +end + +local function findVolume(id) + id = trim(id) + for _, vol in ipairs(volumes) do + if vol.id == id then + return vol + end + end + return nil +end + +local function pathEqual(a, b) + a = expand(a) + b = expand(b) + a = a:gsub("/+$", "") + b = b:gsub("/+$", "") + if a == "" then a = "/" end + if b == "" then b = "/" end + return a == b +end + +local function parseMounts(stdout) + local mounts = {} + for line in (tostring(stdout or "") .. "\n"):gmatch("(.-)\n") do + line = trim(line) + if line ~= "" then + local source, target, fstype = line:match("^(%S+)%s+(%S+)%s+(%S+)") + if fstype and (fstype == "fuse.gocryptfs" or fstype:find("gocryptfs", 1, true)) then + local function unescape(s) + return (s:gsub("\\(%d%d%d)", function(oct) + return string.char(tonumber(oct, 8)) + end)) + end + mounts[unescape(target)] = unescape(source) + end + end + end + return mounts +end + +local function isMounted(vol, mounts) + local mp = expand(vol.mountPoint) + mp = mp:gsub("/+$", "") + if mp == "" then mp = "/" end + if mounts[mp] then + return true + end + for target, _ in pairs(mounts) do + if pathEqual(target, vol.mountPoint) then + return true + end + end + return false +end + +local function publishSnapshot() + snapshot.busy = actionBusy + snapshot.totalCount = #volumes + snapshot.volumes = {} + local mounted = 0 + for _, vol in ipairs(volumes) do + local entry = { + id = vol.id, + name = vol.name, + cipherDir = vol.cipherDir, + mountPoint = vol.mountPoint, + passfile = vol.passfile, + useKeyring = vol.useKeyring == true, + allowOther = vol.allowOther, + readOnly = vol.readOnly, + autoMount = vol.autoMount == true, + mounted = vol.mounted == true, + cipherExists = vol.cipherExists == true, + initialized = vol.initialized == true, + } + if entry.mounted then + mounted += 1 + end + table.insert(snapshot.volumes, entry) + end + snapshot.mountedCount = mounted + noctalia.state.set(STATE_KEY, snapshot) +end + +local function updateRevision(signature) + if signature ~= dataSignature then + dataSignature = signature + snapshot.revision += 1 + end +end + +local function refreshIntervalMs() + local seconds = tonumber(noctalia.getConfig("refresh_interval")) or 3 + seconds = math.max(1, math.min(60, math.floor(seconds))) + return seconds * 1000 +end + +local function shouldNotify() + return noctalia.getConfig("notify_on_action") ~= false +end + +local function autoMountEnabled() + return noctalia.getConfig("auto_mount") ~= false +end + +local refreshAll +local mountVolume +local kickAutoMount + +local refreshStartedAt = 0 + +refreshAll = function() + -- Recover if an in-flight /proc/mounts callback never completed. + if refreshPending and refreshStartedAt > 0 and (os.time() - refreshStartedAt) >= 15 then + noctalia.log("gocryptfs: forcing stuck refresh reset") + refreshPending = false + refreshStartedAt = 0 + snapshot.loading = false + end + + if refreshPending then + refreshAgain = true + return + end + refreshPending = true + refreshAgain = false + refreshStartedAt = os.time() + refreshGeneration += 1 + local generation = refreshGeneration + + if not noctalia.commandExists("gocryptfs") then + snapshot.available = false + snapshot.loading = false + snapshot.error = noctalia.tr("result.gocryptfs_missing") + for _, vol in ipairs(volumes) do + vol.mounted = false + vol.cipherExists = noctalia.fileExists(expand(vol.cipherDir)) + vol.initialized = noctalia.fileExists(expand(vol.cipherDir) .. "/gocryptfs.conf") + end + refreshPending = false + refreshStartedAt = 0 + updateRevision("gocryptfs-missing") + publishSnapshot() + return + end + + snapshot.available = true + -- Avoid flashing "Checking mounts…" on every poll when we already have data. + if (snapshot.updatedAt or 0) == 0 then + snapshot.loading = true + publishSnapshot() + end + + local started = noctalia.runAsync("cat /proc/mounts", function(result) + if generation ~= refreshGeneration then + return + end + local mounts = {} + if result and result.exitCode == 0 then + mounts = parseMounts(result.stdout) + snapshot.error = "" + else + snapshot.error = trim(result and result.stderr) + if snapshot.error == "" then + snapshot.error = "could not read /proc/mounts" + end + end + + local sigParts = {} + for _, vol in ipairs(volumes) do + vol.mounted = isMounted(vol, mounts) + local cipher = expand(vol.cipherDir) + vol.cipherExists = noctalia.fileExists(cipher) + vol.initialized = noctalia.fileExists(cipher .. "/gocryptfs.conf") + table.insert(sigParts, vol.id .. ":" .. tostring(vol.mounted) .. ":" .. vol.mountPoint) + end + + snapshot.loading = false + snapshot.updatedAt = os.time() + refreshPending = false + refreshStartedAt = 0 + updateRevision(table.concat(sigParts, "|") .. "|" .. tostring(#volumes)) + publishSnapshot() + + if not autoMountScheduled then + autoMountScheduled = true + if autoMountEnabled() then + for _, vol in ipairs(volumes) do + if vol.autoMount and not vol.mounted + and (vol.useKeyring == true or trim(vol.passfile) ~= "") + then + table.insert(autoMountQueue, vol.id) + end + end + if #autoMountQueue > 0 then + noctalia.log(`gocryptfs: auto-mount queue has {#autoMountQueue} volume(s)`) + kickAutoMount() + end + end + end + + if refreshAgain then + refreshAgain = false + refreshAll() + end + end) + + if not started then + snapshot.loading = false + snapshot.error = "could not start status check" + refreshPending = false + refreshStartedAt = 0 + publishSnapshot() + end +end + +local function actionResult(command, ok, message, extra) + local result = { + requestId = command and command.requestId or "", + action = command and command.action or "", + ok = ok, + message = message or "", + } + if type(extra) == "table" then + for key, value in pairs(extra) do + result[key] = value + end + end + noctalia.state.set(RESULT_KEY, result) +end + +local function notifyOk(message) + if shouldNotify() then + noctalia.notify(noctalia.tr("title"), message) + end +end + +local function notifyErr(message) + noctalia.notifyError(noctalia.tr("title"), message) +end + +local function unmountBinary() + if noctalia.commandExists("fusermount3") then + return "fusermount3" + end + if noctalia.commandExists("fusermount") then + return "fusermount" + end + return nil +end + +local function finishAction(command, ok, message) + actionBusy = false + local silent = command and command.silent == true + actionResult(command, ok, message) + if not silent then + if ok then + notifyOk(message) + else + notifyErr(message) + end + elseif not ok then + -- still surface auto-mount failures + notifyErr(message) + end + -- Always bump revision so panel/widget re-render even if mount flags + -- were updated optimistically to the same shape as a prior sample. + updateRevision( + "action:" + .. tostring(command and command.action or "") + .. ":" + .. tostring(command and command.id or "") + .. ":" + .. tostring(ok) + .. ":" + .. tostring(os.time()) + .. ":" + .. tostring(snapshot.mountedCount) + ) + publishSnapshot() + -- Confirm against /proc/mounts (may be briefly stale; optimistic flags already set). + refreshPending = false + refreshAgain = false + refreshAll() + if #autoMountQueue > 0 then + kickAutoMount() + end +end + +local function ensureDir(path) + local okPath, resolved = isSafeFsPath(path) + if not okPath then + return false, resolved or "unsafe path" + end + path = resolved + if noctalia.fileExists(path) then + local info = noctalia.fileInfo(path) + if info and info.isDir then + return true + end + return false, "not a directory" + end + local ok, err = noctalia.mkdirAll(path) + if not ok then + return false, err or "mkdir failed" + end + return true +end + +local function ensureMountPoint(path) + local okPath, resolved = isSafeFsPath(path) + if not okPath then + return false, resolved or "unsafe path" + end + path = resolved + if noctalia.fileExists(path) then + local info = noctalia.fileInfo(path) + if info and info.isDir then + return true + end + return false, "not a directory" + end + if noctalia.getConfig("create_mountpoint") == false then + return false, "does not exist" + end + return ensureDir(path) +end + +local function removePassfile(path) + if path and path ~= "" then + noctalia.removeFile(path) + end +end + +-- Short-lived password material on tmpfs when possible (never long-term secret storage). +local function tempPassPath(suffix) + local base = "/dev/shm" + if not noctalia.fileExists(base) then + base = noctalia.pluginDataDir() or "/tmp" + end + return base .. "/noctalia-gocryptfs-" .. tostring(suffix) +end + +local function writeSecurePassfile(path, password) + local written, werr = noctalia.writeFile(path, password .. "\n") + if not written then + return false, werr or "write failed" + end + noctalia.runAsync(shellCommand({ "chmod", "600", path })) + return true +end + +-- Kernel user-keyring description (session cache; payload does not survive reboot). +local function keyringDesc(volumeId) + return "noctalia-gocryptfs:" .. tostring(volumeId or "") +end + +-- Freedesktop Secret Service attributes (GNOME Keyring / KeePassXC / etc.). +local SECRET_SERVICE = "noctalia-gocryptfs" + +local function keyctlAvailable() + return noctalia.commandExists("keyctl") +end + +local function secretToolAvailable() + return noctalia.commandExists("secret-tool") +end + +-- Store password in @u keyring (replaces any prior key with same description). +-- Password is written only to a tmpfs temp file, then loaded via keyctl padd stdin. +local function storeSessionKeyring(volumeId, password, callback) + if type(callback) ~= "function" then + callback = function() end + end + if not keyctlAvailable() then + callback(false, "keyctl not found") + return + end + local desc = keyringDesc(volumeId) + if desc == "noctalia-gocryptfs:" or password == nil or password == "" then + callback(false, "invalid keyring store") + return + end + local tmp = tempPassPath("kr-" .. tostring(volumeId) .. "-" .. tostring(os.time())) + local written, werr = noctalia.writeFile(tmp, password) + if not written then + callback(false, werr or "temp write failed") + return + end + noctalia.runAsync(shellCommand({ "chmod", "600", tmp }), function() + local cmd = "OLD=$(keyctl search @u user " + .. shellQuote(desc) + .. " 2>/dev/null); " + .. "[ -n \"$OLD\" ] && keyctl unlink \"$OLD\" @u 2>/dev/null; " + .. "keyctl padd user " + .. shellQuote(desc) + .. " @u < " + .. shellQuote(tmp) + .. "; EC=$?; rm -f " + .. shellQuote(tmp) + .. "; exit $EC" + noctalia.runAsync(cmd, function(result) + removePassfile(tmp) + local ok = result ~= nil and result.exitCode == 0 and not result.timedOut + if ok then + callback(true, nil) + else + local err = trim(result and (result.stderr ~= "" and result.stderr or result.stdout) or "keyctl padd failed") + callback(false, err) + end + end, 10000) + end) +end + +-- Persist password via libsecret (secret-tool → desktop keyring). Survives reboot +-- once the login keyring is unlocked. +local function storePersistentPassword(volumeId, password, callback) + if type(callback) ~= "function" then + callback = function() end + end + if not secretToolAvailable() then + callback(false, "secret-tool not found") + return + end + local vid = tostring(volumeId or "") + if vid == "" or password == nil or password == "" then + callback(false, "invalid persistent store") + return + end + local tmp = tempPassPath("sec-" .. vid .. "-" .. tostring(os.time())) + local written, werr = noctalia.writeFile(tmp, password) + if not written then + callback(false, werr or "temp write failed") + return + end + local label = "noctalia-gocryptfs:" .. vid + noctalia.runAsync(shellCommand({ "chmod", "600", tmp }), function() + -- Clear any previous entry first so store does not leave duplicates. + local cmd = "secret-tool clear service " + .. shellQuote(SECRET_SERVICE) + .. " volume-id " + .. shellQuote(vid) + .. " 2>/dev/null; " + .. "secret-tool store --label=" + .. shellQuote(label) + .. " service " + .. shellQuote(SECRET_SERVICE) + .. " volume-id " + .. shellQuote(vid) + .. " < " + .. shellQuote(tmp) + .. "; EC=$?; rm -f " + .. shellQuote(tmp) + .. "; exit $EC" + noctalia.runAsync(cmd, function(result) + removePassfile(tmp) + local ok = result ~= nil and result.exitCode == 0 and not result.timedOut + if ok then + callback(true, nil) + else + local err = trim(result and (result.stderr ~= "" and result.stderr or result.stdout) or "secret-tool store failed") + callback(false, err) + end + end, 30000) + end) +end + +-- Store in session keyctl and, when available, desktop Secret Service. +-- Succeeds if at least one backend works (prefer both). +local function storeKeyringPassword(volumeId, password, callback) + if type(callback) ~= "function" then + callback = function() end + end + local desc = keyringDesc(volumeId) + if desc == "noctalia-gocryptfs:" or password == nil or password == "" then + callback(false, "invalid keyring store") + return + end + if not keyctlAvailable() and not secretToolAvailable() then + callback(false, "neither keyctl nor secret-tool found") + return + end + + local sessionDone = false + local persistDone = false + local sessionOk, sessionErr = false, nil + local persistOk, persistErr = false, nil + + local function maybeFinish() + if not sessionDone or not persistDone then + return + end + if sessionOk or persistOk then + if not persistOk and secretToolAvailable() and persistErr then + noctalia.log(`gocryptfs: secret-tool store failed (session key still set): {persistErr}`) + end + if not sessionOk and keyctlAvailable() and sessionErr then + noctalia.log(`gocryptfs: keyctl store failed (persistent secret still set): {sessionErr}`) + end + callback(true, nil) + else + callback(false, sessionErr or persistErr or "keyring store failed") + end + end + + if keyctlAvailable() then + storeSessionKeyring(volumeId, password, function(ok, err) + sessionOk = ok + sessionErr = err + sessionDone = true + maybeFinish() + end) + else + sessionDone = true + maybeFinish() + end + + if secretToolAvailable() then + storePersistentPassword(volumeId, password, function(ok, err) + persistOk = ok + persistErr = err + persistDone = true + maybeFinish() + end) + else + persistDone = true + maybeFinish() + end +end + +local function unlinkSessionKeyring(volumeId, callback) + if type(callback) ~= "function" then + callback = function() end + end + if not keyctlAvailable() then + callback(true) + return + end + local desc = keyringDesc(volumeId) + local cmd = "OLD=$(keyctl search @u user " + .. shellQuote(desc) + .. " 2>/dev/null); " + .. "[ -n \"$OLD\" ] && keyctl unlink \"$OLD\" @u 2>/dev/null; exit 0" + noctalia.runAsync(cmd, function() + callback(true) + end, 5000) +end + +local function clearPersistentPassword(volumeId, callback) + if type(callback) ~= "function" then + callback = function() end + end + if not secretToolAvailable() then + callback(true) + return + end + local vid = tostring(volumeId or "") + if vid == "" then + callback(true) + return + end + local cmd = "secret-tool clear service " + .. shellQuote(SECRET_SERVICE) + .. " volume-id " + .. shellQuote(vid) + .. " 2>/dev/null; exit 0" + noctalia.runAsync(cmd, function() + callback(true) + end, 15000) +end + +-- Clear session key + desktop keyring entry. +local function unlinkKeyringPassword(volumeId, callback) + if type(callback) ~= "function" then + callback = function() end + end + local left = 2 + local function done() + left -= 1 + if left <= 0 then + callback(true) + end + end + unlinkSessionKeyring(volumeId, done) + clearPersistentPassword(volumeId, done) +end + +-- Shell prelude: ensure session key exists, hydrating from secret-tool when needed. +-- Sets shell var K to key id, or exits 2 if no password material is available. +local function ensureSessionKeyShell(volumeId) + local desc = keyringDesc(volumeId) + local vid = tostring(volumeId or "") + return "K=$(keyctl search @u user " + .. shellQuote(desc) + .. " 2>/dev/null) || true; " + .. "if [ -z \"$K\" ] && command -v secret-tool >/dev/null 2>&1 && command -v keyctl >/dev/null 2>&1; then " + .. "secret-tool lookup service " + .. shellQuote(SECRET_SERVICE) + .. " volume-id " + .. shellQuote(vid) + .. " 2>/dev/null | keyctl padd user " + .. shellQuote(desc) + .. " @u >/dev/null 2>&1 || true; " + .. "K=$(keyctl search @u user " + .. shellQuote(desc) + .. " 2>/dev/null) || true; " + .. "fi; " + .. "if [ -z \"$K\" ]; then exit 2; fi; " +end + +-- Legacy managed plaintext path (no longer created; still cleaned on remove). +local function defaultPassfilePath(volumeId) + local dir = passfilesDir() + if not dir then + return nil + end + return dir .. "/" .. volumeId .. ".pass" +end + +local function canAutoMount(vol) + if not vol or vol.mounted then + return false + end + if not vol.autoMount then + return false + end + return vol.useKeyring == true or trim(vol.passfile) ~= "" +end + +mountVolume = function(command) + local vol = findVolume(command.id) + if not vol then + actionResult(command, false, noctalia.tr("result.not_found")) + kickAutoMount() + return + end + if vol.mounted then + actionResult(command, false, noctalia.tr("result.already_mounted", { name = vol.name })) + kickAutoMount() + return + end + + local okCipher, cipherOrErr = isSafeFsPath(vol.cipherDir) + if not okCipher then + local msg = noctalia.tr("result.failed", { error = cipherOrErr or "unsafe cipher path" }) + actionResult(command, false, msg) + notifyErr(msg) + kickAutoMount() + return + end + local cipher = cipherOrErr + local okMountPath, mountOrErr = isSafeFsPath(vol.mountPoint) + if not okMountPath then + local msg = noctalia.tr("result.failed", { error = mountOrErr or "unsafe mount path" }) + actionResult(command, false, msg) + notifyErr(msg) + kickAutoMount() + return + end + local mount = mountOrErr + + if not noctalia.fileExists(cipher) then + local msg = noctalia.tr("result.cipher_missing", { path = cipher }) + actionResult(command, false, msg) + if command.silent ~= true then + notifyErr(msg) + else + notifyErr(msg) + end + kickAutoMount() + return + end + + local okMp, mpErr = ensureMountPoint(mount) + if not okMp then + local msg = noctalia.tr("result.mount_create_failed", { path = mount }) .. " (" .. tostring(mpErr) .. ")" + actionResult(command, false, msg) + notifyErr(msg) + kickAutoMount() + return + end + + local args = { "gocryptfs", "-q" } + if vol.readOnly then + table.insert(args, "-ro") + end + if vol.allowOther then + table.insert(args, "-allow_other") + end + + local tempPassfile = nil + local passfile = trim(vol.passfile) + local password = type(command.password) == "string" and command.password or "" + local useKeyring = vol.useKeyring == true + local storeKeyring = command.storeKeyring == true or useKeyring + local mountCmd = nil -- full shell when using keyring (search + extpass) + + if password ~= "" then + -- One-shot: password via tmpfs temp passfile (deleted after mount). + tempPassfile = tempPassPath("pass-" .. vol.id .. "-" .. tostring(os.time())) + local written, werr = writeSecurePassfile(tempPassfile, password) + if not written then + actionResult(command, false, noctalia.tr("result.failed", { error = werr or "could not write passfile" })) + kickAutoMount() + return + end + table.insert(args, "-passfile") + table.insert(args, tempPassfile) + elseif useKeyring then + if not keyctlAvailable() and not secretToolAvailable() then + local msg = noctalia.tr("result.failed", { + error = "neither keyctl nor secret-tool found (install keyutils and/or libsecret)", + }) + actionResult(command, false, msg) + if command.silent ~= true then + notifyErr(msg) + end + kickAutoMount() + return + end + -- Prefer session keyctl (hydrated from secret-tool after reboot); else secret-tool -extpass. + local gocryptParts = {} + for _, a in ipairs(args) do + table.insert(gocryptParts, shellQuote(a)) + end + local cipherQ = shellQuote(cipher) + local mountQ = shellQuote(mount) + local vid = tostring(vol.id) + if keyctlAvailable() then + table.insert(gocryptParts, shellQuote("-extpass")) + table.insert(gocryptParts, shellQuote("keyctl")) + table.insert(gocryptParts, shellQuote("-extpass")) + table.insert(gocryptParts, shellQuote("pipe")) + table.insert(gocryptParts, shellQuote("-extpass")) + table.insert(gocryptParts, "\"$K\"") + table.insert(gocryptParts, cipherQ) + table.insert(gocryptParts, mountQ) + local keyctlMount = table.concat(gocryptParts, " ") + if secretToolAvailable() then + -- Fallback: secret-tool -extpass when session hydrate cannot produce K + local secretParts = {} + for _, a in ipairs(args) do + table.insert(secretParts, shellQuote(a)) + end + table.insert(secretParts, shellQuote("-extpass")) + table.insert(secretParts, shellQuote("secret-tool")) + table.insert(secretParts, shellQuote("-extpass")) + table.insert(secretParts, shellQuote("lookup")) + table.insert(secretParts, shellQuote("-extpass")) + table.insert(secretParts, shellQuote("service")) + table.insert(secretParts, shellQuote("-extpass")) + table.insert(secretParts, shellQuote(SECRET_SERVICE)) + table.insert(secretParts, shellQuote("-extpass")) + table.insert(secretParts, shellQuote("volume-id")) + table.insert(secretParts, shellQuote("-extpass")) + table.insert(secretParts, shellQuote(vid)) + table.insert(secretParts, cipherQ) + table.insert(secretParts, mountQ) + -- ensureSessionKeyShell exits 2 when no material; override to allow secret-tool path + mountCmd = "K=$(keyctl search @u user " + .. shellQuote(keyringDesc(vid)) + .. " 2>/dev/null) || true; " + .. "if [ -z \"$K\" ] && command -v secret-tool >/dev/null 2>&1; then " + .. "secret-tool lookup service " + .. shellQuote(SECRET_SERVICE) + .. " volume-id " + .. shellQuote(vid) + .. " 2>/dev/null | keyctl padd user " + .. shellQuote(keyringDesc(vid)) + .. " @u >/dev/null 2>&1 || true; " + .. "K=$(keyctl search @u user " + .. shellQuote(keyringDesc(vid)) + .. " 2>/dev/null) || true; " + .. "fi; " + .. "if [ -n \"$K\" ]; then " + .. keyctlMount + .. "; else " + .. table.concat(secretParts, " ") + .. "; fi" + else + mountCmd = ensureSessionKeyShell(vid) .. keyctlMount + end + else + -- No keyctl: mount directly via secret-tool + table.insert(gocryptParts, shellQuote("-extpass")) + table.insert(gocryptParts, shellQuote("secret-tool")) + table.insert(gocryptParts, shellQuote("-extpass")) + table.insert(gocryptParts, shellQuote("lookup")) + table.insert(gocryptParts, shellQuote("-extpass")) + table.insert(gocryptParts, shellQuote("service")) + table.insert(gocryptParts, shellQuote("-extpass")) + table.insert(gocryptParts, shellQuote(SECRET_SERVICE)) + table.insert(gocryptParts, shellQuote("-extpass")) + table.insert(gocryptParts, shellQuote("volume-id")) + table.insert(gocryptParts, shellQuote("-extpass")) + table.insert(gocryptParts, shellQuote(vid)) + table.insert(gocryptParts, cipherQ) + table.insert(gocryptParts, mountQ) + mountCmd = table.concat(gocryptParts, " ") + end + elseif passfile ~= "" then + -- Optional legacy/custom plaintext passfile path (user-managed). + local okPf, pfOrErr = isSafeFsPath(passfile) + if not okPf then + local msg = noctalia.tr("result.failed", { error = pfOrErr or "unsafe passfile path" }) + actionResult(command, false, msg) + notifyErr(msg) + kickAutoMount() + return + end + local pf = pfOrErr + if not noctalia.fileExists(pf) then + local msg = noctalia.tr("result.failed", { error = "passfile not found: " .. pf }) + actionResult(command, false, msg) + notifyErr(msg) + kickAutoMount() + return + end + table.insert(args, "-passfile") + table.insert(args, pf) + else + actionResult(command, false, noctalia.tr("panel.password_required")) + kickAutoMount() + return + end + + if mountCmd == nil then + table.insert(args, cipher) + table.insert(args, mount) + mountCmd = shellCommand(args) + end + + actionBusy = true + publishSnapshot() + + local launched = noctalia.runAsync(mountCmd, function(result) + removePassfile(tempPassfile) + local ok = result ~= nil and result.exitCode == 0 and not result.timedOut + local message + if ok then + vol.mounted = true + message = noctalia.tr("result.mounted", { name = vol.name }) + -- After a typed-password mount, keep password in keyring for this login session. + if password ~= "" and storeKeyring then + storeKeyringPassword(vol.id, password, function(krOk, krErr) + if krOk then + vol.useKeyring = true + saveVolumes() + publishSnapshot() + else + noctalia.log(`gocryptfs: keyring store failed: {krErr}`) + end + end) + end + else + local err = trim(result and (result.stderr ~= "" and result.stderr or result.stdout)) + if err == "" then + if result and result.exitCode == 2 and useKeyring and password == "" then + err = "keyring password missing (Remember once, or unlock desktop keyring)" + else + err = result and result.timedOut and "timed out" or "unknown error" + end + end + message = noctalia.tr("result.failed", { error = err }) + end + finishAction(command, ok, message) + end, 60000) + + if not launched then + removePassfile(tempPassfile) + actionBusy = false + actionResult(command, false, noctalia.tr("result.failed", { error = "could not start gocryptfs" })) + publishSnapshot() + kickAutoMount() + end +end + +kickAutoMount = function() + if actionBusy then + return + end + if #autoMountQueue == 0 then + return + end + local id = table.remove(autoMountQueue, 1) + local vol = findVolume(id) + if not canAutoMount(vol) then + kickAutoMount() + return + end + noctalia.log(`gocryptfs: auto-mounting {vol.name}`) + mountVolume({ + action = "mount", + id = id, + requestId = "auto-" .. id, + silent = true, + }) +end + +local function unmountVolume(command) + local vol = findVolume(command.id) + if not vol then + actionResult(command, false, noctalia.tr("result.not_found")) + return + end + if not vol.mounted then + actionResult(command, false, noctalia.tr("result.not_mounted", { name = vol.name })) + return + end + + local bin = unmountBinary() + if not bin then + actionResult(command, false, noctalia.tr("result.failed", { error = "fusermount not found" })) + return + end + + local okMountPath, mountOrErr = isSafeFsPath(vol.mountPoint) + if not okMountPath then + actionResult(command, false, noctalia.tr("result.failed", { error = mountOrErr or "unsafe mount path" })) + return + end + local mount = mountOrErr + local args = { bin, "-u", mount } + + actionBusy = true + publishSnapshot() + + local launched = noctalia.runAsync(shellCommand(args), function(result) + local ok = result ~= nil and result.exitCode == 0 and not result.timedOut + local message + if ok then + -- Optimistic: list must show unmounted even if the follow-up + -- /proc/mounts refresh is delayed or coalesced with an in-flight poll. + vol.mounted = false + message = noctalia.tr("result.unmounted", { name = vol.name }) + else + local err = trim(result and (result.stderr ~= "" and result.stderr or result.stdout)) + if err == "" then + err = "unknown error" + end + message = noctalia.tr("result.failed", { error = err }) + end + finishAction(command, ok, message) + end, 30000) + + if not launched then + actionBusy = false + actionResult(command, false, noctalia.tr("result.failed", { error = "could not start fusermount" })) + publishSnapshot() + end +end + +local function openMount(command) + local vol = findVolume(command.id) + if not vol then + actionResult(command, false, noctalia.tr("result.not_found")) + return + end + local okPath, pathOrErr = isSafeFsPath(vol.mountPoint) + if not okPath then + actionResult(command, false, noctalia.tr("result.failed", { error = pathOrErr or "unsafe mount path" })) + return + end + local path = pathOrErr + if not vol.mounted then + if not noctalia.fileExists(path) then + actionResult(command, false, noctalia.tr("result.not_mounted", { name = vol.name })) + return + end + end + noctalia.runAsync(shellCommand({ "xdg-open", path })) + actionResult(command, true, noctalia.tr("result.success")) +end + +local function conflictsWith(vol, excludeId) + for _, other in ipairs(volumes) do + if other.id ~= excludeId then + if pathEqual(other.cipherDir, vol.cipherDir) or pathEqual(other.mountPoint, vol.mountPoint) then + return true + end + end + end + return false +end + +local function addOrUpdateVolume(command, isUpdate) + local raw = { + id = isUpdate and command.id or nil, + name = command.name, + cipherDir = command.cipherDir, + mountPoint = command.mountPoint, + passfile = command.passfile, + useKeyring = command.useKeyring == true, + allowOther = command.allowOther == true, + readOnly = command.readOnly == true, + autoMount = command.autoMount, + } + if command.autoMount == nil and not isUpdate then + raw.autoMount = command.useKeyring == true or trim(command.passfile or "") ~= "" + end + local vol = normalizeVolume(raw) + if not vol then + actionResult(command, false, noctalia.tr("panel.field.required")) + return + end + + local okCipher, cipherOrErr = isSafeFsPath(vol.cipherDir) + if not okCipher then + actionResult(command, false, noctalia.tr("result.failed", { error = cipherOrErr or "unsafe cipher path" })) + return + end + vol.cipherDir = cipherOrErr + local okMountPath, mountOrErr = isSafeFsPath(vol.mountPoint) + if not okMountPath then + actionResult(command, false, noctalia.tr("result.failed", { error = mountOrErr or "unsafe mount path" })) + return + end + vol.mountPoint = mountOrErr + if trim(vol.passfile) ~= "" then + local okPf, pfOrErr = isSafeFsPath(vol.passfile) + if not okPf then + actionResult(command, false, noctalia.tr("result.failed", { error = pfOrErr or "unsafe passfile path" })) + return + end + vol.passfile = pfOrErr + end + + if isUpdate then + local existing = findVolume(command.id) + if not existing then + actionResult(command, false, noctalia.tr("result.not_found")) + return + end + if conflictsWith(vol, existing.id) then + actionResult(command, false, noctalia.tr("panel.field.duplicate")) + return + end + existing.name = vol.name + existing.cipherDir = vol.cipherDir + existing.mountPoint = vol.mountPoint + existing.passfile = vol.passfile + existing.useKeyring = vol.useKeyring + existing.allowOther = vol.allowOther + existing.readOnly = vol.readOnly + existing.autoMount = vol.autoMount + else + if conflictsWith(vol, nil) then + actionResult(command, false, noctalia.tr("panel.field.duplicate")) + return + end + table.insert(volumes, vol) + end + + local ok, err = saveVolumes() + if not ok then + actionResult(command, false, noctalia.tr("result.failed", { error = err or "save failed" })) + return + end + actionResult(command, true, noctalia.tr("result.saved", { name = vol.name })) + updateRevision("volumes-changed-" .. tostring(os.time())) + publishSnapshot() + refreshAll() +end + +local function removeVolume(command) + local id = trim(command.id) + local index = nil + local name = "" + local passfile = "" + local useKeyring = false + for i, vol in ipairs(volumes) do + if vol.id == id then + index = i + name = vol.name + passfile = vol.passfile + useKeyring = vol.useKeyring == true + if vol.mounted then + actionResult(command, false, noctalia.tr("result.failed", { error = "unmount before removing" })) + return + end + break + end + end + if not index then + actionResult(command, false, noctalia.tr("result.not_found")) + return + end + table.remove(volumes, index) + + -- remove managed passfile if it lives under our passfiles dir (legacy) + local managed = defaultPassfilePath(id) + if managed and passfile ~= "" and pathEqual(passfile, managed) then + noctalia.removeFile(expand(managed)) + end + if useKeyring then + unlinkKeyringPassword(id, function() end) + end + + local ok, err = saveVolumes() + if not ok then + actionResult(command, false, noctalia.tr("result.failed", { error = err or "save failed" })) + return + end + actionResult(command, true, noctalia.tr("result.removed", { name = name })) + updateRevision("volumes-removed-" .. tostring(os.time())) + publishSnapshot() + refreshAll() +end + +local function initVolume(command) + if actionBusy then + actionResult(command, false, noctalia.tr("result.busy")) + return + end + + local name = trim(command.name) + local cipherDir = trim(command.cipherDir) + local mountPoint = trim(command.mountPoint) + local password = type(command.password) == "string" and command.password or "" + local plaintextNames = command.plaintextNames == true + local aesSiv = command.aesSiv == true + -- "savePassfile" UI flag now means store in kernel keyring (no plaintext at rest). + local saveKeyring = command.savePassfile == true or command.saveKeyring == true + local autoMount = command.autoMount == true + local allowOther = command.allowOther == true + local readOnly = command.readOnly == true + + if name == "" or cipherDir == "" or mountPoint == "" then + actionResult(command, false, noctalia.tr("panel.field.required")) + return + end + if password == "" then + actionResult(command, false, noctalia.tr("panel.password_required")) + return + end + if saveKeyring and not keyctlAvailable() and not secretToolAvailable() then + actionResult(command, false, noctalia.tr("result.failed", { + error = "neither keyctl nor secret-tool found (install keyutils and/or libsecret)", + })) + return + end + + local vol = normalizeVolume({ + name = name, + cipherDir = cipherDir, + mountPoint = mountPoint, + passfile = "", + useKeyring = saveKeyring, + allowOther = allowOther, + readOnly = readOnly, + autoMount = autoMount and saveKeyring, + }) + if not vol then + actionResult(command, false, noctalia.tr("panel.field.required")) + return + end + if conflictsWith(vol, nil) then + actionResult(command, false, noctalia.tr("panel.field.duplicate")) + return + end + + local okCipher, cipherOrErr = isSafeFsPath(vol.cipherDir) + if not okCipher then + actionResult(command, false, noctalia.tr("result.failed", { error = cipherOrErr or "unsafe cipher path" })) + return + end + local cipher = cipherOrErr + local okMountPath, mountOrErr = isSafeFsPath(vol.mountPoint) + if not okMountPath then + actionResult(command, false, noctalia.tr("result.failed", { error = mountOrErr or "unsafe mount path" })) + return + end + + if noctalia.fileExists(cipher .. "/gocryptfs.conf") then + actionResult(command, false, noctalia.tr("result.already_initialized", { path = cipher })) + return + end + + local okDir, dirErr = ensureDir(cipher) + if not okDir then + actionResult(command, false, noctalia.tr("result.failed", { error = "cipher dir: " .. tostring(dirErr) })) + return + end + + local tempPassfile = tempPassPath("init-" .. vol.id .. "-" .. tostring(os.time())) + local written, werr = writeSecurePassfile(tempPassfile, password) + if not written then + actionResult(command, false, noctalia.tr("result.failed", { error = werr or "could not write passfile" })) + return + end + + local args = { "gocryptfs", "-init", "-q", "-passfile", tempPassfile } + if plaintextNames then + table.insert(args, "-plaintextnames") + end + if aesSiv then + table.insert(args, "-aessiv") + end + table.insert(args, cipher) + + actionBusy = true + publishSnapshot() + + local launched = noctalia.runAsync(shellCommand(args), function(result) + removePassfile(tempPassfile) + local ok = result ~= nil and result.exitCode == 0 and not result.timedOut + if not ok then + local err = trim(result and (result.stderr ~= "" and result.stderr or result.stdout)) + if err == "" then + err = result and result.timedOut and "timed out" or "unknown error" + end + finishAction(command, false, noctalia.tr("result.failed", { error = err })) + return + end + + local function finishInit() + table.insert(volumes, vol) + local saved, saveErr = saveVolumes() + if not saved then + finishAction(command, false, noctalia.tr("result.failed", { error = saveErr or "save failed" })) + return + end + finishAction(command, true, noctalia.tr("result.initialized", { name = vol.name })) + end + + if saveKeyring then + storeKeyringPassword(vol.id, password, function(krOk, krErr) + if krOk then + vol.useKeyring = true + vol.autoMount = autoMount + else + noctalia.log(`gocryptfs: keyring store failed: {krErr}`) + vol.useKeyring = false + vol.autoMount = false + end + finishInit() + end) + else + finishInit() + end + end, 120000) + + if not launched then + removePassfile(tempPassfile) + actionBusy = false + actionResult(command, false, noctalia.tr("result.failed", { error = "could not start gocryptfs -init" })) + publishSnapshot() + end +end + +-- Store password in kernel keyring for an existing volume (no mount required). +local function storeKeyringAction(command) + local vol = findVolume(command.id) + if not vol then + actionResult(command, false, noctalia.tr("result.not_found")) + return + end + local password = type(command.password) == "string" and command.password or "" + if password == "" then + actionResult(command, false, noctalia.tr("panel.password_required")) + return + end + if not keyctlAvailable() and not secretToolAvailable() then + actionResult(command, false, noctalia.tr("result.failed", { + error = "neither keyctl nor secret-tool found (install keyutils and/or libsecret)", + })) + return + end + + actionBusy = true + publishSnapshot() + + storeKeyringPassword(vol.id, password, function(ok, err) + actionBusy = false + if not ok then + local msg = noctalia.tr("result.failed", { error = err or "keyring store failed" }) + actionResult(command, false, msg) + notifyErr(msg) + publishSnapshot() + return + end + vol.useKeyring = true + if command.enableAutoMount == true then + vol.autoMount = true + end + local saved, saveErr = saveVolumes() + if not saved then + local msg = noctalia.tr("result.failed", { error = saveErr or "save failed" }) + actionResult(command, false, msg) + notifyErr(msg) + publishSnapshot() + return + end + local msg = noctalia.tr("result.keyring_saved", { name = vol.name }) + actionResult(command, true, msg) + notifyOk(msg) + publishSnapshot() + refreshAll() + end) +end + +local function executeAction(command) + if type(command) ~= "table" or type(command.action) ~= "string" then + return + end + + if command.action == "refresh" then + refreshAll() + return + end + + if command.action == "reload_config" then + loadVolumes() + autoMountScheduled = false + autoMountQueue = {} + refreshAll() + return + end + + if command.action == "add_volume" then + addOrUpdateVolume(command, false) + return + end + if command.action == "update_volume" then + addOrUpdateVolume(command, true) + return + end + if command.action == "remove_volume" then + removeVolume(command) + return + end + if command.action == "open" then + openMount(command) + return + end + if command.action == "init_volume" then + initVolume(command) + return + end + if command.action == "store_keyring" then + storeKeyringAction(command) + return + end + + if command.action == "forget_keyring" then + local vol = findVolume(command.id) + if not vol then + actionResult(command, false, noctalia.tr("result.not_found")) + return + end + actionBusy = true + publishSnapshot() + unlinkKeyringPassword(vol.id, function() + vol.useKeyring = false + -- Do not force autoMount off if user still has a passfile path + if trim(vol.passfile) == "" then + vol.autoMount = false + end + local saved, saveErr = saveVolumes() + actionBusy = false + if not saved then + local msg = noctalia.tr("result.failed", { error = saveErr or "save failed" }) + actionResult(command, false, msg) + notifyErr(msg) + publishSnapshot() + return + end + local msg = noctalia.tr("result.keyring_forgotten", { name = vol.name }) + actionResult(command, true, msg) + notifyOk(msg) + publishSnapshot() + refreshAll() + end) + return + end + + if actionBusy then + actionResult(command, false, noctalia.tr("result.busy")) + return + end + + if command.action == "mount" then + mountVolume(command) + elseif command.action == "unmount" then + unmountVolume(command) + else + actionResult(command, false, `Unknown action: {command.action}`) + end +end + +-- boot +loadVolumes() +noctalia.state.watch(COMMAND_KEY, executeAction) +noctalia.setUpdateInterval(refreshIntervalMs()) +refreshAll() + +function update() + refreshAll() +end + +function onConfigChanged() + noctalia.setUpdateInterval(refreshIntervalMs()) + -- re-run auto-mount if user turned it on + if autoMountEnabled() and autoMountScheduled then + for _, vol in ipairs(volumes) do + if canAutoMount(vol) then + local already = false + for _, id in ipairs(autoMountQueue) do + if id == vol.id then + already = true + break + end + end + if not already then + table.insert(autoMountQueue, vol.id) + end + end + end + kickAutoMount() + end + refreshAll() +end + +function onIpc(event, _payload) + if event == "refresh" then + refreshAll() + elseif event == "reload" then + loadVolumes() + autoMountScheduled = false + autoMountQueue = {} + refreshAll() + elseif event == "automount" then + autoMountScheduled = false + autoMountQueue = {} + refreshAll() + end +end diff --git a/gocryptfs/thumbnail.webp b/gocryptfs/thumbnail.webp new file mode 100644 index 0000000..8e362ef Binary files /dev/null and b/gocryptfs/thumbnail.webp differ diff --git a/gocryptfs/translations/en.json b/gocryptfs/translations/en.json new file mode 100644 index 0000000..1373d26 --- /dev/null +++ b/gocryptfs/translations/en.json @@ -0,0 +1,140 @@ +{ + "title": "Gocryptfs", + "settings": { + "refresh_interval": { + "label": "Refresh interval (seconds)", + "description": "How often the service rechecks mount status." + }, + "notify_on_action": { + "label": "Notify on mount/unmount", + "description": "Show a notification when a volume mounts or unmounts." + }, + "create_mountpoint": { + "label": "Create mount points", + "description": "Create the mount directory if it does not exist before mounting." + }, + "auto_mount": { + "label": "Auto-mount on login", + "description": "When Noctalia starts, mount volumes that have auto-mount enabled and a keyring password or passfile." + }, + "show_count": { + "label": "Show mounted count", + "description": "Display mounted/total on the bar widget." + }, + "glyph_color": { + "label": "Icon color", + "description": "Color of the lock icon when gocryptfs is available." + }, + "mounted_color": { + "label": "Mounted indicator", + "description": "Status-dot color when at least one volume is mounted." + }, + "unmounted_color": { + "label": "Unmounted indicator", + "description": "Status-dot color when nothing is mounted." + } + }, + "colors": { + "default": "Default", + "primary": "Primary", + "secondary": "Secondary", + "tertiary": "Tertiary", + "error": "Error", + "muted": "Muted" + }, + "widget": { + "tooltip": "{mounted} of {total} mounted", + "tooltip_none": "No volumes configured", + "unavailable": "gocryptfs is not installed", + "refresh_requested": "Refreshing volume status…" + }, + "panel": { + "subtitle": "Encrypted volumes", + "empty": "No volumes yet. Initialize a new volume or add an existing one.", + "loading": "Checking mounts…", + "busy": "Working…", + "select_hint": "Select a volume to manage it.", + "updated": "Updated {time}", + "password_title": "Password for {name}", + "password_label": "Password", + "password_placeholder": "Volume password", + "password_required": "Password is required.", + "keyring_title": "Remember password for {name}", + "keyring_label": "Volume password (desktop keyring + session cache)", + "keyring_help": "Saved in your desktop keyring (survives reboot when unlocked) and cached for this login. No password file under plugin data.", + "keyring_not_set": "No remembered password for this volume.", + "remember_keyring": "Also remember in keyring (desktop + this login)", + "add_title": "Add existing volume", + "edit_title": "Edit volume", + "init_title": "Initialize new volume", + "init_help": "Runs gocryptfs -init on the cipher directory, then registers it for mounting.", + "field": { + "name": "Name", + "name_placeholder": "Documents", + "cipher": "Cipher directory", + "cipher_placeholder": "~/Encrypted/docs", + "mount": "Mount point", + "mount_placeholder": "~/Private/docs", + "keyring_section": "Password (desktop keyring)", + "passfile": "Passfile path (optional, advanced)", + "passfile_placeholder": "Leave empty — prefer desktop keyring", + "password": "Password", + "password_confirm": "Confirm password", + "password_confirm_placeholder": "Re-enter password", + "password_mismatch": "Passwords do not match.", + "plaintextnames": "Plaintext file names (-plaintextnames)", + "aessiv": "AES-SIV encryption (-aessiv)", + "save_passfile": "Remember password in keyring (desktop + this login)", + "auto_mount": "Auto-mount on login", + "auto_mount_help": "Requires a remembered keyring password or advanced passfile. Global auto-mount must also be on. Desktop keyring must unlock after login for reboot-safe auto-mount.", + "allow_other": "Allow other users (-allow_other)", + "read_only": "Read-only (-ro)", + "required": "Name, cipher directory, and mount point are required.", + "duplicate": "A volume with that cipher directory or mount point already exists." + }, + "status": { + "mounted": "Mounted", + "unmounted": "Unmounted", + "unknown": "Unknown" + }, + "cipher": "Cipher: {path}", + "mountpoint": "Mount: {path}", + "passfile_hint": "Using passfile", + "keyring_hint": "Password in keyring", + "automount_hint": "Auto-mount" + }, + "actions": { + "mount": "Mount", + "unmount": "Unmount", + "open": "Open", + "refresh": "Refresh", + "add": "Add", + "init": "Init", + "edit": "Edit", + "remove": "Remove", + "remember": "Remember", + "forget": "Forget", + "save": "Save", + "cancel": "Cancel", + "close": "Close" + }, + "result": { + "success": "Done", + "failed": "Failed: {error}", + "busy": "Another operation is already running.", + "gocryptfs_missing": "gocryptfs is not on PATH. Install gocryptfs and try again.", + "mounted": "Mounted {name}", + "unmounted": "Unmounted {name}", + "initialized": "Initialized and saved {name}", + "already_initialized": "Already a gocryptfs volume: {path}", + "not_found": "Volume not found.", + "cipher_missing": "Cipher directory does not exist: {path}", + "mount_create_failed": "Could not create mount point: {path}", + "already_mounted": "{name} is already mounted.", + "not_mounted": "{name} is not mounted.", + "removed": "Removed {name}", + "saved": "Saved {name}", + "keyring_saved": "Password for {name} saved in desktop keyring", + "keyring_forgotten": "Keyring password cleared for {name}" + } +} diff --git a/gocryptfs/widget.luau b/gocryptfs/widget.luau new file mode 100644 index 0000000..dbe411d --- /dev/null +++ b/gocryptfs/widget.luau @@ -0,0 +1,93 @@ +--!nonstrict + +local PANEL_ID = "davemhammer/gocryptfs:manager" +local STATE_KEY = "gocrypt_snapshot" +local COMMAND_KEY = "gocrypt_command" + +local snapshot = noctalia.state.get(STATE_KEY) or { + available = false, + loading = true, + mountedCount = 0, + totalCount = 0, +} + +local requestId = 0 + +local function configString(key, fallback) + local value = noctalia.getConfig(key) + return type(value) == "string" and value or fallback +end + +local function render() + local mounted = tonumber(snapshot.mountedCount) or 0 + local total = tonumber(snapshot.totalCount) or 0 + local available = snapshot.available == true + local showCount = noctalia.getConfig("show_count") ~= false + local anyMounted = mounted > 0 + + local glyphName = anyMounted and "lock-open" or "lock" + local glyphColor = available and configString("glyph_color", "on_surface") or "on_surface_variant" + if anyMounted then + glyphColor = configString("mounted_color", "tertiary") + end + + local children = { + ui.glyph({ + name = glyphName, + size = 16, + color = glyphColor, + }), + } + + if showCount and available and total > 0 then + table.insert(children, ui.label({ + text = `{mounted}/{total}`, + fontWeight = "bold", + color = "on_surface", + })) + end + + if available and total > 0 then + table.insert(children, ui.box({ + width = 7, + height = 7, + radius = 4, + fill = anyMounted and configString("mounted_color", "tertiary") or configString("unmounted_color", "on_surface_variant"), + })) + end + + local container = barWidget.isVertical() and ui.column or ui.row + barWidget.render(container({ gap = 5, align = "center" }, children)) + + if not available then + barWidget.setTooltip(noctalia.tr("widget.unavailable")) + elseif total == 0 then + barWidget.setTooltip(noctalia.tr("widget.tooltip_none")) + else + barWidget.setTooltip(noctalia.tr("widget.tooltip", { mounted = mounted, total = total })) + end +end + +noctalia.state.watch(STATE_KEY, function(value) + if type(value) == "table" then + snapshot = value + render() + end +end) + +noctalia.setUpdateInterval(5000) +render() + +function update() + render() +end + +function onClick() + noctalia.togglePanel(PANEL_ID) +end + +function onRightClick() + requestId += 1 + noctalia.state.set(COMMAND_KEY, { action = "refresh", requestId = `widget-{requestId}` }) + noctalia.notify(noctalia.tr("title"), noctalia.tr("widget.refresh_requested")) +end