mirror of
https://github.com/greenart7c3/Amber.git
synced 2026-10-06 19:33:01 +00:00
desktop: mandatory passphrase, dedicated OS user, non-blocking tray
The passphrase lock is no longer opt-in: a first-run setup screen gates the whole app (the relay engine stays disconnected until it is set), and PassphraseLock.disable() is gone together with the remove-passphrase UI — there is no way back to plaintext key storage. Auto-lock now defaults to one hour (explicitly saved values are kept), and logging out of an account asks for the passphrase and verifies it via the new PassphraseLock.verify() before deleting anything. On Linux Amber now runs under a dedicated OS user instead of the login user. On first open a dialog asks for the sudo password and, as root: creates the amber user with its own home, moves the invoking user's Amber data there once, installs a root-owned launcher that execs exactly the Amber binary with only session socket locations as arguments, and installs a visudo-validated NOPASSWD sudoers rule for that launcher — then re-executes Amber under the dedicated user. Later launches switch silently; a changed binary path re-runs setup. The sudoers rule cannot be reused to run anything else, and the wrapper never accepts arbitrary args or env (no JDK_JAVA_OPTIONS injection). AMBER_DISABLE_DEDICATED_USER=1 skips the flow; bare java launches (./gradlew :desktop:run) are skipped automatically. The dorkbox tray init could block SystemTray.get() indefinitely with no SNI host or a half-alive one, stalling startup before the window ever opened. Tray creation now probes for a StatusNotifierItem host (gdbus) and runs with a 5s timeout on a daemon thread: the window always opens, and a late-completing init still publishes the icon with working menu actions. DatabaseEncryptionTest folds into PassphraseLockTest (the only class that toggles the lock) since the disable() reset path it relied on no longer exists; DedicatedUserTest covers the launcher, sudoers rule and root-script generation, including two real bugs it caught (indented heredoc terminators swallowing the rest of the script, and a nested-quote chown that never expanded). New strings are translated in all 14 locales. :desktop:ktlintCheck and :desktop:test are green; the first-open sudo dialog and the fresh-install passphrase gate verified on-screen, and :desktop:run opens its window in ~4s with the tray bounded.
This commit is contained in:
+54
-14
@@ -31,13 +31,17 @@ for the JVM) and mirrors the mobile UI and permission model.
|
||||
inside a `group`/drawer that you expand to reveal the icon) and the
|
||||
`libayatana-appindicator` runtime library; Amber automatically bridges the
|
||||
Ayatana library to the legacy `libappindicator3` names dorkbox looks for,
|
||||
so no compat symlink is required. `AMBER_TRAY_TYPE=Gtk|AppIndicator|AutoDetect`
|
||||
so no compat symlink is required. When no SNI host is on the session bus
|
||||
(or tray init takes too long), Amber skips the tray and logs why instead of
|
||||
blocking startup. `AMBER_TRAY_TYPE=Gtk|AppIndicator|AutoDetect`
|
||||
forces the backend and `AMBER_DISABLE_TRAY=1` skips the tray entirely.
|
||||
- Notifications go through the OS-native channel: the freedesktop
|
||||
notification daemon (mako, dunst, swaync, GNOME Shell, …) via `notify-send`
|
||||
or `gdbus` on Linux — so they work on Hyprland/Wayland — `osascript` on
|
||||
macOS, and the AWT tray notification on Windows
|
||||
- Optional passphrase lock (see Key storage below)
|
||||
- Mandatory passphrase lock (see Key storage below); on Linux Amber
|
||||
additionally runs under its own dedicated OS user, set up in-app on first
|
||||
open (see Running under a dedicated user below)
|
||||
- Native desktop layout: sidebar navigation with an account switcher, dense
|
||||
list views, and keyboard shortcuts
|
||||
- Light/dark theme using the Amber palette
|
||||
@@ -59,7 +63,7 @@ Ctrl on Windows/Linux, ⌘ on macOS:
|
||||
| Ctrl/⌘ + Enter | Approve the selected request with the chosen duration |
|
||||
| Ctrl/⌘ + Shift + Enter | Reject the selected request |
|
||||
| Escape | Leave the application detail view |
|
||||
| Ctrl/⌘ + L | Lock (when a passphrase is set) |
|
||||
| Ctrl/⌘ + L | Lock |
|
||||
| Ctrl/⌘ + M | Minimize to tray (keep running in the background) |
|
||||
| Ctrl/⌘ + W | Same as Ctrl/⌘ + M |
|
||||
| Ctrl/⌘ + Q | Quit |
|
||||
@@ -98,14 +102,16 @@ protection too. If you move the data directory to another machine, also
|
||||
transfer the `com.greenart7c3.nostrsigner` entry from the credential store
|
||||
(or keep the legacy `keystore.pass` file).
|
||||
|
||||
### Passphrase lock (stronger, opt-in)
|
||||
### Passphrase lock (mandatory)
|
||||
|
||||
Enable a passphrase under **Settings → Security** for defence that does not
|
||||
depend on the OS credential store. The AES master key is then stored only
|
||||
wrapped (AES-256-GCM) under a key derived from your passphrase with
|
||||
**Argon2id**, in `master.key.enc`; the plain keystore and its
|
||||
Amber requires a passphrase: on first run a setup screen asks for one before
|
||||
anything else can be used, and installs that already have only the OS
|
||||
credential store are migrated to it on the next launch. The AES master key
|
||||
is then stored only wrapped (AES-256-GCM) under a key derived from your
|
||||
passphrase with **Argon2id**, in `master.key.enc`; the plain keystore and its
|
||||
credential-store/file password are deleted. The passphrase is never written
|
||||
anywhere.
|
||||
anywhere. You can change it under **Settings → Security**, but there is no
|
||||
way to remove it and go back to unprotected storage.
|
||||
|
||||
With the lock on:
|
||||
|
||||
@@ -115,13 +121,16 @@ With the lock on:
|
||||
request history, relay logs) is also encrypted at rest with the master
|
||||
key — AES-256-GCM, with an `AMBERENC1:` header — so the metadata about
|
||||
which apps you sign for stays private too. Enabling the passphrase
|
||||
re-encrypts existing data immediately; removing it rewrites plaintext.
|
||||
re-encrypts existing data immediately.
|
||||
(`settings.json` and `accounts.json` stay plaintext, but the private keys
|
||||
inside `accounts.json` are always encrypted with the master key.)
|
||||
- Amber asks for the passphrase at startup and can auto-lock after an idle
|
||||
timeout (5 min / 15 min / 1 hour / never) or immediately via **Lock now**.
|
||||
Locking evicts all key material from memory and disconnects the relays, so
|
||||
no request can be signed until you unlock again.
|
||||
- Amber asks for the passphrase at startup and auto-locks after an idle
|
||||
timeout — 1 hour by default, selectable (5 min / 15 min / 1 hour / never)
|
||||
under **Settings → Security** — or immediately via **Lock now**. Locking
|
||||
evicts all key material from memory and disconnects the relays, so no
|
||||
request can be signed until you unlock again.
|
||||
- Logging out of an account (which deletes its key from this device) asks
|
||||
for the passphrase first.
|
||||
|
||||
Residual risk it cannot remove: while unlocked, the keys are in the
|
||||
process's memory, so malware that can scrape another process's memory or
|
||||
@@ -150,6 +159,37 @@ jpackage can only produce installers for the OS it runs on, so release
|
||||
builds are made per-platform. Linux packaging needs `fakeroot` (deb) or
|
||||
`rpm-build` (rpm) installed.
|
||||
|
||||
### Running under a dedicated user (Linux)
|
||||
|
||||
On Linux, Amber does not run as your login user: the first time it opens, it
|
||||
asks for your password (sudo), creates a dedicated OS user, and re-launches
|
||||
itself under that user. The process that holds your keys is then walled off
|
||||
from the rest of your desktop session by the OS — other apps can no longer
|
||||
read Amber's memory or files, closing the same-user-malware residual risk
|
||||
described above (a process running as that user can still be attacked, of
|
||||
course — this is isolation, not a security boundary against root).
|
||||
|
||||
What the first-open setup does (as root, once):
|
||||
|
||||
1. creates the dedicated user `amber` with its own home directory
|
||||
(`AMBER_USER=name` picks a different name),
|
||||
2. moves your existing Amber data (`~/.local/share/amber`) into that home,
|
||||
3. installs a root-owned launcher (`/usr/local/bin/amber-runas-<name>`) that
|
||||
execs exactly the Amber binary with only your session's socket locations
|
||||
(Wayland, X11/XWayland, D-Bus for tray and notifications) passed as
|
||||
arguments — never arbitrary code or environment,
|
||||
4. installs a narrow sudoers rule (`/etc/sudoers.d/amber-runas-<name>`,
|
||||
validated with `visudo`) allowing your user to run that launcher as the
|
||||
dedicated user without a password,
|
||||
5. re-launches Amber under the dedicated user.
|
||||
|
||||
Afterwards every launch switches to the dedicated user silently. If the
|
||||
installed binary path changes (reinstall, update), the next open asks for
|
||||
your password once to regenerate the launcher. Set
|
||||
`AMBER_DISABLE_DEDICATED_USER=1` to skip the whole flow (useful for
|
||||
`./gradlew :desktop:run`, which is skipped automatically since it launches a
|
||||
bare `java` binary), and requires the `acl` package for `setfacl`.
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
|
||||
@@ -20,6 +20,7 @@ import androidx.compose.ui.window.rememberWindowState
|
||||
import com.greenart7c3.nostrsigner.desktop.core.AccountManager
|
||||
import com.greenart7c3.nostrsigner.desktop.core.AccountsStore
|
||||
import com.greenart7c3.nostrsigner.desktop.core.AmberDesktop
|
||||
import com.greenart7c3.nostrsigner.desktop.core.DedicatedUser
|
||||
import com.greenart7c3.nostrsigner.desktop.core.DesktopAccount
|
||||
import com.greenart7c3.nostrsigner.desktop.core.Notifier
|
||||
import com.greenart7c3.nostrsigner.desktop.core.PassphraseLock
|
||||
@@ -29,6 +30,7 @@ import com.greenart7c3.nostrsigner.desktop.core.describe
|
||||
import com.greenart7c3.nostrsigner.desktop.ui.App
|
||||
import com.greenart7c3.nostrsigner.desktop.ui.NostrSignerTheme
|
||||
import com.greenart7c3.nostrsigner.desktop.ui.handleShortcut
|
||||
import com.greenart7c3.nostrsigner.desktop.ui.runUserSetupWindow
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -45,14 +47,16 @@ object Session {
|
||||
var engineStarted = false
|
||||
PassphraseLock.state.collect { status ->
|
||||
when (status) {
|
||||
PassphraseLock.Status.LOCKED -> {
|
||||
// Key material is evicted; drop the account reference so
|
||||
// nothing in the UI can reach a decrypted signer.
|
||||
PassphraseLock.Status.LOCKED, PassphraseLock.Status.DISABLED -> {
|
||||
// Key material is evicted (or not set up yet); drop the
|
||||
// account reference so nothing in the UI can reach a
|
||||
// decrypted signer, and keep the engine disconnected
|
||||
// until the passphrase has been set up and unlocked.
|
||||
account.value = null
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
PassphraseLock.Status.DISABLED, PassphraseLock.Status.UNLOCKED -> {
|
||||
PassphraseLock.Status.UNLOCKED -> {
|
||||
val saved = AmberDesktop.settings.currentAccount
|
||||
val npub = saved.ifBlank { AccountsStore.accounts.value.firstOrNull()?.npub ?: "" }
|
||||
if (npub.isNotBlank()) {
|
||||
@@ -118,12 +122,30 @@ private object DesktopTray {
|
||||
}
|
||||
|
||||
fun main() {
|
||||
// The dorkbox tray MUST be created before Compose/AWT initializes GTK:
|
||||
// dorkbox has to own GTK loading, otherwise the AppIndicator backend fails
|
||||
// to start and SystemTray.get() returns null even when
|
||||
// libayatana-appindicator is installed. So build it here, first thing.
|
||||
when (val state = DedicatedUser.detect()) {
|
||||
DedicatedUser.State.Active -> startAmber()
|
||||
is DedicatedUser.State.Ready ->
|
||||
// A launcher is installed: switch to the dedicated user right away
|
||||
// without asking for a password. If the switch fails, fall through
|
||||
// to the setup window to regenerate it; this process exits either
|
||||
// way once the window closes.
|
||||
if (!DedicatedUser.relaunch(state.command)) {
|
||||
runUserSetupWindow()
|
||||
}
|
||||
|
||||
is DedicatedUser.State.SetupNeeded -> runUserSetupWindow()
|
||||
}
|
||||
}
|
||||
|
||||
private fun startAmber() {
|
||||
// The dorkbox tray prefers to be created before Compose/AWT initializes
|
||||
// GTK (dorkbox has to own GTK loading, otherwise the AppIndicator backend
|
||||
// fails to start and SystemTray.get() returns null even when
|
||||
// libayatana-appindicator is installed). So start it here, first thing —
|
||||
// but never let it block the window: createBounded probes for an SNI host
|
||||
// and abandons init that takes too long.
|
||||
if (DesktopTray.isLinux && System.getenv("AMBER_DISABLE_TRAY") == null) {
|
||||
DesktopTray.instance = NativeTray.create(
|
||||
DesktopTray.instance = NativeTray.createBounded(
|
||||
iconStream = { NativeTray::class.java.getResourceAsStream("/icon.png") },
|
||||
tooltip = Strings.get("d_tray_tooltip"),
|
||||
openLabel = Strings.get("d_tray_open"),
|
||||
|
||||
@@ -7,6 +7,10 @@ import dorkbox.systemTray.SystemTray
|
||||
import java.awt.event.ActionListener
|
||||
import java.io.File
|
||||
import java.nio.file.Files
|
||||
import java.util.concurrent.Callable
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.TimeoutException
|
||||
|
||||
/**
|
||||
* A tray icon backed by the dorkbox SystemTray library, used on Linux.
|
||||
@@ -122,6 +126,76 @@ class NativeTray private constructor(
|
||||
return if (isWayland()) SystemTray.TrayType.AppIndicator else SystemTray.TrayType.AutoDetect
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the session bus advertises a StatusNotifierItem host
|
||||
* (waybar's tray module, GNOME Shell, …), parsed from the reply of
|
||||
* `gdbus call … IsStatusNotifierHostRegistered`. No watcher (or no
|
||||
* gdbus) means no host: AppIndicator init would just hang waiting for
|
||||
* one, so callers skip the tray instead.
|
||||
*/
|
||||
internal fun sniHostRegistered(): Boolean {
|
||||
val output = runCatching {
|
||||
val process = ProcessBuilder(
|
||||
"gdbus", "call", "--session",
|
||||
"--dest", "org.kde.StatusNotifierWatcher",
|
||||
"--object-path", "/StatusNotifierWatcher",
|
||||
"--method", "org.freedesktop.DBus.Properties.Get",
|
||||
"org.kde.StatusNotifierWatcher", "IsStatusNotifierHostRegistered",
|
||||
).redirectErrorStream(true).start()
|
||||
val out = process.inputStream.readBytes().toString(Charsets.UTF_8)
|
||||
process.waitFor()
|
||||
out
|
||||
}.getOrDefault("")
|
||||
return parseSniHostReply(output)
|
||||
}
|
||||
|
||||
/** Package-visible for testing: a host is registered only on a `true` reply. */
|
||||
internal fun parseSniHostReply(output: String): Boolean = output.contains("true", ignoreCase = true)
|
||||
|
||||
/**
|
||||
* [create] with two safety nets so a missing/broken tray can never
|
||||
* block the app window: when no SNI host is on the session bus the
|
||||
* tray is skipped outright, and init is abandoned after [timeoutMs].
|
||||
* On timeout the init thread (daemon) may still complete in the
|
||||
* background and publish the icon, but Amber no longer waits for it.
|
||||
*/
|
||||
fun createBounded(
|
||||
timeoutMs: Long = 5_000,
|
||||
iconStream: () -> java.io.InputStream?,
|
||||
tooltip: String,
|
||||
openLabel: String,
|
||||
lockLabel: String,
|
||||
quitLabel: String,
|
||||
onToggle: () -> Unit,
|
||||
onLock: () -> Unit,
|
||||
onQuit: () -> Unit,
|
||||
): NativeTray? {
|
||||
// An explicit AMBER_TRAY_TYPE overrides the host probe (debugging).
|
||||
if (System.getenv("AMBER_TRAY_TYPE") == null && !sniHostRegistered()) {
|
||||
AmberLogger.i(
|
||||
"NativeTray",
|
||||
"No StatusNotifierItem host on the session bus; skipping the tray. " +
|
||||
"(Enable waybar's tray module, or set AMBER_TRAY_TYPE=AppIndicator to force.)",
|
||||
)
|
||||
return null
|
||||
}
|
||||
val executor = Executors.newSingleThreadExecutor { runnable ->
|
||||
Thread(runnable, "amber-tray-init").apply { isDaemon = true }
|
||||
}
|
||||
return try {
|
||||
executor.submit(Callable { create(iconStream, tooltip, openLabel, lockLabel, quitLabel, onToggle, onLock, onQuit) })
|
||||
.get(timeoutMs, TimeUnit.MILLISECONDS)
|
||||
} catch (e: TimeoutException) {
|
||||
AmberLogger.i("NativeTray", "Tray init did not finish in ${timeoutMs}ms; continuing without it")
|
||||
null
|
||||
} catch (e: Exception) {
|
||||
AmberLogger.i("NativeTray", "Tray init failed: ${e.message}")
|
||||
null
|
||||
} finally {
|
||||
executor.shutdownNow()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to create the tray. Returns null when no tray backend is
|
||||
* available (e.g. libayatana-appindicator is not installed), so the
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
package com.greenart7c3.nostrsigner.desktop.core
|
||||
|
||||
import java.io.File
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
|
||||
/**
|
||||
* On Linux, Amber runs under a dedicated OS user instead of the login user:
|
||||
* the process that holds your keys is then walled off from the rest of the
|
||||
* desktop session by the OS itself (memory, files, and data directory).
|
||||
*
|
||||
* On first open (and whenever the setup went stale) Amber asks for the sudo
|
||||
* password in-app and, as root:
|
||||
* 1. creates the dedicated user with its own home directory,
|
||||
* 2. moves the invoking user's Amber data into that home (once),
|
||||
* 3. installs a root-owned launcher that runs the Amber binary as the
|
||||
* dedicated user with only the session socket locations as arguments,
|
||||
* 4. installs a narrow sudoers rule (this user -> launcher, NOPASSWD) so
|
||||
* later launches switch to the dedicated user without asking again,
|
||||
* 5. re-executes Amber under the dedicated user.
|
||||
*
|
||||
* The launcher never accepts arbitrary arguments or environment: it execs
|
||||
* exactly the Amber binary it was generated for, so the sudoers rule cannot
|
||||
* be reused to run anything else as the dedicated user.
|
||||
*
|
||||
* Escapes: `AMBER_DISABLE_DEDICATED_USER=1` skips the whole flow, and
|
||||
* `AMBER_USER=name` picks a different OS user name. Bare `java` launches
|
||||
* (`./gradlew :desktop:run`) are skipped — use the packaged image.
|
||||
*/
|
||||
object DedicatedUser {
|
||||
private const val ENV_DISABLE = "AMBER_DISABLE_DEDICATED_USER"
|
||||
private const val ENV_USER = "AMBER_USER"
|
||||
|
||||
/** `$` for building shell scripts inside Kotlin templates. */
|
||||
private const val D = "$"
|
||||
|
||||
private fun wrapperPath(name: String) = "/usr/local/bin/amber-runas-$name"
|
||||
private fun sudoersPath(name: String) = "/etc/sudoers.d/amber-runas-$name"
|
||||
|
||||
val isLinux: Boolean = System.getProperty("os.name").lowercase().let {
|
||||
it.contains("linux") || it.contains("nix") || it.contains("nux")
|
||||
}
|
||||
|
||||
/** Name of the OS user that runs Amber. */
|
||||
val userName: String get() = System.getenv(ENV_USER) ?: "amber"
|
||||
|
||||
sealed interface State {
|
||||
/** Already running as the dedicated user, or the feature does not apply. */
|
||||
data object Active : State
|
||||
|
||||
/** Needs (re-)setup; [stale] is true when only the launcher is outdated. */
|
||||
data class SetupNeeded(val stale: Boolean) : State
|
||||
|
||||
/** A working launcher exists: switch without asking for a password. */
|
||||
data class Ready(val command: List<String>) : State
|
||||
}
|
||||
|
||||
/** Inspects this process and returns what to do before starting the app. */
|
||||
fun detect(): State {
|
||||
if (!isLinux || System.getenv(ENV_DISABLE) == "1") return State.Active
|
||||
if (currentUserName() == userName) return State.Active
|
||||
val exe = currentExecutable() ?: return State.Active
|
||||
if (File(exe).name == "java") return State.Active // dev run under Gradle
|
||||
|
||||
val wrapper = File(wrapperPath(userName))
|
||||
val userOk = userExists(userName)
|
||||
val launcherOk = try {
|
||||
wrapper.isFile && wrapper.canRead() && wrapper.readText().contains(exe)
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
return when {
|
||||
userOk && launcherOk -> State.Ready(relaunchCommand(wrapper.absolutePath))
|
||||
else -> State.SetupNeeded(stale = userOk)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the sudo setup with the given password (fed to `sudo -S`) and
|
||||
* returns true when the dedicated user, launcher, and sudoers rule are
|
||||
* all in place.
|
||||
*/
|
||||
fun setup(password: CharArray): Boolean {
|
||||
val session = sessionArgs()
|
||||
return try {
|
||||
val script = rootSetupScript(
|
||||
name = userName,
|
||||
currentUserName = currentUserName(),
|
||||
exePath = currentExecutable() ?: return false,
|
||||
wrapperPath = wrapperPath(userName),
|
||||
sudoersPath = sudoersPath(userName),
|
||||
dataDir = AppDirs.dataDir.absolutePath,
|
||||
amberDataDir = amberDataDir(userName),
|
||||
xdgRuntimeDir = session[0],
|
||||
waylandDisplay = session[1],
|
||||
x11Socket = x11SocketPath(session[2], session[0]),
|
||||
)
|
||||
val process = ProcessBuilder("sudo", "-k", "-S", "-p", "", "bash", "-s")
|
||||
.redirectErrorStream(true)
|
||||
.start()
|
||||
process.outputStream.use { out ->
|
||||
out.write(String(password).toByteArray(Charsets.UTF_8))
|
||||
out.write('\n'.code)
|
||||
out.write(script.toByteArray(Charsets.UTF_8))
|
||||
}
|
||||
val output = process.inputStream.readBytes().toString(Charsets.UTF_8)
|
||||
val ok = process.waitFor() == 0
|
||||
if (!ok) AmberLogger.d("DedicatedUser", "Setup failed: ${output.take(500)}")
|
||||
ok
|
||||
} catch (e: Exception) {
|
||||
AmberLogger.d("DedicatedUser", "Setup failed: ${e.message}")
|
||||
false
|
||||
} finally {
|
||||
password.fill('\u0000')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawns [command] (the launcher as the dedicated user) and reports
|
||||
* whether the child looks alive — the caller then exits this process.
|
||||
*/
|
||||
fun relaunch(command: List<String>): Boolean = try {
|
||||
val child = ProcessBuilder(command).start()
|
||||
Thread.sleep(400)
|
||||
child.isAlive
|
||||
} catch (e: Exception) {
|
||||
AmberLogger.d("DedicatedUser", "Relaunch failed: ${e.message}")
|
||||
false
|
||||
}
|
||||
|
||||
/** The command to switch to the dedicated user for the current session. */
|
||||
fun relaunchCommand(wrapperPath: String): List<String> = listOf("sudo", "-n", "-u", userName, wrapperPath) + sessionArgs()
|
||||
|
||||
// ----- pure generators (unit-tested) -----
|
||||
|
||||
fun isValidUserName(name: String): Boolean = Regex("[a-z_][a-z0-9_-]{0,31}").matches(name)
|
||||
|
||||
fun wrapperScript(exePath: String, home: String, name: String): String = """
|
||||
#!/bin/sh
|
||||
# Generated by Amber; do not edit.
|
||||
# Runs the Amber desktop app as its dedicated user. Only the session
|
||||
# socket locations are passed as arguments — never arbitrary code or env.
|
||||
set -e
|
||||
XDG_RUNTIME_DIR='${D}1'
|
||||
WAYLAND_DISPLAY='${D}2'
|
||||
DISPLAY='${D}3'
|
||||
DBUS_SESSION_BUS_ADDRESS='${D}4'
|
||||
HOME='$home'
|
||||
USER='$name'
|
||||
LOGNAME='$name'
|
||||
export XDG_RUNTIME_DIR WAYLAND_DISPLAY DISPLAY DBUS_SESSION_BUS_ADDRESS HOME USER LOGNAME
|
||||
exec '$exePath'
|
||||
""".trimIndent() + "\n"
|
||||
|
||||
fun sudoersRule(invoker: String, name: String, wrapperPath: String): String = "$invoker ALL=($name) NOPASSWD: $wrapperPath\n"
|
||||
|
||||
/**
|
||||
* The root script piped to `sudo bash -s`. Every interpolated value is a
|
||||
* system path or a validated user name; the launcher and sudoers payloads
|
||||
* are written via quoted heredocs so nothing inside them is expanded.
|
||||
*/
|
||||
fun rootSetupScript(
|
||||
name: String,
|
||||
currentUserName: String,
|
||||
exePath: String,
|
||||
wrapperPath: String,
|
||||
sudoersPath: String,
|
||||
dataDir: String,
|
||||
amberDataDir: String,
|
||||
xdgRuntimeDir: String,
|
||||
waylandDisplay: String,
|
||||
x11Socket: String?,
|
||||
): String {
|
||||
require(isValidUserName(name)) { "Invalid user name: $name" }
|
||||
val sudoersTmp = "$sudoersPath.tmp"
|
||||
val launcher = wrapperScript(exePath = exePath, home = homeOf(name), name = name)
|
||||
val rule = sudoersRule(currentUserName, name, wrapperPath)
|
||||
// Payloads are inserted AFTER trimIndent: interpolating them into the
|
||||
// template would reset the common indentation to zero (their lines are
|
||||
// flush-left), leaving the heredoc terminators indented and the
|
||||
// heredocs swallowing the rest of the script.
|
||||
return """
|
||||
set -e
|
||||
if ! id -u '$name' >/dev/null 2>&1; then
|
||||
nologin_bin="$D(command -v nologin || echo /bin/false)"
|
||||
useradd --create-home --shell "${D}nologin_bin" '$name'
|
||||
fi
|
||||
if [ -d '$dataDir' ] && [ ! -e '$amberDataDir' ]; then
|
||||
mkdir -p "$D(dirname '$amberDataDir')"
|
||||
cp -a '$dataDir' '$amberDataDir'
|
||||
# 'user:' sets the owner and defaults the group to the user's
|
||||
# login group (avoids nested quoting around a command
|
||||
# substitution here).
|
||||
chown -R "$name:" '$amberDataDir'
|
||||
fi
|
||||
cat > '$wrapperPath' <<'WRAPPER_EOF'
|
||||
AMBER_WRAPPER_PAYLOAD
|
||||
WRAPPER_EOF
|
||||
chown root:root '$wrapperPath'
|
||||
chmod 0755 '$wrapperPath'
|
||||
cat > '$sudoersTmp' <<'SUDOERS_EOF'
|
||||
AMBER_SUDOERS_PAYLOAD
|
||||
SUDOERS_EOF
|
||||
chown root:root '$sudoersTmp'
|
||||
chmod 0440 '$sudoersTmp'
|
||||
if command -v visudo >/dev/null 2>&1; then
|
||||
visudo -cf '$sudoersTmp' >/dev/null
|
||||
fi
|
||||
mv '$sudoersTmp' '$sudoersPath'
|
||||
# Let the dedicated user reach this session's sockets: X11/XWayland,
|
||||
# Wayland, and D-Bus (tray + notifications).
|
||||
if command -v setfacl >/dev/null 2>&1; then
|
||||
setfacl -m 'u:$name:rx' '$xdgRuntimeDir' || true
|
||||
[ -S '$xdgRuntimeDir/$waylandDisplay' ] && setfacl -m 'u:$name:rw' '$xdgRuntimeDir/$waylandDisplay' || true
|
||||
[ -n '${x11Socket ?: ""}' ] && [ -S '$x11Socket' ] && setfacl -m 'u:$name:rw' '$x11Socket' || true
|
||||
[ -S '$xdgRuntimeDir/bus' ] && setfacl -m 'u:$name:rw' '$xdgRuntimeDir/bus' || true
|
||||
fi
|
||||
""".trimIndent()
|
||||
.replace("AMBER_WRAPPER_PAYLOAD\n", launcher + "\n")
|
||||
.replace("AMBER_SUDOERS_PAYLOAD\n", rule) + "\n"
|
||||
}
|
||||
|
||||
// ----- environment helpers -----
|
||||
|
||||
internal fun amberDataDir(name: String): String = Path.of(homeOf(name), ".local", "share", "amber").toString()
|
||||
|
||||
internal fun x11SocketPath(display: String?, xdgRuntimeDir: String): String? {
|
||||
if (display.isNullOrBlank() || !display.startsWith(":")) return null
|
||||
val number = display.drop(1).substringBefore('.')
|
||||
return "$xdgRuntimeDir/.X11-unix/X$number"
|
||||
}
|
||||
|
||||
private fun currentUserName(): String = System.getProperty("user.name")
|
||||
|
||||
private fun currentExecutable(): String? = try {
|
||||
Files.readSymbolicLink(Path.of("/proc/self/exe")).toString()
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
private fun userExists(name: String): Boolean = try {
|
||||
ProcessBuilder("getent", "passwd", name).start().waitFor() == 0
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
|
||||
private fun homeOf(name: String): String = "/home/$name"
|
||||
|
||||
internal fun sessionArgs(): List<String> = listOf(
|
||||
System.getenv("XDG_RUNTIME_DIR") ?: "/run/user/${uid()}",
|
||||
System.getenv("WAYLAND_DISPLAY") ?: "",
|
||||
System.getenv("DISPLAY") ?: "",
|
||||
System.getenv("DBUS_SESSION_BUS_ADDRESS") ?: "",
|
||||
)
|
||||
|
||||
private fun uid(): String = ProcessHandle.current().let { _ ->
|
||||
try {
|
||||
val lines = java.nio.file.Files.readAllLines(Path.of("/proc/self/status"))
|
||||
lines.firstOrNull { it.startsWith("Uid:") }?.split(Regex("\\s+"))?.get(1) ?: "1000"
|
||||
} catch (_: Exception) {
|
||||
"1000"
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-17
@@ -110,23 +110,7 @@ object DesktopKeyStore {
|
||||
OsCredentialStore().delete()
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-creates the unprotected storage (keystore + credential-store/file
|
||||
* password) from [key] when the passphrase lock is removed.
|
||||
*/
|
||||
internal suspend fun recreateUnprotectedStore(key: SecretKey): Unit = mutex.withLock {
|
||||
val resolved = KeystorePassword.resolve(
|
||||
osStore = OsCredentialStore(),
|
||||
fileStore = FilePasswordStore(passwordFile),
|
||||
keystoreExists = false,
|
||||
opens = { false },
|
||||
)
|
||||
writeKeystore(key, resolved.password.toCharArray())
|
||||
cachedKey = key
|
||||
sourceDescription = resolved.source.description
|
||||
}
|
||||
|
||||
// ----- keystore-backed path (passphrase lock disabled) -----
|
||||
// ----- keystore-backed path (before the first passphrase is set) -----
|
||||
|
||||
private fun loadKeyStore(password: CharArray): KeyStore {
|
||||
val keyStore = KeyStore.getInstance("PKCS12")
|
||||
|
||||
@@ -173,8 +173,8 @@ data class DesktopSettings(
|
||||
),
|
||||
val currentAccount: String = "",
|
||||
val darkTheme: Boolean? = null,
|
||||
/** Auto-lock delay for the passphrase lock, in minutes; 0 = never. */
|
||||
val autoLockMinutes: Int = 0,
|
||||
/** Auto-lock delay for the passphrase lock, in minutes; 0 = never. Defaults to 1 hour. */
|
||||
val autoLockMinutes: Int = 60,
|
||||
/** Keep running in the system tray when the window is closed. */
|
||||
val closeToTray: Boolean = true,
|
||||
/** Show a system notification when a request needs approval. */
|
||||
|
||||
+14
-17
@@ -20,20 +20,24 @@ import org.bouncycastle.crypto.generators.Argon2BytesGenerator
|
||||
import org.bouncycastle.crypto.params.Argon2Parameters
|
||||
|
||||
/**
|
||||
* Optional passphrase lock: when enabled, the master AES key exists on disk
|
||||
* only wrapped (AES-256-GCM) under a key derived from the user's passphrase
|
||||
* with Argon2id. The passphrase itself is never stored anywhere — neither
|
||||
* the data directory nor the OS credential store is enough to decrypt the
|
||||
* Mandatory passphrase lock: the master AES key exists on disk only wrapped
|
||||
* (AES-256-GCM) under a key derived from the user's passphrase with
|
||||
* Argon2id. The passphrase itself is never stored anywhere — neither the
|
||||
* data directory nor the OS credential store is enough to decrypt the
|
||||
* account keys, which is the strongest protection a portable desktop app
|
||||
* can offer against same-user malware reading files at rest.
|
||||
*
|
||||
* Fresh installs set the passphrase before the app is usable ([Status.DISABLED]
|
||||
* is only ever that transient pre-setup state); there is no way back to
|
||||
* unprotected storage.
|
||||
*
|
||||
* While unlocked, the unwrapped master key (and the decrypted account keys)
|
||||
* live in this process's memory; locking evicts them and disconnects the
|
||||
* relay client.
|
||||
*/
|
||||
object PassphraseLock {
|
||||
enum class Status {
|
||||
/** No passphrase configured; the keystore + credential store path is used. */
|
||||
/** Passphrase not configured yet (first run); setup is required. */
|
||||
DISABLED,
|
||||
|
||||
/** Passphrase configured, master key not in memory. Nothing can be signed. */
|
||||
@@ -99,6 +103,11 @@ object PassphraseLock {
|
||||
return true
|
||||
}
|
||||
|
||||
/** Checks the passphrase without installing the master key. */
|
||||
suspend fun verify(passphrase: CharArray): Boolean = mutex.withLock {
|
||||
isEnabled() && unwrap(passphrase) != null
|
||||
}
|
||||
|
||||
/**
|
||||
* Evicts all key material from memory and disconnects from the relays.
|
||||
* Incoming NIP-46 requests cannot be decrypted (let alone signed) until
|
||||
@@ -114,18 +123,6 @@ object PassphraseLock {
|
||||
state.value = Status.LOCKED
|
||||
}
|
||||
|
||||
/** Removes the lock, restoring the keystore + credential-store storage. */
|
||||
suspend fun disable() = mutex.withLock {
|
||||
check(state.value == Status.UNLOCKED) { "Unlock first" }
|
||||
val key = DesktopKeyStore.masterKeyForWrapping()
|
||||
DesktopKeyStore.recreateUnprotectedStore(key)
|
||||
blobFile.delete()
|
||||
// isEnabled() is now false, so rewrite every account's database as plaintext.
|
||||
AmberDesktop.rewriteAllStores()
|
||||
autoLockJob?.cancel()
|
||||
state.value = Status.DISABLED
|
||||
}
|
||||
|
||||
/** Rewraps the master key under a new passphrase; false when [old] is wrong. */
|
||||
suspend fun changePassphrase(old: CharArray, new: CharArray, params: KdfParams = KdfParams()): Boolean = mutex.withLock {
|
||||
val key = unwrap(old) ?: return false
|
||||
|
||||
@@ -100,9 +100,19 @@ fun App() {
|
||||
}
|
||||
|
||||
val lockStatus by PassphraseLock.state.collectAsState()
|
||||
if (lockStatus == PassphraseLock.Status.LOCKED) {
|
||||
UnlockScreen()
|
||||
return
|
||||
when (lockStatus) {
|
||||
PassphraseLock.Status.LOCKED -> {
|
||||
UnlockScreen()
|
||||
return
|
||||
}
|
||||
|
||||
// First run: no passphrase yet. Nothing else may load until it is set.
|
||||
PassphraseLock.Status.DISABLED -> {
|
||||
PassphraseSetupScreen()
|
||||
return
|
||||
}
|
||||
|
||||
PassphraseLock.Status.UNLOCKED -> {}
|
||||
}
|
||||
|
||||
if (acc == null || addingAccount) {
|
||||
|
||||
@@ -208,22 +208,50 @@ fun SettingsScreen(account: DesktopAccount) {
|
||||
LogsDialog(account) { showLogsDialog = false }
|
||||
}
|
||||
showLogoutConfirm?.let { npub ->
|
||||
var logoutPassphrase by remember { mutableStateOf("") }
|
||||
var loggingOut by remember { mutableStateOf(false) }
|
||||
AlertDialog(
|
||||
onDismissRequest = { showLogoutConfirm = null },
|
||||
onDismissRequest = { if (!loggingOut) showLogoutConfirm = null },
|
||||
title = { Text(Strings.get("d_log_out_q", language)) },
|
||||
text = { Text(Strings.get("d_log_out_confirm", language)) },
|
||||
text = {
|
||||
Column {
|
||||
Text(Strings.get("d_log_out_confirm", language))
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(Strings.get("d_log_out_passphrase", language))
|
||||
Spacer(Modifier.height(4.dp))
|
||||
OutlinedTextField(
|
||||
value = logoutPassphrase,
|
||||
onValueChange = { logoutPassphrase = it },
|
||||
label = { Text(Strings.get("d_passphrase", language)) },
|
||||
singleLine = true,
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
enabled = !loggingOut,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
AmberTextButton(
|
||||
text = Strings.get("d_log_out", language),
|
||||
text = if (loggingOut) Strings.get("d_working", language) else Strings.get("d_log_out", language),
|
||||
enabled = !loggingOut,
|
||||
onClick = {
|
||||
showLogoutConfirm = null
|
||||
scope.launch { Session.logout(npub) }
|
||||
loggingOut = true
|
||||
scope.launch {
|
||||
if (PassphraseLock.verify(logoutPassphrase.toCharArray())) {
|
||||
showLogoutConfirm = null
|
||||
Session.logout(npub)
|
||||
} else {
|
||||
Toaster.toast(Strings.get("d_wrong_passphrase", language))
|
||||
}
|
||||
loggingOut = false
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
dismissButton = {
|
||||
AmberTextButton(
|
||||
text = Strings.get("cancel", language),
|
||||
enabled = !loggingOut,
|
||||
onClick = { showLogoutConfirm = null },
|
||||
)
|
||||
},
|
||||
@@ -280,94 +308,47 @@ private fun SettingSwitch(
|
||||
|
||||
@Composable
|
||||
private fun SecuritySection() {
|
||||
val scope = rememberCoroutineScope()
|
||||
val lockStatus by PassphraseLock.state.collectAsState()
|
||||
val settings by SettingsStore.settings.collectAsState()
|
||||
val language by Strings.currentLanguage.collectAsState()
|
||||
var dialog by remember { mutableStateOf<PassphraseDialogMode?>(null) }
|
||||
var showRemoveConfirm by remember { mutableStateOf(false) }
|
||||
var showChangeDialog by remember { mutableStateOf(false) }
|
||||
|
||||
if (lockStatus == PassphraseLock.Status.DISABLED) {
|
||||
Text(
|
||||
Strings.get("d_passphrase_desc", language),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
AmberOutlinedButton(
|
||||
text = Strings.get("d_lock_now", language),
|
||||
onClick = { PassphraseLock.lock() },
|
||||
)
|
||||
AmberButton(text = Strings.get("d_set_passphrase", language), onClick = { dialog = PassphraseDialogMode.SET })
|
||||
} else {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
AmberOutlinedButton(
|
||||
text = Strings.get("d_lock_now", language),
|
||||
onClick = { PassphraseLock.lock() },
|
||||
AmberOutlinedButton(
|
||||
text = Strings.get("d_change_passphrase", language),
|
||||
onClick = { showChangeDialog = true },
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(Strings.get("d_lock_after", language), style = MaterialTheme.typography.bodySmall)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
listOf(
|
||||
0 to Strings.get("d_lock_never", language),
|
||||
5 to Strings.get("d_lock_5min", language),
|
||||
15 to Strings.get("d_lock_15min", language),
|
||||
60 to Strings.get("d_lock_1hour", language),
|
||||
).forEach { (minutes, label) ->
|
||||
FilterChip(
|
||||
selected = settings.autoLockMinutes == minutes,
|
||||
onClick = {
|
||||
SettingsStore.update { it.copy(autoLockMinutes = minutes) }
|
||||
PassphraseLock.touch()
|
||||
},
|
||||
label = { Text(label) },
|
||||
)
|
||||
AmberOutlinedButton(
|
||||
text = Strings.get("d_change_passphrase", language),
|
||||
onClick = { dialog = PassphraseDialogMode.CHANGE },
|
||||
)
|
||||
AmberOutlinedButton(
|
||||
text = Strings.get("d_remove_passphrase", language),
|
||||
onClick = { showRemoveConfirm = true },
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(Strings.get("d_lock_after", language), style = MaterialTheme.typography.bodySmall)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
listOf(
|
||||
0 to Strings.get("d_lock_never", language),
|
||||
5 to Strings.get("d_lock_5min", language),
|
||||
15 to Strings.get("d_lock_15min", language),
|
||||
60 to Strings.get("d_lock_1hour", language),
|
||||
).forEach { (minutes, label) ->
|
||||
FilterChip(
|
||||
selected = settings.autoLockMinutes == minutes,
|
||||
onClick = {
|
||||
SettingsStore.update { it.copy(autoLockMinutes = minutes) }
|
||||
PassphraseLock.touch()
|
||||
},
|
||||
label = { Text(label) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dialog?.let { mode ->
|
||||
PassphraseDialog(mode) { dialog = null }
|
||||
}
|
||||
if (showRemoveConfirm) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showRemoveConfirm = false },
|
||||
title = { Text(Strings.get("d_remove_passphrase_q", language)) },
|
||||
text = {
|
||||
Text(Strings.get("d_remove_passphrase_desc", language))
|
||||
},
|
||||
confirmButton = {
|
||||
AmberTextButton(
|
||||
text = Strings.get("remove", language),
|
||||
onClick = {
|
||||
showRemoveConfirm = false
|
||||
scope.launch {
|
||||
PassphraseLock.disable()
|
||||
Toaster.toast(Strings.get("d_passphrase_removed", language))
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
dismissButton = {
|
||||
AmberTextButton(
|
||||
text = Strings.get("cancel", language),
|
||||
onClick = { showRemoveConfirm = false },
|
||||
)
|
||||
},
|
||||
)
|
||||
if (showChangeDialog) {
|
||||
ChangePassphraseDialog { showChangeDialog = false }
|
||||
}
|
||||
}
|
||||
|
||||
private enum class PassphraseDialogMode { SET, CHANGE }
|
||||
|
||||
@Composable
|
||||
private fun PassphraseDialog(
|
||||
mode: PassphraseDialogMode,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
private fun ChangePassphraseDialog(onDismiss: () -> Unit) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val language by Strings.currentLanguage.collectAsState()
|
||||
var current by remember { mutableStateOf("") }
|
||||
@@ -377,7 +358,7 @@ private fun PassphraseDialog(
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = { if (!working) onDismiss() },
|
||||
title = { Text(if (mode == PassphraseDialogMode.SET) Strings.get("d_set_passphrase", language) else Strings.get("d_change_the_passphrase", language)) },
|
||||
title = { Text(Strings.get("d_change_the_passphrase", language)) },
|
||||
text = {
|
||||
Column {
|
||||
Text(
|
||||
@@ -385,17 +366,15 @@ private fun PassphraseDialog(
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
if (mode == PassphraseDialogMode.CHANGE) {
|
||||
OutlinedTextField(
|
||||
value = current,
|
||||
onValueChange = { current = it },
|
||||
label = { Text(Strings.get("d_current_passphrase", language)) },
|
||||
singleLine = true,
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = current,
|
||||
onValueChange = { current = it },
|
||||
label = { Text(Strings.get("d_current_passphrase", language)) },
|
||||
singleLine = true,
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
OutlinedTextField(
|
||||
value = new,
|
||||
onValueChange = { new = it },
|
||||
@@ -431,17 +410,11 @@ private fun PassphraseDialog(
|
||||
working = true
|
||||
scope.launch {
|
||||
try {
|
||||
if (mode == PassphraseDialogMode.SET) {
|
||||
PassphraseLock.enable(new.toCharArray())
|
||||
Toaster.toast(Strings.get("d_passphrase_set", language))
|
||||
if (PassphraseLock.changePassphrase(current.toCharArray(), new.toCharArray())) {
|
||||
Toaster.toast(Strings.get("d_passphrase_changed", language))
|
||||
onDismiss()
|
||||
} else {
|
||||
if (PassphraseLock.changePassphrase(current.toCharArray(), new.toCharArray())) {
|
||||
Toaster.toast(Strings.get("d_passphrase_changed", language))
|
||||
onDismiss()
|
||||
} else {
|
||||
Toaster.toast(Strings.get("d_wrong_current_passphrase", language))
|
||||
}
|
||||
Toaster.toast(Strings.get("d_wrong_current_passphrase", language))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Toaster.toast(e.message ?: Strings.get("d_failed_update_passphrase", language))
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.greenart7c3.nostrsigner.desktop.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
@@ -52,6 +53,113 @@ fun UnlockScreen() {
|
||||
}
|
||||
}
|
||||
|
||||
LockScreenScaffold(subtitle = Strings.get("d_locked", language)) {
|
||||
OutlinedTextField(
|
||||
value = passphrase,
|
||||
onValueChange = { passphrase = it },
|
||||
label = { Text(Strings.get("d_passphrase", language)) },
|
||||
singleLine = true,
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
error?.let {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
Spacer(Modifier.height(16.dp))
|
||||
AmberButton(
|
||||
text = if (working) Strings.get("d_unlocking", language) else Strings.get("d_unlock", language),
|
||||
fillWidth = true,
|
||||
enabled = passphrase.isNotEmpty() && !working,
|
||||
onClick = ::submit,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* First-run gate: Amber refuses to start until a passphrase is set. There is
|
||||
* no way past this screen and no way back to unprotected key storage.
|
||||
*/
|
||||
@Composable
|
||||
fun PassphraseSetupScreen() {
|
||||
val scope = rememberCoroutineScope()
|
||||
val language by Strings.currentLanguage.collectAsState()
|
||||
var passphrase by remember { mutableStateOf("") }
|
||||
var confirm by remember { mutableStateOf("") }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
var working by remember { mutableStateOf(false) }
|
||||
|
||||
fun submit() {
|
||||
if (working) return
|
||||
error = null
|
||||
if (passphrase.length < 8) {
|
||||
error = Strings.get("d_use_8_chars", language)
|
||||
return
|
||||
}
|
||||
if (passphrase != confirm) {
|
||||
error = Strings.get("d_passphrases_no_match", language)
|
||||
return
|
||||
}
|
||||
working = true
|
||||
scope.launch {
|
||||
try {
|
||||
PassphraseLock.enable(passphrase.toCharArray())
|
||||
passphrase = ""
|
||||
confirm = ""
|
||||
} catch (e: Exception) {
|
||||
error = e.message ?: Strings.get("d_failed_update_passphrase", language)
|
||||
}
|
||||
working = false
|
||||
}
|
||||
}
|
||||
|
||||
LockScreenScaffold(subtitle = Strings.get("d_passphrase_desc", language)) {
|
||||
OutlinedTextField(
|
||||
value = passphrase,
|
||||
onValueChange = { passphrase = it },
|
||||
label = { Text(Strings.get("d_new_passphrase", language)) },
|
||||
singleLine = true,
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
OutlinedTextField(
|
||||
value = confirm,
|
||||
onValueChange = { confirm = it },
|
||||
label = { Text(Strings.get("d_repeat_passphrase", language)) },
|
||||
singleLine = true,
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
error?.let {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
Spacer(Modifier.height(16.dp))
|
||||
AmberButton(
|
||||
text = if (working) Strings.get("d_working", language) else Strings.get("d_set_passphrase", language),
|
||||
fillWidth = true,
|
||||
enabled = !working,
|
||||
onClick = ::submit,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
Strings.get("d_passphrase_never_stored", language),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Shared centered layout for the lock and first-run passphrase screens. */
|
||||
@Composable
|
||||
private fun LockScreenScaffold(
|
||||
subtitle: String,
|
||||
content: @Composable ColumnScope.() -> Unit,
|
||||
) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Column(
|
||||
Modifier.widthIn(max = 480.dp).padding(24.dp),
|
||||
@@ -62,28 +170,10 @@ fun UnlockScreen() {
|
||||
style = MaterialTheme.typography.headlineLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Text(Strings.get("d_locked", language), style = MaterialTheme.typography.bodyMedium)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(subtitle, style = MaterialTheme.typography.bodyMedium)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
OutlinedTextField(
|
||||
value = passphrase,
|
||||
onValueChange = { passphrase = it },
|
||||
label = { Text(Strings.get("d_passphrase", language)) },
|
||||
singleLine = true,
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
error?.let {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
Spacer(Modifier.height(16.dp))
|
||||
AmberButton(
|
||||
text = if (working) Strings.get("d_unlocking", language) else Strings.get("d_unlock", language),
|
||||
fillWidth = true,
|
||||
enabled = passphrase.isNotEmpty() && !working,
|
||||
onClick = ::submit,
|
||||
)
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
package com.greenart7c3.nostrsigner.desktop.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Window
|
||||
import androidx.compose.ui.window.application
|
||||
import androidx.compose.ui.window.rememberWindowState
|
||||
import com.greenart7c3.nostrsigner.desktop.core.DedicatedUser
|
||||
import com.greenart7c3.nostrsigner.desktop.core.Strings
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* Blocking first-open flow on Linux: asks for the sudo password, creates the
|
||||
* dedicated OS user with its launcher + sudoers rule, and re-launches Amber
|
||||
* under that user. Returns true when a dedicated-user process has been
|
||||
* started (the caller must exit); false when the user closed the window
|
||||
* without completing the setup.
|
||||
*/
|
||||
fun runUserSetupWindow(): Boolean {
|
||||
var switched = false
|
||||
application {
|
||||
val windowState = rememberWindowState(width = 560.dp, height = 400.dp)
|
||||
var password by remember { mutableStateOf("") }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
var working by remember { mutableStateOf(false) }
|
||||
val scope = rememberCoroutineScope()
|
||||
val language by Strings.currentLanguage.collectAsState()
|
||||
|
||||
Window(
|
||||
onCloseRequest = ::exitApplication,
|
||||
state = windowState,
|
||||
visible = true,
|
||||
title = "Amber",
|
||||
icon = painterResource("icon.png"),
|
||||
) {
|
||||
NostrSignerTheme {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Column(Modifier.widthIn(max = 480.dp).padding(24.dp)) {
|
||||
Text(
|
||||
Strings.get("d_dedicated_user_title", language),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
Strings.format("d_dedicated_user_desc", DedicatedUser.userName, language = language),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
OutlinedTextField(
|
||||
value = password,
|
||||
onValueChange = { password = it },
|
||||
label = { Text(Strings.get("d_dedicated_user_password", language)) },
|
||||
singleLine = true,
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||
enabled = !working,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
error?.let {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
it,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
AmberButton(
|
||||
text = if (working) Strings.get("d_working", language) else Strings.get("d_dedicated_user_set_up", language),
|
||||
enabled = !working && password.isNotEmpty(),
|
||||
onClick = {
|
||||
working = true
|
||||
error = null
|
||||
scope.launch {
|
||||
val ok = withContext(Dispatchers.IO) {
|
||||
DedicatedUser.setup(password.toCharArray())
|
||||
}
|
||||
password = ""
|
||||
if (!ok) {
|
||||
error = Strings.get("d_dedicated_user_failed", language)
|
||||
working = false
|
||||
} else {
|
||||
val command = (DedicatedUser.detect() as? DedicatedUser.State.Ready)?.command
|
||||
if (command != null && DedicatedUser.relaunch(command)) {
|
||||
switched = true
|
||||
exitApplication()
|
||||
} else {
|
||||
error = Strings.get("d_dedicated_user_failed", language)
|
||||
working = false
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
AmberOutlinedButton(
|
||||
text = Strings.get("cancel", language),
|
||||
enabled = !working,
|
||||
onClick = ::exitApplication,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return switched
|
||||
}
|
||||
@@ -785,19 +785,22 @@
|
||||
<string name="d_view_logs">Protokolle anzeigen</string>
|
||||
<string name="d_log_out_q">Abmelden?</string>
|
||||
<string name="d_log_out_confirm">Dadurch werden der Kontoschlüssel und seine Verbindungen von diesem Gerät gelöscht. Stelle sicher, dass der Schlüssel gesichert ist.</string>
|
||||
<string name="d_passphrase_desc">Lege eine Passphrase fest, damit deine Schlüssel selbst gegen Software verschlüsselt bleiben, die deine Dateien lesen kann. Du wirst beim Start von Amber danach gefragt.</string>
|
||||
<string name="d_log_out_passphrase">Gib deine Passphrase ein, um dich abzumelden</string>
|
||||
<string name="d_passphrase_desc">Amber benötigt eine Passphrase. Deine Schlüssel bleiben auf der Festplatte verschlüsselt, selbst gegenüber Software, die deine Dateien lesen kann – du wirst beim Start von Amber und nach jedem Sperren danach gefragt.</string>
|
||||
<string name="d_setup_title">Schütze deine Schlüssel</string>
|
||||
<string name="d_dedicated_user_title">Amber unter einem eigenen Benutzer ausführen</string>
|
||||
<string name="d_dedicated_user_desc">Amber erstellt den Benutzer „%1$s“ und führt stets unter ihm aus, damit andere Apps auf diesem Computer nicht seinen Speicher oder seine Dateien lesen können. Deine vorhandenen Amber-Daten werden in dessen Home-Verzeichnis verschoben. Gib dein Passwort (sudo) ein, um das einzurichten.</string>
|
||||
<string name="d_dedicated_user_password">Dein Passwort (sudo)</string>
|
||||
<string name="d_dedicated_user_set_up">Einrichten und neu starten</string>
|
||||
<string name="d_dedicated_user_failed">Einrichtung fehlgeschlagen – prüfe dein Passwort und versuche es erneut</string>
|
||||
<string name="d_set_passphrase">Passphrase festlegen</string>
|
||||
<string name="d_lock_now">Jetzt sperren</string>
|
||||
<string name="d_change_passphrase">Passphrase ändern</string>
|
||||
<string name="d_remove_passphrase">Passphrase entfernen</string>
|
||||
<string name="d_lock_after">Automatisch sperren nach</string>
|
||||
<string name="d_lock_never">Nie</string>
|
||||
<string name="d_lock_5min">5 Min.</string>
|
||||
<string name="d_lock_15min">15 Min.</string>
|
||||
<string name="d_lock_1hour">1 Stunde</string>
|
||||
<string name="d_remove_passphrase_q">Passphrase entfernen?</string>
|
||||
<string name="d_remove_passphrase_desc">Der Verschlüsselungsschlüssel wird wieder im Anmeldedatenspeicher des Betriebssystems (oder in einer lokalen Datei) abgelegt, und Amber fragt beim Start nicht mehr nach einer Passphrase.</string>
|
||||
<string name="d_passphrase_removed">Passphrase entfernt</string>
|
||||
<string name="d_change_the_passphrase">Passphrase ändern</string>
|
||||
<string name="d_passphrase_never_stored">Die Passphrase wird nirgendwo gespeichert. Wenn du sie vergisst, ist der einzige Weg zurück die Wiederherstellung deiner Schlüssel aus einer Sicherung (nsec oder Seed-Wörter).</string>
|
||||
<string name="d_current_passphrase">Aktuelle Passphrase</string>
|
||||
@@ -805,7 +808,6 @@
|
||||
<string name="d_repeat_passphrase">Neue Passphrase wiederholen</string>
|
||||
<string name="d_use_8_chars">Verwende mindestens 8 Zeichen</string>
|
||||
<string name="d_passphrases_no_match">Die Passphrasen stimmen nicht überein</string>
|
||||
<string name="d_passphrase_set">Passphrase festgelegt</string>
|
||||
<string name="d_passphrase_changed">Passphrase geändert</string>
|
||||
<string name="d_wrong_current_passphrase">Falsche aktuelle Passphrase</string>
|
||||
<string name="d_failed_update_passphrase">Passphrase konnte nicht aktualisiert werden</string>
|
||||
|
||||
@@ -796,19 +796,22 @@
|
||||
<string name="d_view_logs">View logs</string>
|
||||
<string name="d_log_out_q">Log out?</string>
|
||||
<string name="d_log_out_confirm">This deletes the account key and its connections from this device. Make sure the key is backed up.</string>
|
||||
<string name="d_passphrase_desc">Set a passphrase to keep your keys encrypted even against software that can read your files. You will be asked for it when Amber starts.</string>
|
||||
<string name="d_log_out_passphrase">Enter your passphrase to log out</string>
|
||||
<string name="d_passphrase_desc">Amber requires a passphrase. Your keys stay encrypted at rest, even against software that can read your files — you will be asked for it when Amber starts and whenever it locks.</string>
|
||||
<string name="d_setup_title">Protect your keys</string>
|
||||
<string name="d_dedicated_user_title">Run Amber under its own user</string>
|
||||
<string name="d_dedicated_user_desc">Amber will create the OS user “%1$s” and always run under it, so other apps on this computer cannot read its memory or files. Your existing Amber data is moved to that user’s home. Enter your password (sudo) to set this up.</string>
|
||||
<string name="d_dedicated_user_password">Your password (sudo)</string>
|
||||
<string name="d_dedicated_user_set_up">Set up and restart</string>
|
||||
<string name="d_dedicated_user_failed">Setup failed — check your password and try again</string>
|
||||
<string name="d_set_passphrase">Set a passphrase</string>
|
||||
<string name="d_lock_now">Lock now</string>
|
||||
<string name="d_change_passphrase">Change passphrase</string>
|
||||
<string name="d_remove_passphrase">Remove passphrase</string>
|
||||
<string name="d_lock_after">Lock automatically after</string>
|
||||
<string name="d_lock_never">Never</string>
|
||||
<string name="d_lock_5min">5 min</string>
|
||||
<string name="d_lock_15min">15 min</string>
|
||||
<string name="d_lock_1hour">1 hour</string>
|
||||
<string name="d_remove_passphrase_q">Remove the passphrase?</string>
|
||||
<string name="d_remove_passphrase_desc">The encryption key will go back to the OS credential store (or a local file), and Amber will no longer ask for a passphrase at startup.</string>
|
||||
<string name="d_passphrase_removed">Passphrase removed</string>
|
||||
<string name="d_change_the_passphrase">Change the passphrase</string>
|
||||
<string name="d_passphrase_never_stored">The passphrase is never stored anywhere. If you forget it, the only way back in is restoring your keys from a backup (nsec or seed words).</string>
|
||||
<string name="d_current_passphrase">Current passphrase</string>
|
||||
@@ -816,7 +819,6 @@
|
||||
<string name="d_repeat_passphrase">Repeat the new passphrase</string>
|
||||
<string name="d_use_8_chars">Use at least 8 characters</string>
|
||||
<string name="d_passphrases_no_match">The passphrases do not match</string>
|
||||
<string name="d_passphrase_set">Passphrase set</string>
|
||||
<string name="d_passphrase_changed">Passphrase changed</string>
|
||||
<string name="d_wrong_current_passphrase">Wrong current passphrase</string>
|
||||
<string name="d_failed_update_passphrase">Failed to update the passphrase</string>
|
||||
|
||||
@@ -788,19 +788,22 @@
|
||||
<string name="d_view_logs">Ver registros</string>
|
||||
<string name="d_log_out_q">¿Cerrar sesión?</string>
|
||||
<string name="d_log_out_confirm">Esto elimina la clave de la cuenta y sus conexiones de este dispositivo. Asegúrate de tener una copia de seguridad de la clave.</string>
|
||||
<string name="d_passphrase_desc">Establece una frase de contraseña para mantener tus claves cifradas incluso frente a software que pueda leer tus archivos. Se te pedirá al iniciar Amber.</string>
|
||||
<string name="d_log_out_passphrase">Introduce tu frase de contraseña para cerrar sesión</string>
|
||||
<string name="d_passphrase_desc">Amber requiere una frase de contraseña. Tus claves permanecen cifradas en reposo, incluso frente a software que pueda leer tus archivos; se te pedirá al iniciar Amber y cada vez que se bloquee.</string>
|
||||
<string name="d_setup_title">Protege tus claves</string>
|
||||
<string name="d_dedicated_user_title">Ejecutar Amber con su propio usuario</string>
|
||||
<string name="d_dedicated_user_desc">Amber creará el usuario del sistema «%1$s» y se ejecutará siempre con él, para que otras aplicaciones de este equipo no puedan leer su memoria ni sus archivos. Tus datos existentes de Amber se mueven a la carpeta personal de ese usuario. Introduce tu contraseña (sudo) para configurarlo.</string>
|
||||
<string name="d_dedicated_user_password">Tu contraseña (sudo)</string>
|
||||
<string name="d_dedicated_user_set_up">Configurar y reiniciar</string>
|
||||
<string name="d_dedicated_user_failed">La configuración falló; comprueba tu contraseña e inténtalo de nuevo</string>
|
||||
<string name="d_set_passphrase">Establecer una frase de contraseña</string>
|
||||
<string name="d_lock_now">Bloquear ahora</string>
|
||||
<string name="d_change_passphrase">Cambiar frase de contraseña</string>
|
||||
<string name="d_remove_passphrase">Eliminar frase de contraseña</string>
|
||||
<string name="d_lock_after">Bloquear automáticamente tras</string>
|
||||
<string name="d_lock_never">Nunca</string>
|
||||
<string name="d_lock_5min">5 min</string>
|
||||
<string name="d_lock_15min">15 min</string>
|
||||
<string name="d_lock_1hour">1 hora</string>
|
||||
<string name="d_remove_passphrase_q">¿Eliminar la frase de contraseña?</string>
|
||||
<string name="d_remove_passphrase_desc">La clave de cifrado volverá al almacén de credenciales del sistema operativo (o a un archivo local), y Amber ya no pedirá una frase de contraseña al iniciar.</string>
|
||||
<string name="d_passphrase_removed">Frase de contraseña eliminada</string>
|
||||
<string name="d_change_the_passphrase">Cambiar la frase de contraseña</string>
|
||||
<string name="d_passphrase_never_stored">La frase de contraseña nunca se almacena en ningún sitio. Si la olvidas, la única forma de recuperar el acceso es restaurar tus claves desde una copia de seguridad (nsec o palabras semilla).</string>
|
||||
<string name="d_current_passphrase">Frase de contraseña actual</string>
|
||||
@@ -808,7 +811,6 @@
|
||||
<string name="d_repeat_passphrase">Repite la nueva frase de contraseña</string>
|
||||
<string name="d_use_8_chars">Usa al menos 8 caracteres</string>
|
||||
<string name="d_passphrases_no_match">Las frases de contraseña no coinciden</string>
|
||||
<string name="d_passphrase_set">Frase de contraseña establecida</string>
|
||||
<string name="d_passphrase_changed">Frase de contraseña cambiada</string>
|
||||
<string name="d_wrong_current_passphrase">Frase de contraseña actual incorrecta</string>
|
||||
<string name="d_failed_update_passphrase">No se pudo actualizar la frase de contraseña</string>
|
||||
|
||||
@@ -785,19 +785,22 @@
|
||||
<string name="d_view_logs">Afficher les journaux</string>
|
||||
<string name="d_log_out_q">Se déconnecter ?</string>
|
||||
<string name="d_log_out_confirm">Cela supprime la clé du compte et ses connexions de cet appareil. Assurez-vous que la clé est sauvegardée.</string>
|
||||
<string name="d_passphrase_desc">Définissez une phrase secrète pour garder vos clés chiffrées, même face à un logiciel capable de lire vos fichiers. Elle vous sera demandée au démarrage d'Amber.</string>
|
||||
<string name="d_log_out_passphrase">Saisissez votre phrase secrète pour vous déconnecter</string>
|
||||
<string name="d_passphrase_desc">Amber exige une phrase secrète. Vos clés restent chiffrées au repos, même face à un logiciel capable de lire vos fichiers ; elle vous sera demandée au démarrage d'Amber et après chaque verrouillage.</string>
|
||||
<string name="d_setup_title">Protégez vos clés</string>
|
||||
<string name="d_dedicated_user_title">Exécuter Amber sous son propre utilisateur</string>
|
||||
<string name="d_dedicated_user_desc">Amber va créer l’utilisateur système « %1$s » et s’exécutera toujours sous celui-ci, afin que les autres applications de cet ordinateur ne puissent pas lire sa mémoire ni ses fichiers. Vos données Amber existantes sont déplacées vers le dossier personnel de cet utilisateur. Saisissez votre mot de passe (sudo) pour configurer cela.</string>
|
||||
<string name="d_dedicated_user_password">Votre mot de passe (sudo)</string>
|
||||
<string name="d_dedicated_user_set_up">Configurer et redémarrer</string>
|
||||
<string name="d_dedicated_user_failed">Échec de la configuration — vérifiez votre mot de passe et réessayez</string>
|
||||
<string name="d_set_passphrase">Définir une phrase secrète</string>
|
||||
<string name="d_lock_now">Verrouiller maintenant</string>
|
||||
<string name="d_change_passphrase">Changer la phrase secrète</string>
|
||||
<string name="d_remove_passphrase">Supprimer la phrase secrète</string>
|
||||
<string name="d_lock_after">Verrouiller automatiquement après</string>
|
||||
<string name="d_lock_never">Jamais</string>
|
||||
<string name="d_lock_5min">5 min</string>
|
||||
<string name="d_lock_15min">15 min</string>
|
||||
<string name="d_lock_1hour">1 heure</string>
|
||||
<string name="d_remove_passphrase_q">Supprimer la phrase secrète ?</string>
|
||||
<string name="d_remove_passphrase_desc">La clé de chiffrement retournera dans le gestionnaire d'identifiants du système d'exploitation (ou un fichier local), et Amber ne demandera plus de phrase secrète au démarrage.</string>
|
||||
<string name="d_passphrase_removed">Phrase secrète supprimée</string>
|
||||
<string name="d_change_the_passphrase">Changer la phrase secrète</string>
|
||||
<string name="d_passphrase_never_stored">La phrase secrète n'est jamais stockée où que ce soit. Si vous l'oubliez, le seul moyen de récupérer l'accès est de restaurer vos clés depuis une sauvegarde (nsec ou mots de récupération).</string>
|
||||
<string name="d_current_passphrase">Phrase secrète actuelle</string>
|
||||
@@ -805,7 +808,6 @@
|
||||
<string name="d_repeat_passphrase">Répétez la nouvelle phrase secrète</string>
|
||||
<string name="d_use_8_chars">Utilisez au moins 8 caractères</string>
|
||||
<string name="d_passphrases_no_match">Les phrases secrètes ne correspondent pas</string>
|
||||
<string name="d_passphrase_set">Phrase secrète définie</string>
|
||||
<string name="d_passphrase_changed">Phrase secrète changée</string>
|
||||
<string name="d_wrong_current_passphrase">Phrase secrète actuelle incorrecte</string>
|
||||
<string name="d_failed_update_passphrase">Échec de la mise à jour de la phrase secrète</string>
|
||||
|
||||
@@ -788,19 +788,22 @@
|
||||
<string name="d_view_logs">Lihat log</string>
|
||||
<string name="d_log_out_q">Keluar?</string>
|
||||
<string name="d_log_out_confirm">Ini menghapus kunci akun dan koneksinya dari perangkat ini. Pastikan kunci sudah dicadangkan.</string>
|
||||
<string name="d_passphrase_desc">Tetapkan frasa sandi agar kunci Anda tetap terenkripsi bahkan dari perangkat lunak yang dapat membaca berkas Anda. Anda akan dimintai frasa sandi saat Amber dimulai.</string>
|
||||
<string name="d_log_out_passphrase">Masukkan frasa sandi Anda untuk keluar</string>
|
||||
<string name="d_passphrase_desc">Amber membutuhkan frasa sandi. Kunci Anda tetap terenkripsi saat disimpan, bahkan dari perangkat lunak yang dapat membaca berkas Anda — Anda akan dimintai frasa sandi saat Amber dimulai dan setiap kali terkunci.</string>
|
||||
<string name="d_setup_title">Lindungi kunci Anda</string>
|
||||
<string name="d_dedicated_user_title">Jalankan Amber dengan penggunanya sendiri</string>
|
||||
<string name="d_dedicated_user_desc">Amber akan membuat pengguna OS “%1$s” dan selalu berjalan dengannya, sehingga aplikasi lain di komputer ini tidak dapat membaca memorinya atau berkasnya. Data Amber Anda yang ada dipindahkan ke direktori rumah pengguna tersebut. Masukkan kata sandi Anda (sudo) untuk menyiapkannya.</string>
|
||||
<string name="d_dedicated_user_password">Kata sandi Anda (sudo)</string>
|
||||
<string name="d_dedicated_user_set_up">Siapkan dan mulai ulang</string>
|
||||
<string name="d_dedicated_user_failed">Penyiapan gagal — periksa kata sandi Anda dan coba lagi</string>
|
||||
<string name="d_set_passphrase">Tetapkan frasa sandi</string>
|
||||
<string name="d_lock_now">Kunci sekarang</string>
|
||||
<string name="d_change_passphrase">Ubah frasa sandi</string>
|
||||
<string name="d_remove_passphrase">Hapus frasa sandi</string>
|
||||
<string name="d_lock_after">Kunci otomatis setelah</string>
|
||||
<string name="d_lock_never">Tidak pernah</string>
|
||||
<string name="d_lock_5min">5 mnt</string>
|
||||
<string name="d_lock_15min">15 mnt</string>
|
||||
<string name="d_lock_1hour">1 jam</string>
|
||||
<string name="d_remove_passphrase_q">Hapus frasa sandi?</string>
|
||||
<string name="d_remove_passphrase_desc">Kunci enkripsi akan kembali ke penyimpanan kredensial OS (atau berkas lokal), dan Amber tidak akan lagi meminta frasa sandi saat mulai.</string>
|
||||
<string name="d_passphrase_removed">Frasa sandi dihapus</string>
|
||||
<string name="d_change_the_passphrase">Ubah frasa sandi</string>
|
||||
<string name="d_passphrase_never_stored">Frasa sandi tidak pernah disimpan di mana pun. Jika Anda lupa, satu-satunya cara masuk kembali adalah memulihkan kunci dari cadangan (nsec atau kata benih).</string>
|
||||
<string name="d_current_passphrase">Frasa sandi saat ini</string>
|
||||
@@ -808,7 +811,6 @@
|
||||
<string name="d_repeat_passphrase">Ulangi frasa sandi baru</string>
|
||||
<string name="d_use_8_chars">Gunakan minimal 8 karakter</string>
|
||||
<string name="d_passphrases_no_match">Frasa sandi tidak cocok</string>
|
||||
<string name="d_passphrase_set">Frasa sandi ditetapkan</string>
|
||||
<string name="d_passphrase_changed">Frasa sandi diubah</string>
|
||||
<string name="d_wrong_current_passphrase">Frasa sandi saat ini salah</string>
|
||||
<string name="d_failed_update_passphrase">Gagal memperbarui frasa sandi</string>
|
||||
|
||||
@@ -788,19 +788,22 @@
|
||||
<string name="d_view_logs">Visualizza i log</string>
|
||||
<string name="d_log_out_q">Uscire?</string>
|
||||
<string name="d_log_out_confirm">Questa operazione elimina la chiave dell'account e le sue connessioni da questo dispositivo. Assicurati che la chiave sia salvata in un backup.</string>
|
||||
<string name="d_passphrase_desc">Imposta una passphrase per mantenere le tue chiavi cifrate anche contro software in grado di leggere i tuoi file. Ti verrà richiesta all'avvio di Amber.</string>
|
||||
<string name="d_log_out_passphrase">Inserisci la tua passphrase per uscire</string>
|
||||
<string name="d_passphrase_desc">Amber richiede una passphrase. Le tue chiavi restano cifrate a riposo, anche contro software in grado di leggere i tuoi file: ti verrà richiesta all'avvio di Amber e a ogni blocco.</string>
|
||||
<string name="d_setup_title">Proteggi le tue chiavi</string>
|
||||
<string name="d_dedicated_user_title">Esegui Amber con un utente dedicato</string>
|
||||
<string name="d_dedicated_user_desc">Amber creerà l’utente di sistema “%1$s” e verrà eseguito sempre con esso, così le altre app di questo computer non potranno leggere la sua memoria o i suoi file. I tuoi dati Amber esistenti vengono spostati nella home di quell’utente. Inserisci la tua password (sudo) per configurarlo.</string>
|
||||
<string name="d_dedicated_user_password">La tua password (sudo)</string>
|
||||
<string name="d_dedicated_user_set_up">Configura e riavvia</string>
|
||||
<string name="d_dedicated_user_failed">Configurazione non riuscita — controlla la password e riprova</string>
|
||||
<string name="d_set_passphrase">Imposta una passphrase</string>
|
||||
<string name="d_lock_now">Blocca ora</string>
|
||||
<string name="d_change_passphrase">Cambia passphrase</string>
|
||||
<string name="d_remove_passphrase">Rimuovi passphrase</string>
|
||||
<string name="d_lock_after">Blocca automaticamente dopo</string>
|
||||
<string name="d_lock_never">Mai</string>
|
||||
<string name="d_lock_5min">5 min</string>
|
||||
<string name="d_lock_15min">15 min</string>
|
||||
<string name="d_lock_1hour">1 ora</string>
|
||||
<string name="d_remove_passphrase_q">Rimuovere la passphrase?</string>
|
||||
<string name="d_remove_passphrase_desc">La chiave di cifratura tornerà nell'archivio credenziali del sistema operativo (o in un file locale) e Amber non chiederà più una passphrase all'avvio.</string>
|
||||
<string name="d_passphrase_removed">Passphrase rimossa</string>
|
||||
<string name="d_change_the_passphrase">Cambia la passphrase</string>
|
||||
<string name="d_passphrase_never_stored">La passphrase non viene mai memorizzata da nessuna parte. Se la dimentichi, l'unico modo per rientrare è ripristinare le tue chiavi da un backup (nsec o seed words).</string>
|
||||
<string name="d_current_passphrase">Passphrase attuale</string>
|
||||
@@ -808,7 +811,6 @@
|
||||
<string name="d_repeat_passphrase">Ripeti la nuova passphrase</string>
|
||||
<string name="d_use_8_chars">Usa almeno 8 caratteri</string>
|
||||
<string name="d_passphrases_no_match">Le passphrase non corrispondono</string>
|
||||
<string name="d_passphrase_set">Passphrase impostata</string>
|
||||
<string name="d_passphrase_changed">Passphrase modificata</string>
|
||||
<string name="d_wrong_current_passphrase">Passphrase attuale errata</string>
|
||||
<string name="d_failed_update_passphrase">Impossibile aggiornare la passphrase</string>
|
||||
|
||||
@@ -764,19 +764,22 @@
|
||||
<string name="d_view_logs">ログを表示</string>
|
||||
<string name="d_log_out_q">ログアウトしますか?</string>
|
||||
<string name="d_log_out_confirm">この操作でアカウントの鍵とその接続がこのデバイスから削除されます。鍵をバックアップしてあることを確認してください。</string>
|
||||
<string name="d_passphrase_desc">パスフレーズを設定すると、ファイルを読み取れるソフトウェアに対しても鍵を暗号化したまま保護できます。Amber の起動時にパスフレーズの入力を求められます。</string>
|
||||
<string name="d_log_out_passphrase">ログアウトするにはパスフレーズを入力してください</string>
|
||||
<string name="d_passphrase_desc">Amber ではパスフレーズが必須です。ファイルを読み取れるソフトウェアに対しても、鍵は保存時に暗号化されたまま保たれます。Amber の起動時とロック解除時にパスフレーズの入力を求められます。</string>
|
||||
<string name="d_setup_title">鍵を保護</string>
|
||||
<string name="d_dedicated_user_title">Amber を専用ユーザーで実行する</string>
|
||||
<string name="d_dedicated_user_desc">Amber は OS ユーザー「%1$s」を作成し、常にそのユーザーで実行します。これにより、このコンピューター上の他のアプリはメモリやファイルを読み取れなくなります。既存の Amber データはそのユーザーのホームに移動されます。設定するにはパスワード(sudo)を入力してください。</string>
|
||||
<string name="d_dedicated_user_password">あなたのパスワード(sudo)</string>
|
||||
<string name="d_dedicated_user_set_up">設定して再起動</string>
|
||||
<string name="d_dedicated_user_failed">セットアップに失敗しました。パスワードを確認してもう一度お試しください</string>
|
||||
<string name="d_set_passphrase">パスフレーズを設定</string>
|
||||
<string name="d_lock_now">今すぐロック</string>
|
||||
<string name="d_change_passphrase">パスフレーズを変更</string>
|
||||
<string name="d_remove_passphrase">パスフレーズを削除</string>
|
||||
<string name="d_lock_after">自動ロックまでの時間</string>
|
||||
<string name="d_lock_never">しない</string>
|
||||
<string name="d_lock_5min">5 分</string>
|
||||
<string name="d_lock_15min">15 分</string>
|
||||
<string name="d_lock_1hour">1 時間</string>
|
||||
<string name="d_remove_passphrase_q">パスフレーズを削除しますか?</string>
|
||||
<string name="d_remove_passphrase_desc">暗号化鍵は OS の資格情報ストア(またはローカルファイル)に戻され、Amber は起動時にパスフレーズを求めなくなります。</string>
|
||||
<string name="d_passphrase_removed">パスフレーズを削除しました</string>
|
||||
<string name="d_change_the_passphrase">パスフレーズを変更</string>
|
||||
<string name="d_passphrase_never_stored">パスフレーズはどこにも保存されません。忘れた場合、復旧する唯一の方法はバックアップ(nsec またはシードワード)から鍵を復元することです。</string>
|
||||
<string name="d_current_passphrase">現在のパスフレーズ</string>
|
||||
@@ -784,7 +787,6 @@
|
||||
<string name="d_repeat_passphrase">新しいパスフレーズを再入力</string>
|
||||
<string name="d_use_8_chars">8 文字以上で入力してください</string>
|
||||
<string name="d_passphrases_no_match">パスフレーズが一致しません</string>
|
||||
<string name="d_passphrase_set">パスフレーズを設定しました</string>
|
||||
<string name="d_passphrase_changed">パスフレーズを変更しました</string>
|
||||
<string name="d_wrong_current_passphrase">現在のパスフレーズが違います</string>
|
||||
<string name="d_failed_update_passphrase">パスフレーズの更新に失敗しました</string>
|
||||
|
||||
@@ -788,19 +788,22 @@
|
||||
<string name="d_view_logs">로그 보기</string>
|
||||
<string name="d_log_out_q">로그아웃할까요?</string>
|
||||
<string name="d_log_out_confirm">이 기기에서 계정 키와 연결이 삭제됩니다. 키가 백업되어 있는지 확인하세요.</string>
|
||||
<string name="d_passphrase_desc">파일을 읽을 수 있는 소프트웨어로부터도 키를 암호화된 상태로 유지하려면 암호를 설정하세요. Amber를 시작할 때 이 암호를 입력해야 합니다.</string>
|
||||
<string name="d_log_out_passphrase">로그아웃하려면 암호를 입력하세요</string>
|
||||
<string name="d_passphrase_desc">Amber에는 암호가 필요합니다. 파일을 읽을 수 있는 소프트웨어로부터도 키는 저장 시 암호화된 상태로 유지됩니다. Amber를 시작할 때와 잠금 해제 시 암호를 입력해야 합니다.</string>
|
||||
<string name="d_setup_title">키 보호</string>
|
||||
<string name="d_dedicated_user_title">Amber를 전용 사용자로 실행</string>
|
||||
<string name="d_dedicated_user_desc">Amber는 OS 사용자 “%1$s”을(를) 만들고 항상 해당 사용자로 실행되어, 이 컴퓨터의 다른 앱이 메모리나 파일을 읽을 수 없게 합니다. 기존 Amber 데이터는 해당 사용자의 홈으로 이동됩니다. 설정하려면 비밀번호(sudo)를 입력하세요.</string>
|
||||
<string name="d_dedicated_user_password">비밀번호(sudo)</string>
|
||||
<string name="d_dedicated_user_set_up">설정 후 다시 시작</string>
|
||||
<string name="d_dedicated_user_failed">설정에 실패했습니다. 비밀번호를 확인하고 다시 시도하세요</string>
|
||||
<string name="d_set_passphrase">암호 설정</string>
|
||||
<string name="d_lock_now">지금 잠그기</string>
|
||||
<string name="d_change_passphrase">암호 변경</string>
|
||||
<string name="d_remove_passphrase">암호 제거</string>
|
||||
<string name="d_lock_after">자동 잠금 시간</string>
|
||||
<string name="d_lock_never">안 함</string>
|
||||
<string name="d_lock_5min">5분</string>
|
||||
<string name="d_lock_15min">15분</string>
|
||||
<string name="d_lock_1hour">1시간</string>
|
||||
<string name="d_remove_passphrase_q">암호를 제거할까요?</string>
|
||||
<string name="d_remove_passphrase_desc">암호화 키가 OS 자격 증명 저장소(또는 로컬 파일)로 되돌아가며, Amber는 시작 시 더 이상 암호를 요청하지 않습니다.</string>
|
||||
<string name="d_passphrase_removed">암호가 제거됨</string>
|
||||
<string name="d_change_the_passphrase">암호 변경</string>
|
||||
<string name="d_passphrase_never_stored">암호는 어디에도 저장되지 않습니다. 잊어버린 경우 유일한 복구 방법은 백업(nsec 또는 시드 단어)에서 키를 복원하는 것입니다.</string>
|
||||
<string name="d_current_passphrase">현재 암호</string>
|
||||
@@ -808,7 +811,6 @@
|
||||
<string name="d_repeat_passphrase">새 암호 다시 입력</string>
|
||||
<string name="d_use_8_chars">최소 8자 이상 사용하세요</string>
|
||||
<string name="d_passphrases_no_match">암호가 일치하지 않습니다</string>
|
||||
<string name="d_passphrase_set">암호가 설정됨</string>
|
||||
<string name="d_passphrase_changed">암호가 변경됨</string>
|
||||
<string name="d_wrong_current_passphrase">현재 암호가 올바르지 않습니다</string>
|
||||
<string name="d_failed_update_passphrase">암호 업데이트에 실패했습니다</string>
|
||||
|
||||
@@ -783,19 +783,22 @@
|
||||
<string name="d_view_logs">Ver registros</string>
|
||||
<string name="d_log_out_q">Sair?</string>
|
||||
<string name="d_log_out_confirm">Isso exclui a chave da conta e suas conexões deste dispositivo. Certifique-se de que a chave esteja em backup.</string>
|
||||
<string name="d_passphrase_desc">Defina uma senha para manter suas chaves criptografadas mesmo contra softwares que possam ler seus arquivos. Ela será solicitada quando o Amber iniciar.</string>
|
||||
<string name="d_log_out_passphrase">Digite sua senha para sair</string>
|
||||
<string name="d_passphrase_desc">O Amber exige uma senha. Suas chaves permanecem criptografadas em repouso, mesmo contra softwares que possam ler seus arquivos — ela será solicitada quando o Amber iniciar e sempre que ele for bloqueado.</string>
|
||||
<string name="d_setup_title">Proteja suas chaves</string>
|
||||
<string name="d_dedicated_user_title">Executar o Amber com um usuário próprio</string>
|
||||
<string name="d_dedicated_user_desc">O Amber criará o usuário do sistema “%1$s” e sempre executará sob ele, para que outros aplicativos neste computador não possam ler a memória nem os arquivos dele. Seus dados existentes do Amber são movidos para a pasta pessoal desse usuário. Digite sua senha (sudo) para configurar.</string>
|
||||
<string name="d_dedicated_user_password">Sua senha (sudo)</string>
|
||||
<string name="d_dedicated_user_set_up">Configurar e reiniciar</string>
|
||||
<string name="d_dedicated_user_failed">Falha na configuração — verifique sua senha e tente novamente</string>
|
||||
<string name="d_set_passphrase">Definir uma senha</string>
|
||||
<string name="d_lock_now">Bloquear agora</string>
|
||||
<string name="d_change_passphrase">Alterar senha</string>
|
||||
<string name="d_remove_passphrase">Remover senha</string>
|
||||
<string name="d_lock_after">Bloquear automaticamente após</string>
|
||||
<string name="d_lock_never">Nunca</string>
|
||||
<string name="d_lock_5min">5 min</string>
|
||||
<string name="d_lock_15min">15 min</string>
|
||||
<string name="d_lock_1hour">1 hora</string>
|
||||
<string name="d_remove_passphrase_q">Remover a senha?</string>
|
||||
<string name="d_remove_passphrase_desc">A chave de criptografia voltará para o armazenamento de credenciais do sistema operacional (ou um arquivo local), e o Amber não pedirá mais uma senha na inicialização.</string>
|
||||
<string name="d_passphrase_removed">Senha removida</string>
|
||||
<string name="d_change_the_passphrase">Alterar a senha</string>
|
||||
<string name="d_passphrase_never_stored">A senha nunca é armazenada em lugar nenhum. Se você a esquecer, a única forma de recuperar o acesso é restaurar suas chaves a partir de um backup (nsec ou palavras-semente).</string>
|
||||
<string name="d_current_passphrase">Senha atual</string>
|
||||
@@ -803,7 +806,6 @@
|
||||
<string name="d_repeat_passphrase">Repita a nova senha</string>
|
||||
<string name="d_use_8_chars">Use pelo menos 8 caracteres</string>
|
||||
<string name="d_passphrases_no_match">As senhas não coincidem</string>
|
||||
<string name="d_passphrase_set">Senha definida</string>
|
||||
<string name="d_passphrase_changed">Senha alterada</string>
|
||||
<string name="d_wrong_current_passphrase">Senha atual incorreta</string>
|
||||
<string name="d_failed_update_passphrase">Falha ao atualizar a senha</string>
|
||||
|
||||
@@ -788,19 +788,22 @@
|
||||
<string name="d_view_logs">Просмотреть журналы</string>
|
||||
<string name="d_log_out_q">Выйти?</string>
|
||||
<string name="d_log_out_confirm">Это удалит ключ аккаунта и его подключения с этого устройства. Убедитесь, что ключ сохранён в резервной копии.</string>
|
||||
<string name="d_passphrase_desc">Задайте пароль-фразу, чтобы ваши ключи оставались зашифрованными даже от программ, способных читать ваши файлы. Amber запросит её при запуске.</string>
|
||||
<string name="d_log_out_passphrase">Введите пароль-фразу, чтобы выйти</string>
|
||||
<string name="d_passphrase_desc">Amber требует пароль-фразу. Ваши ключи остаются зашифрованными на диске, даже от программ, способных читать ваши файлы, — она запрашивается при запуске Amber и после каждой блокировки.</string>
|
||||
<string name="d_setup_title">Защитите свои ключи</string>
|
||||
<string name="d_dedicated_user_title">Запускать Amber под отдельным пользователем</string>
|
||||
<string name="d_dedicated_user_desc">Amber создаст системного пользователя «%1$s» и всегда будет работать под ним, чтобы другие приложения этого компьютера не могли читать его память и файлы. Существующие данные Amber переносятся в домашний каталог этого пользователя. Введите свой пароль (sudo) для настройки.</string>
|
||||
<string name="d_dedicated_user_password">Ваш пароль (sudo)</string>
|
||||
<string name="d_dedicated_user_set_up">Настроить и перезапустить</string>
|
||||
<string name="d_dedicated_user_failed">Не удалось настроить — проверьте пароль и попробуйте снова</string>
|
||||
<string name="d_set_passphrase">Задать пароль-фразу</string>
|
||||
<string name="d_lock_now">Заблокировать сейчас</string>
|
||||
<string name="d_change_passphrase">Изменить пароль-фразу</string>
|
||||
<string name="d_remove_passphrase">Удалить пароль-фразу</string>
|
||||
<string name="d_lock_after">Блокировать автоматически через</string>
|
||||
<string name="d_lock_never">Никогда</string>
|
||||
<string name="d_lock_5min">5 мин</string>
|
||||
<string name="d_lock_15min">15 мин</string>
|
||||
<string name="d_lock_1hour">1 час</string>
|
||||
<string name="d_remove_passphrase_q">Удалить пароль-фразу?</string>
|
||||
<string name="d_remove_passphrase_desc">Ключ шифрования вернётся в хранилище учётных данных ОС (или локальный файл), и Amber больше не будет запрашивать пароль-фразу при запуске.</string>
|
||||
<string name="d_passphrase_removed">Пароль-фраза удалена</string>
|
||||
<string name="d_change_the_passphrase">Изменить пароль-фразу</string>
|
||||
<string name="d_passphrase_never_stored">Пароль-фраза нигде не хранится. Если вы её забудете, единственный способ вернуться — восстановить ключи из резервной копии (nsec или сид-слова).</string>
|
||||
<string name="d_current_passphrase">Текущая пароль-фраза</string>
|
||||
@@ -808,7 +811,6 @@
|
||||
<string name="d_repeat_passphrase">Повторите новую пароль-фразу</string>
|
||||
<string name="d_use_8_chars">Используйте не менее 8 символов</string>
|
||||
<string name="d_passphrases_no_match">Пароль-фразы не совпадают</string>
|
||||
<string name="d_passphrase_set">Пароль-фраза задана</string>
|
||||
<string name="d_passphrase_changed">Пароль-фраза изменена</string>
|
||||
<string name="d_wrong_current_passphrase">Неверная текущая пароль-фраза</string>
|
||||
<string name="d_failed_update_passphrase">Не удалось обновить пароль-фразу</string>
|
||||
|
||||
@@ -764,19 +764,22 @@
|
||||
<string name="d_view_logs">ดูบันทึก</string>
|
||||
<string name="d_log_out_q">ออกจากระบบ?</string>
|
||||
<string name="d_log_out_confirm">การกระทำนี้จะลบกุญแจบัญชีและการเชื่อมต่อออกจากอุปกรณ์นี้ ตรวจสอบให้แน่ใจว่าได้สำรองกุญแจไว้แล้ว</string>
|
||||
<string name="d_passphrase_desc">ตั้งวลีรหัสผ่านเพื่อรักษากุญแจของคุณให้เข้ารหัสไว้ แม้กระทั่งกับซอฟต์แวร์ที่สามารถอ่านไฟล์ของคุณได้ คุณจะถูกถามวลีรหัสผ่านนี้เมื่อ Amber เริ่มทำงาน</string>
|
||||
<string name="d_log_out_passphrase">ป้อนวลีรหัสผ่านเพื่อออกจากระบบ</string>
|
||||
<string name="d_passphrase_desc">Amber กำหนดให้ต้องมีวลีรหัสผ่าน กุญแจของคุณจะถูกเข้ารหัสไว้ขณะจัดเก็บ แม้กระทั่งจากซอฟต์แวร์ที่อ่านไฟล์ของคุณได้ และคุณจะถูกถามวลีรหัสผ่านเมื่อ Amber เริ่มทำงานและเมื่อถูกล็อก</string>
|
||||
<string name="d_setup_title">ปกป้องกุญแจของคุณ</string>
|
||||
<string name="d_dedicated_user_title">เรียกใช้ Amber ภายใต้ผู้ใช้ของตัวเอง</string>
|
||||
<string name="d_dedicated_user_desc">Amber จะสร้างผู้ใช้ระบบ “%1$s” และทำงานภายใต้ผู้ใช้นั้นเสมอ เพื่อให้แอปอื่นในคอมพิวเตอร์เครื่องนี้อ่านหน่วยความจำหรือไฟล์ของมันไม่ได้ ข้อมูล Amber ที่มีอยู่ของคุณจะถูกย้ายไปที่โฮมของผู้ใช้นั้น ป้อนรหัสผ่านของคุณ (sudo) เพื่อตั้งค่า</string>
|
||||
<string name="d_dedicated_user_password">รหัสผ่านของคุณ (sudo)</string>
|
||||
<string name="d_dedicated_user_set_up">ตั้งค่าและรีสตาร์ท</string>
|
||||
<string name="d_dedicated_user_failed">การตั้งค่าล้มเหลว — ตรวจสอบรหัสผ่านแล้วลองอีกครั้ง</string>
|
||||
<string name="d_set_passphrase">ตั้งวลีรหัสผ่าน</string>
|
||||
<string name="d_lock_now">ล็อกทันที</string>
|
||||
<string name="d_change_passphrase">เปลี่ยนวลีรหัสผ่าน</string>
|
||||
<string name="d_remove_passphrase">ลบวลีรหัสผ่าน</string>
|
||||
<string name="d_lock_after">ล็อกอัตโนมัติหลังจาก</string>
|
||||
<string name="d_lock_never">ไม่เลย</string>
|
||||
<string name="d_lock_5min">5 นาที</string>
|
||||
<string name="d_lock_15min">15 นาที</string>
|
||||
<string name="d_lock_1hour">1 ชั่วโมง</string>
|
||||
<string name="d_remove_passphrase_q">ลบวลีรหัสผ่าน?</string>
|
||||
<string name="d_remove_passphrase_desc">กุญแจเข้ารหัสจะกลับไปยังที่จัดเก็บข้อมูลรับรองของระบบปฏิบัติการ (หรือไฟล์ในเครื่อง) และ Amber จะไม่ถามวลีรหัสผ่านเมื่อเริ่มทำงานอีกต่อไป</string>
|
||||
<string name="d_passphrase_removed">ลบวลีรหัสผ่านแล้ว</string>
|
||||
<string name="d_change_the_passphrase">เปลี่ยนวลีรหัสผ่าน</string>
|
||||
<string name="d_passphrase_never_stored">วลีรหัสผ่านจะไม่ถูกจัดเก็บไว้ที่ใดเลย หากคุณลืม วิธีเดียวที่จะกลับเข้าใช้งานได้คือการกู้คืนกุญแจจากข้อมูลสำรอง (nsec หรือคำกู้คืน)</string>
|
||||
<string name="d_current_passphrase">วลีรหัสผ่านปัจจุบัน</string>
|
||||
@@ -784,7 +787,6 @@
|
||||
<string name="d_repeat_passphrase">ยืนยันวลีรหัสผ่านใหม่</string>
|
||||
<string name="d_use_8_chars">ใช้อย่างน้อย 8 ตัวอักษร</string>
|
||||
<string name="d_passphrases_no_match">วลีรหัสผ่านไม่ตรงกัน</string>
|
||||
<string name="d_passphrase_set">ตั้งวลีรหัสผ่านแล้ว</string>
|
||||
<string name="d_passphrase_changed">เปลี่ยนวลีรหัสผ่านแล้ว</string>
|
||||
<string name="d_wrong_current_passphrase">วลีรหัสผ่านปัจจุบันไม่ถูกต้อง</string>
|
||||
<string name="d_failed_update_passphrase">อัปเดตวลีรหัสผ่านไม่สำเร็จ</string>
|
||||
|
||||
@@ -784,19 +784,22 @@
|
||||
<string name="d_view_logs">Günlükleri görüntüle</string>
|
||||
<string name="d_log_out_q">Çıkış yapılsın mı?</string>
|
||||
<string name="d_log_out_confirm">Bu işlem hesap anahtarını ve bağlantılarını bu cihazdan siler. Anahtarın yedeklendiğinden emin olun.</string>
|
||||
<string name="d_passphrase_desc">Anahtarlarınızı, dosyalarınızı okuyabilen yazılımlara karşı bile şifreli tutmak için bir parola belirleyin. Amber başlatıldığında bu parola istenecektir.</string>
|
||||
<string name="d_log_out_passphrase">Çıkış yapmak için parolanızı girin</string>
|
||||
<string name="d_passphrase_desc">Amber bir parola gerektirir. Anahtarlarınız, dosyalarınızı okuyabilen yazılımlara karşı bile diskte şifreli kalır; parola Amber başlatıldığında ve her kilitlenmeden sonra istenir.</string>
|
||||
<string name="d_setup_title">Anahtarlarınızı koruyun</string>
|
||||
<string name="d_dedicated_user_title">Amber'i kendi kullanıcısı altında çalıştır</string>
|
||||
<string name="d_dedicated_user_desc">Amber, “%1$s” işletim sistemi kullanıcısını oluşturacak ve her zaman onun altında çalışacak; böylece bu bilgisayardaki diğer uygulamalar belleğini veya dosyalarını okuyamayacak. Mevcut Amber verileriniz o kullanıcının ev dizinine taşınır. Kurulum için parolanızı (sudo) girin.</string>
|
||||
<string name="d_dedicated_user_password">Parolanız (sudo)</string>
|
||||
<string name="d_dedicated_user_set_up">Kur ve yeniden başlat</string>
|
||||
<string name="d_dedicated_user_failed">Kurulum başarısız oldu — parolanızı kontrol edip tekrar deneyin</string>
|
||||
<string name="d_set_passphrase">Parola belirle</string>
|
||||
<string name="d_lock_now">Şimdi kilitle</string>
|
||||
<string name="d_change_passphrase">Parolayı değiştir</string>
|
||||
<string name="d_remove_passphrase">Parolayı kaldır</string>
|
||||
<string name="d_lock_after">Şu süreden sonra otomatik kilitle</string>
|
||||
<string name="d_lock_never">Asla</string>
|
||||
<string name="d_lock_5min">5 dk</string>
|
||||
<string name="d_lock_15min">15 dk</string>
|
||||
<string name="d_lock_1hour">1 saat</string>
|
||||
<string name="d_remove_passphrase_q">Parola kaldırılsın mı?</string>
|
||||
<string name="d_remove_passphrase_desc">Şifreleme anahtarı işletim sistemi kimlik deposuna (veya yerel bir dosyaya) geri döner ve Amber başlangıçta artık parola istemez.</string>
|
||||
<string name="d_passphrase_removed">Parola kaldırıldı</string>
|
||||
<string name="d_change_the_passphrase">Parolayı değiştir</string>
|
||||
<string name="d_passphrase_never_stored">Parola hiçbir yerde saklanmaz. Unutursanız, geri dönmenin tek yolu anahtarlarınızı bir yedekten (nsec veya tohum kelimeleri) geri yüklemektir.</string>
|
||||
<string name="d_current_passphrase">Mevcut parola</string>
|
||||
@@ -804,7 +807,6 @@
|
||||
<string name="d_repeat_passphrase">Yeni parolayı tekrar girin</string>
|
||||
<string name="d_use_8_chars">En az 8 karakter kullanın</string>
|
||||
<string name="d_passphrases_no_match">Parolalar eşleşmiyor</string>
|
||||
<string name="d_passphrase_set">Parola belirlendi</string>
|
||||
<string name="d_passphrase_changed">Parola değiştirildi</string>
|
||||
<string name="d_wrong_current_passphrase">Mevcut parola yanlış</string>
|
||||
<string name="d_failed_update_passphrase">Parola güncellenemedi</string>
|
||||
|
||||
@@ -764,19 +764,22 @@
|
||||
<string name="d_view_logs">Xem nhật ký</string>
|
||||
<string name="d_log_out_q">Đăng xuất?</string>
|
||||
<string name="d_log_out_confirm">Thao tác này sẽ xóa khóa tài khoản và các kết nối của nó khỏi thiết bị này. Hãy chắc chắn rằng khóa đã được sao lưu.</string>
|
||||
<string name="d_passphrase_desc">Đặt cụm mật khẩu để giữ khóa của bạn được mã hóa ngay cả trước phần mềm có thể đọc tệp của bạn. Bạn sẽ được yêu cầu nhập nó khi Amber khởi động.</string>
|
||||
<string name="d_log_out_passphrase">Nhập cụm mật khẩu để đăng xuất</string>
|
||||
<string name="d_passphrase_desc">Amber yêu cầu có cụm mật khẩu. Khóa của bạn luôn được mã hóa khi lưu, ngay cả trước phần mềm có thể đọc tệp của bạn — bạn sẽ được yêu cầu nhập khi Amber khởi động và mỗi khi bị khóa.</string>
|
||||
<string name="d_setup_title">Bảo vệ khóa của bạn</string>
|
||||
<string name="d_dedicated_user_title">Chạy Amber dưới người dùng riêng</string>
|
||||
<string name="d_dedicated_user_desc">Amber sẽ tạo người dùng hệ thống “%1$s” và luôn chạy dưới người dùng đó, để các ứng dụng khác trên máy tính này không thể đọc bộ nhớ hay tệp của nó. Dữ liệu Amber hiện có của bạn được chuyển sang thư mục chính của người dùng đó. Nhập mật khẩu của bạn (sudo) để thiết lập.</string>
|
||||
<string name="d_dedicated_user_password">Mật khẩu của bạn (sudo)</string>
|
||||
<string name="d_dedicated_user_set_up">Thiết lập và khởi động lại</string>
|
||||
<string name="d_dedicated_user_failed">Thiết lập thất bại — kiểm tra mật khẩu và thử lại</string>
|
||||
<string name="d_set_passphrase">Đặt cụm mật khẩu</string>
|
||||
<string name="d_lock_now">Khóa ngay</string>
|
||||
<string name="d_change_passphrase">Đổi cụm mật khẩu</string>
|
||||
<string name="d_remove_passphrase">Xóa cụm mật khẩu</string>
|
||||
<string name="d_lock_after">Tự động khóa sau</string>
|
||||
<string name="d_lock_never">Không bao giờ</string>
|
||||
<string name="d_lock_5min">5 phút</string>
|
||||
<string name="d_lock_15min">15 phút</string>
|
||||
<string name="d_lock_1hour">1 giờ</string>
|
||||
<string name="d_remove_passphrase_q">Xóa cụm mật khẩu?</string>
|
||||
<string name="d_remove_passphrase_desc">Khóa mã hóa sẽ được trả lại kho lưu trữ thông tin đăng nhập của hệ điều hành (hoặc một tệp cục bộ), và Amber sẽ không còn yêu cầu cụm mật khẩu khi khởi động.</string>
|
||||
<string name="d_passphrase_removed">Đã xóa cụm mật khẩu</string>
|
||||
<string name="d_change_the_passphrase">Đổi cụm mật khẩu</string>
|
||||
<string name="d_passphrase_never_stored">Cụm mật khẩu không bao giờ được lưu ở bất kỳ đâu. Nếu bạn quên nó, cách duy nhất để truy cập lại là khôi phục khóa từ bản sao lưu (nsec hoặc các từ khôi phục).</string>
|
||||
<string name="d_current_passphrase">Cụm mật khẩu hiện tại</string>
|
||||
@@ -784,7 +787,6 @@
|
||||
<string name="d_repeat_passphrase">Nhập lại cụm mật khẩu mới</string>
|
||||
<string name="d_use_8_chars">Dùng ít nhất 8 ký tự</string>
|
||||
<string name="d_passphrases_no_match">Cụm mật khẩu không khớp</string>
|
||||
<string name="d_passphrase_set">Đã đặt cụm mật khẩu</string>
|
||||
<string name="d_passphrase_changed">Đã đổi cụm mật khẩu</string>
|
||||
<string name="d_wrong_current_passphrase">Cụm mật khẩu hiện tại không đúng</string>
|
||||
<string name="d_failed_update_passphrase">Không thể cập nhật cụm mật khẩu</string>
|
||||
|
||||
@@ -769,19 +769,22 @@
|
||||
<string name="d_view_logs">查看日志</string>
|
||||
<string name="d_log_out_q">退出登录?</string>
|
||||
<string name="d_log_out_confirm">这将从此设备删除账户密钥及其连接。请确保已备份密钥。</string>
|
||||
<string name="d_passphrase_desc">设置一个密码短语,即使面对能读取你文件的软件,也能让你的密钥保持加密。Amber 启动时会要求输入它。</string>
|
||||
<string name="d_log_out_passphrase">输入密码短语以退出登录</string>
|
||||
<string name="d_passphrase_desc">Amber 必须设置密码短语。即使面对能读取你文件的软件,密钥在存储时也保持加密——Amber 启动时和每次锁定后都会要求输入。</string>
|
||||
<string name="d_setup_title">保护你的密钥</string>
|
||||
<string name="d_dedicated_user_title">以专属用户运行 Amber</string>
|
||||
<string name="d_dedicated_user_desc">Amber 将创建系统用户“%1$s”并始终以该用户运行,这样这台电脑上的其他应用无法读取它的内存或文件。你现有的 Amber 数据会移动到该用户的主目录。输入你的密码(sudo)以完成设置。</string>
|
||||
<string name="d_dedicated_user_password">你的密码(sudo)</string>
|
||||
<string name="d_dedicated_user_set_up">设置并重启</string>
|
||||
<string name="d_dedicated_user_failed">设置失败——请检查密码后重试</string>
|
||||
<string name="d_set_passphrase">设置密码短语</string>
|
||||
<string name="d_lock_now">立即锁定</string>
|
||||
<string name="d_change_passphrase">更改密码短语</string>
|
||||
<string name="d_remove_passphrase">移除密码短语</string>
|
||||
<string name="d_lock_after">自动锁定于</string>
|
||||
<string name="d_lock_never">从不</string>
|
||||
<string name="d_lock_5min">5 分钟</string>
|
||||
<string name="d_lock_15min">15 分钟</string>
|
||||
<string name="d_lock_1hour">1 小时</string>
|
||||
<string name="d_remove_passphrase_q">移除密码短语?</string>
|
||||
<string name="d_remove_passphrase_desc">加密密钥将回到操作系统凭据存储(或本地文件),Amber 启动时将不再要求输入密码短语。</string>
|
||||
<string name="d_passphrase_removed">密码短语已移除</string>
|
||||
<string name="d_change_the_passphrase">更改密码短语</string>
|
||||
<string name="d_passphrase_never_stored">密码短语绝不会存储在任何地方。如果你忘记了它,唯一的找回方式是从备份(nsec 或助记词)恢复你的密钥。</string>
|
||||
<string name="d_current_passphrase">当前密码短语</string>
|
||||
@@ -789,7 +792,6 @@
|
||||
<string name="d_repeat_passphrase">重复输入新密码短语</string>
|
||||
<string name="d_use_8_chars">请至少使用 8 个字符</string>
|
||||
<string name="d_passphrases_no_match">两次输入的密码短语不一致</string>
|
||||
<string name="d_passphrase_set">密码短语已设置</string>
|
||||
<string name="d_passphrase_changed">密码短语已更改</string>
|
||||
<string name="d_wrong_current_passphrase">当前密码短语错误</string>
|
||||
<string name="d_failed_update_passphrase">更新密码短语失败</string>
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
package com.greenart7c3.nostrsigner.desktop
|
||||
|
||||
import com.greenart7c3.nostrsigner.desktop.core.AccountManager
|
||||
import com.greenart7c3.nostrsigner.desktop.core.AccountStore
|
||||
import com.greenart7c3.nostrsigner.desktop.core.AmberDesktop
|
||||
import com.greenart7c3.nostrsigner.desktop.core.AppDirs
|
||||
import com.greenart7c3.nostrsigner.desktop.core.AppRecord
|
||||
import com.greenart7c3.nostrsigner.desktop.core.AppWithPermissions
|
||||
import com.greenart7c3.nostrsigner.desktop.core.DesktopKeyStore
|
||||
import com.greenart7c3.nostrsigner.desktop.core.PassphraseLock
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import java.io.File
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.BeforeClass
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Verifies that the per-account database (applications/permissions/history/
|
||||
* logs) is encrypted at rest once a passphrase lock is set, and migrates
|
||||
* transparently in both directions.
|
||||
*/
|
||||
class DatabaseEncryptionTest {
|
||||
companion object {
|
||||
private val fastKdf = PassphraseLock.KdfParams(memoryKb = 1024, iterations = 1, parallelism = 1)
|
||||
|
||||
@JvmStatic
|
||||
@BeforeClass
|
||||
fun isolateDataDir() {
|
||||
val tmp = File.createTempFile("amber-db-enc", "").apply {
|
||||
delete()
|
||||
mkdirs()
|
||||
deleteOnExit()
|
||||
}
|
||||
System.setProperty("user.home", tmp.absolutePath)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun databaseIsEncryptedUnderPassphrase() = runBlocking {
|
||||
val marker = "SECRET_APP_NAME_9f3a"
|
||||
val account = AccountManager.addAccount(KeyPair(), name = "db")
|
||||
val store = AmberDesktop.store(account.npub)
|
||||
store.upsert(AppWithPermissions(app = AppRecord(key = "app-key-1", name = marker, pubKey = account.hexKey)))
|
||||
store.addLog("wss://relay.example.com", "bunker", "sensitive-log-line")
|
||||
|
||||
val appsFile = File(AppDirs.accountDir(account.npub), "applications.json")
|
||||
|
||||
// Before any passphrase: plaintext JSON on disk.
|
||||
assertTrue("app db should be plaintext without a passphrase", appsFile.readText().contains(marker))
|
||||
|
||||
// Enabling a passphrase re-encrypts the database at rest.
|
||||
PassphraseLock.enable("correct horse battery".toCharArray(), fastKdf)
|
||||
val encrypted = appsFile.readText()
|
||||
assertTrue("encrypted file should carry the marker header", encrypted.startsWith("AMBERENC1:"))
|
||||
assertFalse("plaintext app name must not survive in the encrypted file", encrypted.contains(marker))
|
||||
|
||||
// A fresh reader (simulating a restart) decrypts it back.
|
||||
val reloaded = AccountStore(account.npub)
|
||||
assertEquals(marker, reloaded.apps.value.first().app.name)
|
||||
|
||||
// Locking evicts the key: nothing can be written to the database.
|
||||
PassphraseLock.lock()
|
||||
assertFalse(DesktopKeyStore.isMasterKeyAvailable())
|
||||
|
||||
// Unlock restores access; disabling the lock rewrites plaintext.
|
||||
assertTrue(PassphraseLock.unlock("correct horse battery".toCharArray()))
|
||||
assertEquals(marker, AmberDesktop.store(account.npub).apps.value.first().app.name)
|
||||
|
||||
PassphraseLock.disable()
|
||||
assertFalse("db should be plaintext after removing the passphrase", appsFile.readText().startsWith("AMBERENC1:"))
|
||||
assertTrue(appsFile.readText().contains(marker))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package com.greenart7c3.nostrsigner.desktop
|
||||
|
||||
import com.greenart7c3.nostrsigner.desktop.core.DedicatedUser
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertThrows
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Covers the pure generators behind the dedicated-user setup: the generated
|
||||
* launcher must exec exactly one fixed binary (no argument pass-through, so
|
||||
* the sudoers rule cannot be reused to run anything else), and the root
|
||||
* script must create the user, migrate data, and validate the sudoers file.
|
||||
*/
|
||||
class DedicatedUserTest {
|
||||
private val dollar = "$"
|
||||
|
||||
@Test
|
||||
fun userNameValidation() {
|
||||
assertTrue(DedicatedUser.isValidUserName("amber"))
|
||||
assertTrue(DedicatedUser.isValidUserName("am-ber_1"))
|
||||
assertFalse(DedicatedUser.isValidUserName(""))
|
||||
assertFalse(DedicatedUser.isValidUserName("Amber"))
|
||||
assertFalse(DedicatedUser.isValidUserName("9amber"))
|
||||
assertFalse(DedicatedUser.isValidUserName("amber;rm -rf /"))
|
||||
assertFalse(DedicatedUser.isValidUserName("a".repeat(33)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun launcherExecsExactlyOneFixedBinary() {
|
||||
val script = DedicatedUser.wrapperScript(
|
||||
exePath = "/opt/Amber/bin/Amber",
|
||||
home = "/home/amber",
|
||||
name = "amber",
|
||||
)
|
||||
assertTrue(script.startsWith("#!/bin/sh"))
|
||||
assertTrue(script.contains("exec '/opt/Amber/bin/Amber'\n"))
|
||||
assertTrue(script.contains("HOME='/home/amber'"))
|
||||
assertTrue(script.contains("USER='amber'"))
|
||||
// Session socket locations arrive as positional arguments...
|
||||
assertTrue(script.contains("XDG_RUNTIME_DIR='" + dollar + "1'"))
|
||||
assertTrue(script.contains("DBUS_SESSION_BUS_ADDRESS='" + dollar + "4'"))
|
||||
// ...and nothing else is forwarded: no argument or env pass-through.
|
||||
assertFalse(script.contains("\"" + dollar + "@\""))
|
||||
assertFalse(script.contains("JDK_JAVA_OPTIONS"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sudoersRuleIsNarrow() {
|
||||
val rule = DedicatedUser.sudoersRule(
|
||||
invoker = "admin",
|
||||
name = "amber",
|
||||
wrapperPath = "/usr/local/bin/amber-runas-amber",
|
||||
)
|
||||
assertEquals("admin ALL=(amber) NOPASSWD: /usr/local/bin/amber-runas-amber\n", rule)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rootScriptCreatesUserMigratesDataAndValidates() {
|
||||
val script = DedicatedUser.rootSetupScript(
|
||||
name = "amber",
|
||||
currentUserName = "admin",
|
||||
exePath = "/opt/Amber/bin/Amber",
|
||||
wrapperPath = "/usr/local/bin/amber-runas-amber",
|
||||
sudoersPath = "/etc/sudoers.d/amber-runas-amber",
|
||||
dataDir = "/home/admin/.local/share/amber",
|
||||
amberDataDir = "/home/amber/.local/share/amber",
|
||||
xdgRuntimeDir = "/run/user/1000",
|
||||
waylandDisplay = "wayland-1",
|
||||
x11Socket = "/run/user/1000/.X11-unix/X0",
|
||||
)
|
||||
// User creation is guarded and idempotent.
|
||||
assertTrue(script.contains("if ! id -u 'amber' >/dev/null 2>&1; then"))
|
||||
assertTrue(script.contains("useradd --create-home"))
|
||||
// Existing data of the invoking user is migrated once, with ownership.
|
||||
assertTrue(script.contains("if [ -d '/home/admin/.local/share/amber' ] && [ ! -e '/home/amber/.local/share/amber' ]; then"))
|
||||
assertTrue(script.contains("cp -a '/home/admin/.local/share/amber' '/home/amber/.local/share/amber'"))
|
||||
assertTrue(script.contains("chown -R \"amber:\" '/home/amber/.local/share/amber'"))
|
||||
// Launcher + sudoers payloads go in via quoted heredocs (no expansion).
|
||||
assertTrue(script.contains("cat > '/usr/local/bin/amber-runas-amber' <<'WRAPPER_EOF'"))
|
||||
assertTrue(script.contains("<<'SUDOERS_EOF'"))
|
||||
assertTrue(script.contains("admin ALL=(amber) NOPASSWD: /usr/local/bin/amber-runas-amber\n"))
|
||||
// The sudoers file is syntax-checked before it is installed.
|
||||
assertTrue(script.contains("visudo -cf"))
|
||||
assertTrue(script.contains("chmod 0440"))
|
||||
// Heredoc terminators must be flush-left: an indented terminator makes
|
||||
// the heredoc swallow the rest of the script.
|
||||
val lines = script.lines()
|
||||
assertTrue(lines.contains("WRAPPER_EOF"))
|
||||
assertTrue(lines.contains("SUDOERS_EOF"))
|
||||
// The launcher is embedded verbatim inside the first heredoc.
|
||||
assertTrue(lines.contains("exec '/opt/Amber/bin/Amber'"))
|
||||
assertTrue(lines.indexOf("exec '/opt/Amber/bin/Amber'") < lines.indexOf("WRAPPER_EOF"))
|
||||
// Session sockets (Wayland, X11, D-Bus) are granted via ACLs.
|
||||
assertTrue(script.contains("setfacl -m 'u:amber:rw' '/run/user/1000/wayland-1'"))
|
||||
assertTrue(script.contains("setfacl -m 'u:amber:rw' '/run/user/1000/.X11-unix/X0'"))
|
||||
assertTrue(script.contains("setfacl -m 'u:amber:rw' '/run/user/1000/bus'"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rootScriptRejectsInvalidUserName() {
|
||||
assertThrows(IllegalArgumentException::class.java) {
|
||||
DedicatedUser.rootSetupScript(
|
||||
name = "amber; rm -rf /",
|
||||
currentUserName = "admin",
|
||||
exePath = "/opt/Amber/bin/Amber",
|
||||
wrapperPath = "/usr/local/bin/amber-runas-amber",
|
||||
sudoersPath = "/etc/sudoers.d/amber-runas-amber",
|
||||
dataDir = "/home/admin/.local/share/amber",
|
||||
amberDataDir = "/home/amber/.local/share/amber",
|
||||
xdgRuntimeDir = "/run/user/1000",
|
||||
waylandDisplay = "wayland-1",
|
||||
x11Socket = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun x11SocketPathDerivation() {
|
||||
assertEquals("/run/user/1000/.X11-unix/X0", DedicatedUser.x11SocketPath(":0", "/run/user/1000"))
|
||||
assertEquals("/run/user/1000/.X11-unix/X1", DedicatedUser.x11SocketPath(":1.0", "/run/user/1000"))
|
||||
assertNull(DedicatedUser.x11SocketPath(null, "/run/user/1000"))
|
||||
assertNull(DedicatedUser.x11SocketPath("", "/run/user/1000"))
|
||||
assertNull(DedicatedUser.x11SocketPath("wayland-1", "/run/user/1000"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun amberDataDirLivesInTheDedicatedHome() {
|
||||
assertEquals("/home/amber/.local/share/amber", DedicatedUser.amberDataDir("amber"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun relaunchCommandSwitchesToTheDedicatedUser() {
|
||||
val command = DedicatedUser.relaunchCommand("/usr/local/bin/amber-runas-amber")
|
||||
assertEquals("sudo", command[0])
|
||||
assertEquals("-n", command[1])
|
||||
assertEquals("-u", command[2])
|
||||
assertEquals(DedicatedUser.userName, command[3])
|
||||
assertEquals("/usr/local/bin/amber-runas-amber", command[4])
|
||||
// Followed by the four session socket arguments.
|
||||
assertEquals(9, command.size)
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import com.greenart7c3.nostrsigner.desktop.core.AccountManager
|
||||
import com.greenart7c3.nostrsigner.desktop.core.AppPermissionRecord
|
||||
import com.greenart7c3.nostrsigner.desktop.core.BunkerEngine
|
||||
import com.greenart7c3.nostrsigner.desktop.core.DesktopKeyStore
|
||||
import com.greenart7c3.nostrsigner.desktop.core.DesktopSettings
|
||||
import com.greenart7c3.nostrsigner.desktop.core.RememberType
|
||||
import com.greenart7c3.nostrsigner.desktop.core.SignerType
|
||||
import com.greenart7c3.nostrsigner.desktop.core.generateBunkerPrivKey
|
||||
@@ -45,6 +46,11 @@ class DesktopCoreTest {
|
||||
assertEquals(secret, DesktopKeyStore.decrypt(encrypted))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun freshInstallsLockAfterOneHour() {
|
||||
assertEquals(60, DesktopSettings().autoLockMinutes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parsesHexAndNsecKeys() {
|
||||
val hex = generateBunkerPrivKey()
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.greenart7c3.nostrsigner.desktop
|
||||
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
@@ -24,4 +26,43 @@ class NativeTrayTest {
|
||||
// the contract under test is "does not throw". Clean up if created.
|
||||
tray?.shutdown()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sniHostReplyParsing() {
|
||||
// gdbus returns GVariant text like "(<true>,)" when a tray host
|
||||
// (waybar's tray module, GNOME Shell, …) is registered.
|
||||
assertTrue(NativeTray.parseSniHostReply("(<true>,)"))
|
||||
assertTrue(NativeTray.parseSniHostReply("(<TRUE>,)"))
|
||||
// Errors ("The name … was not provided by any .service files") and
|
||||
// an unregistered host must not be mistaken for a host.
|
||||
assertFalse(NativeTray.parseSniHostReply("(<false>,)"))
|
||||
assertFalse(
|
||||
NativeTray.parseSniHostReply(
|
||||
"Error: GDBus.Error:... The name org.kde.StatusNotifierWatcher was not provided by any .service files",
|
||||
),
|
||||
)
|
||||
assertFalse(NativeTray.parseSniHostReply(""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun createBoundedGivesUpInsteadOfBlockingForever() {
|
||||
// No SNI host exists under gradle test workers, so the probe path
|
||||
// returns null immediately — the contract under test is "returns at
|
||||
// all, quickly, without throwing" (the pre-fix hang lived in create()).
|
||||
val start = System.nanoTime()
|
||||
val tray = NativeTray.createBounded(
|
||||
timeoutMs = 1_000,
|
||||
iconStream = { null },
|
||||
tooltip = "Amber",
|
||||
openLabel = "Open",
|
||||
lockLabel = "Lock",
|
||||
quitLabel = "Quit",
|
||||
onToggle = {},
|
||||
onLock = {},
|
||||
onQuit = {},
|
||||
)
|
||||
val elapsedMs = (System.nanoTime() - start) / 1_000_000
|
||||
tray?.shutdown()
|
||||
assertTrue("createBounded must return fast, took ${elapsedMs}ms", elapsedMs < 5_000)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
package com.greenart7c3.nostrsigner.desktop
|
||||
|
||||
import com.greenart7c3.nostrsigner.desktop.core.AccountManager
|
||||
import com.greenart7c3.nostrsigner.desktop.core.AccountStore
|
||||
import com.greenart7c3.nostrsigner.desktop.core.AmberDesktop
|
||||
import com.greenart7c3.nostrsigner.desktop.core.AppDirs
|
||||
import com.greenart7c3.nostrsigner.desktop.core.AppRecord
|
||||
import com.greenart7c3.nostrsigner.desktop.core.AppWithPermissions
|
||||
import com.greenart7c3.nostrsigner.desktop.core.DesktopKeyStore
|
||||
import com.greenart7c3.nostrsigner.desktop.core.PassphraseLock
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import java.io.File
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Assert.assertEquals
|
||||
@@ -12,7 +19,11 @@ import org.junit.BeforeClass
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Exercises the full passphrase-lock lifecycle against a scratch data dir.
|
||||
* Exercises the full mandatory-passphrase lifecycle against a scratch data
|
||||
* dir: plain pre-setup mode, setup, database encryption at rest, lock,
|
||||
* verify, unlock, and passphrase change. This is the only test class that
|
||||
* enables the lock — [PassphraseLock] and [DesktopKeyStore] are process-wide
|
||||
* singletons, so a second class toggling them would race this one.
|
||||
* Small Argon2 parameters keep the tests fast; production defaults are only
|
||||
* a cost change, not a code path change.
|
||||
*/
|
||||
@@ -34,13 +45,24 @@ class PassphraseLockTest {
|
||||
|
||||
@Test
|
||||
fun fullLifecycle() = runBlocking {
|
||||
// Plain mode: encrypt something so the master key exists.
|
||||
// Plain mode (pre-setup): keys and the account database are plaintext.
|
||||
val secret = "super-secret-private-key"
|
||||
val cipher1 = DesktopKeyStore.encrypt(secret)
|
||||
assertEquals(secret, DesktopKeyStore.decrypt(cipher1))
|
||||
assertEquals(PassphraseLock.Status.DISABLED, PassphraseLock.state.value)
|
||||
assertFalse(PassphraseLock.isEnabled())
|
||||
|
||||
// Enable the lock: same master key, so old ciphertexts still decrypt.
|
||||
val marker = "SECRET_APP_NAME_9f3a"
|
||||
val account = AccountManager.addAccount(KeyPair(), name = "db")
|
||||
val store = AmberDesktop.store(account.npub)
|
||||
store.upsert(AppWithPermissions(app = AppRecord(key = "app-key-1", name = marker, pubKey = account.hexKey)))
|
||||
store.addLog("wss://relay.example.com", "bunker", "sensitive-log-line")
|
||||
val appsFile = File(AppDirs.accountDir(account.npub), "applications.json")
|
||||
assertTrue("app db should be plaintext without a passphrase", appsFile.readText().contains(marker))
|
||||
|
||||
// Setup wraps the same master key under the passphrase: old
|
||||
// ciphertexts still decrypt, and the account database is re-encrypted
|
||||
// at rest.
|
||||
PassphraseLock.enable("correct horse battery staple".toCharArray(), fastKdf)
|
||||
assertTrue(PassphraseLock.isEnabled())
|
||||
assertEquals(PassphraseLock.Status.UNLOCKED, PassphraseLock.state.value)
|
||||
@@ -52,9 +74,26 @@ class PassphraseLockTest {
|
||||
assertFalse(File(dataDir, "keystore.pass").exists())
|
||||
assertTrue(File(dataDir, "master.key.enc").exists())
|
||||
|
||||
val encrypted = appsFile.readText()
|
||||
assertTrue("encrypted file should carry the marker header", encrypted.startsWith("AMBERENC1:"))
|
||||
assertFalse("plaintext app name must not survive in the encrypted file", encrypted.contains(marker))
|
||||
|
||||
// A fresh reader (simulating a restart) decrypts it back.
|
||||
val reloaded = AccountStore(account.npub)
|
||||
assertEquals(marker, reloaded.apps.value.first().app.name)
|
||||
|
||||
// Locking evicts the key: crypto operations fail.
|
||||
PassphraseLock.lock()
|
||||
assertEquals(PassphraseLock.Status.LOCKED, PassphraseLock.state.value)
|
||||
assertFalse(DesktopKeyStore.isMasterKeyAvailable())
|
||||
assertThrows(DesktopKeyStore.LockedException::class.java) {
|
||||
runBlocking { DesktopKeyStore.decrypt(cipher1) }
|
||||
}
|
||||
|
||||
// verify() checks the passphrase without unlocking anything.
|
||||
assertTrue(PassphraseLock.verify("correct horse battery staple".toCharArray()))
|
||||
assertFalse(PassphraseLock.verify("wrong passphrase".toCharArray()))
|
||||
assertEquals(PassphraseLock.Status.LOCKED, PassphraseLock.state.value)
|
||||
assertThrows(DesktopKeyStore.LockedException::class.java) {
|
||||
runBlocking { DesktopKeyStore.decrypt(cipher1) }
|
||||
}
|
||||
@@ -64,6 +103,8 @@ class PassphraseLockTest {
|
||||
assertEquals(PassphraseLock.Status.LOCKED, PassphraseLock.state.value)
|
||||
assertTrue(PassphraseLock.unlock("correct horse battery staple".toCharArray()))
|
||||
assertEquals(secret, DesktopKeyStore.decrypt(cipher1))
|
||||
assertEquals(marker, AmberDesktop.store(account.npub).apps.value.first().app.name)
|
||||
assertTrue("db must stay encrypted while a passphrase is set", appsFile.readText().startsWith("AMBERENC1:"))
|
||||
|
||||
// Changing the passphrase requires the old one and keeps the data.
|
||||
assertFalse(PassphraseLock.changePassphrase("nope".toCharArray(), "new passphrase 42".toCharArray(), fastKdf))
|
||||
@@ -72,12 +113,5 @@ class PassphraseLockTest {
|
||||
assertFalse(PassphraseLock.unlock("correct horse battery staple".toCharArray()))
|
||||
assertTrue(PassphraseLock.unlock("new passphrase 42".toCharArray()))
|
||||
assertEquals(secret, DesktopKeyStore.decrypt(cipher1))
|
||||
|
||||
// Disabling restores the keystore + password-store path.
|
||||
PassphraseLock.disable()
|
||||
assertEquals(PassphraseLock.Status.DISABLED, PassphraseLock.state.value)
|
||||
assertFalse(File(dataDir, "master.key.enc").exists())
|
||||
assertTrue(File(dataDir, "amber.keystore").exists())
|
||||
assertEquals(secret, DesktopKeyStore.decrypt(cipher1))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user