desktop: start-on-boot via hardened systemd user service

Settings gains a "Start on boot" toggle (Settings → Desktop, packaged
runs only — a dev gradle run has no stable binary for systemd to
supervise). Enabling installs and starts ~/.config/systemd/user/
amber.service: Opal-style hardening (NoNewPrivileges, ProtectSystem=
strict, ProtectHome=read-only, seccomp @system-service, restrict
namespaces/address families/kernel surfaces, LimitCORE=0, UMask=0077)
with one deliberate exception — no MemoryDenyWriteExecute, which the
JVM's JIT cannot survive. Amber always starts locked; the passphrase
is still required before anything signs.

Every packaged launch refreshes the unit, so the ExecStart path
survives reinstalls and updates. Disabling autostart never stops a
running instance. Two bugs were caught live while verifying this on
the target machine and are pinned by AutoStartTest: systemd requires
an absolute ExecStart (the launch path is now canonicalized), and
PrivateTmp hides /tmp/.X11-unix, so the unit binds the real X11 socket
directory back in — without it AWT can never reach the display and the
service runs headless forever.
This commit is contained in:
greenart7c3
2026-09-28 16:23:24 -03:00
parent 23df2c9922
commit adc873b391
20 changed files with 204 additions and 0 deletions
+5
View File
@@ -40,6 +40,11 @@ for the JVM) and mirrors the mobile UI and permission model.
or `gdbus` on Linux — so they work on Hyprland/Wayland — `osascript` on
macOS, and the AWT tray notification on Windows
- Mandatory passphrase lock (see Key storage below)
- Optional start-on-boot (Settings → Desktop): installs and enables a
hardened systemd **user** service that starts Amber with the desktop
session — always locked, passphrase required before anything signs. No
`MemoryDenyWriteExecute` (the JVM's JIT cannot run under it); the unit
still gets `ProtectSystem=strict`, seccomp, `NoNewPrivileges` and friends
- Native desktop layout: sidebar navigation with an account switcher, dense
list views, and keyboard shortcuts
- Light/dark theme using the Amber palette
@@ -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.AutoStart
import com.greenart7c3.nostrsigner.desktop.core.DesktopAccount
import com.greenart7c3.nostrsigner.desktop.core.NetworkConnectivity
import com.greenart7c3.nostrsigner.desktop.core.Notifier
@@ -141,6 +142,11 @@ fun main(args: Array<String>) {
}
if (launchUri != null) UriLaunch.pending.value = launchUri
// Keep the autostart unit fresh (binary path can change between builds).
if (SettingsStore.settings.value.startOnBoot) {
AutoStart.setEnabled(true)
}
// 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
@@ -0,0 +1,117 @@
package com.greenart7c3.nostrsigner.desktop.core
import java.io.File
/**
* Optional start-on-boot: installs and enables a hardened systemd user unit
* (Opal-style) that starts Amber with the desktop session. Amber always
* comes up locked — the passphrase is still required before anything signs.
*
* Hardening mirrors Opal's unit with one deliberate exception: no
* MemoryDenyWriteExecute, which the JVM cannot survive (the JIT needs
* writable executable memory). The unit needs write access to: the data dir,
* the runtime dir (nostrconnect socket) and the applications dir (scheme
* handler registration).
*
* Only packaged runs can be supervised: a dev run's command line is a bare
* gradle invocation with no stable binary ([isSupported] gates the UI).
*/
object AutoStart {
private const val UNIT_NAME = "amber.service"
val isLinux: Boolean = System.getProperty("os.name").lowercase().let {
it.contains("linux") || it.contains("nix") || it.contains("nux")
}
private fun currentExecutable(): String? = runCatching {
String(java.nio.file.Files.readAllBytes(java.nio.file.Path.of("/proc/self/cmdline")), Charsets.UTF_8)
.split('\u0000')
.firstOrNull { it.isNotBlank() }
}.getOrNull()
/** True when the current launch can be supervised by systemd. */
fun isSupported(): Boolean {
if (!isLinux) return false
val exe = currentExecutable() ?: return false
return File(exe).name != "java"
}
private fun unitDir(): File = File(System.getProperty("user.home"), ".config/systemd/user")
private fun unitFile(): File = File(unitDir(), UNIT_NAME)
/**
* Installs (or refreshes) the unit and enables it, then starts the
* service. Starting while a manual instance is already running is
* harmless: that instance forwards a raise to it and exits cleanly.
*/
fun setEnabled(enabled: Boolean) {
if (!isSupported()) return
runCatching {
// systemd requires an absolute ExecStart: /proc/self/cmdline
// records the path exactly as invoked (it can be relative).
val exe = currentExecutable()
?.let { File(it).canonicalFile.path }
?: return
unitDir().mkdirs()
unitFile().writeText(unitContent(exe))
systemctl("daemon-reload")
if (enabled) {
systemctl("enable", UNIT_NAME)
systemctl("start", UNIT_NAME)
} else {
// No --now on purpose: disabling autostart must not kill an
// app the user is currently using.
systemctl("disable", UNIT_NAME)
}
}
}
private fun systemctl(vararg args: String): Boolean = runCatching {
ProcessBuilder("systemctl", "--user", *args).start().waitFor() == 0
}.getOrDefault(false)
private fun quote(value: String): String = if (value.none { it == ' ' || it == '\t' }) value else "\"$value\""
internal fun unitContent(exePath: String): String = """
[Unit]
Description=Amber Nostr signer
PartOf=graphical-session.target
After=graphical-session.target
[Service]
Type=simple
ExecStart=${quote(exePath)}
Restart=on-failure
RestartSec=3
# JVM exception: no MemoryDenyWriteExecute — the JIT needs W+X memory.
NoNewPrivileges=yes
PrivateTmp=yes
# PrivateTmp hides /tmp, and with it the XWayland socket — bind the
# real X11 socket dir back in or AWT cannot reach the display.
BindPaths=-/tmp/.X11-unix
ProtectSystem=strict
ProtectHome=read-only
ReadWritePaths=-%h/.local/share/amber -%h/.local/share/applications %t
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
RestrictRealtime=yes
RestrictSUIDSGID=yes
LockPersonality=yes
SystemCallArchitectures=native
SystemCallFilter=@system-service
SystemCallErrorNumber=EPERM
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK
RestrictNamespaces=yes
ProtectClock=yes
ProtectHostname=yes
ProtectKernelLogs=yes
# Decrypted keys live in this process: never write core dumps.
LimitCORE=0
UMask=0077
[Install]
WantedBy=graphical-session.target
""".trimIndent() + "\n"
}
@@ -181,6 +181,8 @@ data class DesktopSettings(
val closeToTray: Boolean = true,
/** Show a system notification when a request needs approval. */
val showNotifications: Boolean = true,
/** Start automatically with the desktop session (systemd user service). */
val startOnBoot: Boolean = false,
/** UI language tag (matches Strings.supportedLanguages); null = follow the OS. */
val language: String? = null,
) {
@@ -40,6 +40,7 @@ import com.greenart7c3.nostrsigner.desktop.Session
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.AutoStart
import com.greenart7c3.nostrsigner.desktop.core.DesktopAccount
import com.greenart7c3.nostrsigner.desktop.core.DesktopKeyStore
import com.greenart7c3.nostrsigner.desktop.core.PassphraseLock
@@ -149,6 +150,17 @@ fun SettingsScreen(account: DesktopAccount) {
checked = settings.showNotifications,
onCheckedChange = { value -> SettingsStore.update { it.copy(showNotifications = value) } },
)
if (AutoStart.isSupported()) {
SettingSwitch(
title = Strings.get("d_start_on_boot", language),
description = Strings.get("d_start_on_boot_sub", language),
checked = settings.startOnBoot,
onCheckedChange = { value ->
SettingsStore.update { it.copy(startOnBoot = value) }
AutoStart.setEnabled(value)
},
)
}
Spacer(Modifier.height(16.dp))
SectionTitle(Strings.get("accounts", language))
@@ -778,6 +778,8 @@
<string name="d_keep_in_tray_sub">Beim Schließen des Fensters wird Amber in den Infobereich minimiert und beantwortet weiterhin Anfragen.</string>
<string name="d_notifications">Benachrichtigungen</string>
<string name="d_notifications_sub">Eine Systembenachrichtigung anzeigen, wenn eine Anfrage deine Genehmigung erfordert.</string>
<string name="d_start_on_boot">Beim Start ausführen</string>
<string name="d_start_on_boot_sub">Amber automatisch starten, wenn du dich anmeldest. Es startet gesperrt — gib deine Passphrase ein, um zu signieren.</string>
<string name="d_active">Aktiv</string>
<string name="d_switch">Wechseln</string>
<string name="d_log_out">Abmelden</string>
@@ -789,6 +789,8 @@
<string name="d_keep_in_tray_sub">Closing the window minimizes Amber to the system tray so it keeps answering requests.</string>
<string name="d_notifications">Notifications</string>
<string name="d_notifications_sub">Show a system notification when a request needs your approval.</string>
<string name="d_start_on_boot">Start on boot</string>
<string name="d_start_on_boot_sub">Start Amber automatically when you log in. It starts locked — enter your passphrase to sign.</string>
<string name="d_active">Active</string>
<string name="d_switch">Switch</string>
<string name="d_log_out">Log out</string>
@@ -781,6 +781,8 @@
<string name="d_keep_in_tray_sub">Al cerrar la ventana, Amber se minimiza a la bandeja del sistema para seguir respondiendo a las solicitudes.</string>
<string name="d_notifications">Notificaciones</string>
<string name="d_notifications_sub">Mostrar una notificación del sistema cuando una solicitud necesite tu aprobación.</string>
<string name="d_start_on_boot">Iniciar al arrancar</string>
<string name="d_start_on_boot_sub">Inicia Amber automáticamente al iniciar sesión. Arranca bloqueado; introduce tu frase de contraseña para firmar.</string>
<string name="d_active">Activa</string>
<string name="d_switch">Cambiar</string>
<string name="d_log_out">Cerrar sesión</string>
@@ -778,6 +778,8 @@
<string name="d_keep_in_tray_sub">Fermer la fenêtre réduit Amber dans la barre d'état système pour qu'il continue de répondre aux requêtes.</string>
<string name="d_notifications">Notifications</string>
<string name="d_notifications_sub">Afficher une notification système lorsqu'une requête nécessite votre approbation.</string>
<string name="d_start_on_boot">Lancer au démarrage</string>
<string name="d_start_on_boot_sub">Lance Amber automatiquement à l'ouverture de session. Il démarre verrouillé — saisissez votre phrase secrète pour signer.</string>
<string name="d_active">Actif</string>
<string name="d_switch">Changer</string>
<string name="d_log_out">Se déconnecter</string>
@@ -781,6 +781,8 @@
<string name="d_keep_in_tray_sub">Menutup jendela akan meminimalkan Amber ke tray sistem agar tetap menjawab permintaan.</string>
<string name="d_notifications">Notifikasi</string>
<string name="d_notifications_sub">Tampilkan notifikasi sistem saat ada permintaan yang perlu persetujuan Anda.</string>
<string name="d_start_on_boot">Mulai saat boot</string>
<string name="d_start_on_boot_sub">Mulai Amber secara otomatis saat Anda masuk. Terbuka dalam keadaan terkunci — masukkan frasa sandi Anda untuk menandatangani.</string>
<string name="d_active">Aktif</string>
<string name="d_switch">Ganti</string>
<string name="d_log_out">Keluar</string>
@@ -781,6 +781,8 @@
<string name="d_keep_in_tray_sub">Chiudendo la finestra Amber viene ridotto a icona nella system tray in modo da continuare a rispondere alle richieste.</string>
<string name="d_notifications">Notifiche</string>
<string name="d_notifications_sub">Mostra una notifica di sistema quando una richiesta necessita della tua approvazione.</string>
<string name="d_start_on_boot">Avvia all'avvio</string>
<string name="d_start_on_boot_sub">Avvia Amber automaticamente al login. Parte bloccato: inserisci la passphrase per firmare.</string>
<string name="d_active">Attivo</string>
<string name="d_switch">Cambia</string>
<string name="d_log_out">Esci</string>
@@ -757,6 +757,8 @@
<string name="d_keep_in_tray_sub">ウィンドウを閉じると Amber がシステムトレイに最小化され、リクエストへの応答を続けます。</string>
<string name="d_notifications">通知</string>
<string name="d_notifications_sub">承認が必要なリクエストがあるとき、システム通知を表示します。</string>
<string name="d_start_on_boot">起動時に開始</string>
<string name="d_start_on_boot_sub">ログイン時に Amber を自動的に起動します。ロックされた状態で起動するため、署名にはパスフレーズの入力が必要です。</string>
<string name="d_active">有効</string>
<string name="d_switch">切り替え</string>
<string name="d_log_out">ログアウト</string>
@@ -781,6 +781,8 @@
<string name="d_keep_in_tray_sub">창을 닫으면 Amber가 시스템 트레이로 최소화되어 계속 요청에 응답합니다.</string>
<string name="d_notifications">알림</string>
<string name="d_notifications_sub">요청에 승인이 필요할 때 시스템 알림을 표시합니다.</string>
<string name="d_start_on_boot">부팅 시 시작</string>
<string name="d_start_on_boot_sub">로그인할 때 Amber를 자동으로 시작합니다. 잠긴 상태로 시작되므로 서명하려면 암호를 입력하세요.</string>
<string name="d_active">활성</string>
<string name="d_switch">전환</string>
<string name="d_log_out">로그아웃</string>
@@ -776,6 +776,8 @@
<string name="d_keep_in_tray_sub">Fechar a janela minimiza o Amber para a bandeja do sistema para que ele continue respondendo às solicitações.</string>
<string name="d_notifications">Notificações</string>
<string name="d_notifications_sub">Mostrar uma notificação do sistema quando uma solicitação precisar da sua aprovação.</string>
<string name="d_start_on_boot">Iniciar na inicialização</string>
<string name="d_start_on_boot_sub">Inicia o Amber automaticamente quando você entra. Ele começa bloqueado — digite sua senha para assinar.</string>
<string name="d_active">Ativo</string>
<string name="d_switch">Trocar</string>
<string name="d_log_out">Sair</string>
@@ -781,6 +781,8 @@
<string name="d_keep_in_tray_sub">При закрытии окна Amber сворачивается в системный трей и продолжает отвечать на запросы.</string>
<string name="d_notifications">Уведомления</string>
<string name="d_notifications_sub">Показывать системное уведомление, когда запрос требует вашего одобрения.</string>
<string name="d_start_on_boot">Запускать при включении</string>
<string name="d_start_on_boot_sub">Запускает Amber автоматически при входе в систему. Он запускается заблокированным — введите пароль-фразу для подписи.</string>
<string name="d_active">Активен</string>
<string name="d_switch">Переключить</string>
<string name="d_log_out">Выйти</string>
@@ -757,6 +757,8 @@
<string name="d_keep_in_tray_sub">การปิดหน้าต่างจะย่อ Amber ลงในถาดระบบเพื่อให้ยังคงตอบรับคำขอต่อไป</string>
<string name="d_notifications">การแจ้งเตือน</string>
<string name="d_notifications_sub">แสดงการแจ้งเตือนของระบบเมื่อมีคำขอที่ต้องการการอนุมัติของคุณ</string>
<string name="d_start_on_boot">เริ่มต้นเมื่อบูตเครื่อง</string>
<string name="d_start_on_boot_sub">เริ่ม Amber โดยอัตโนมัติเมื่อคุณล็อกอิน มันจะเริ่มในสถานะล็อก — ป้อนวลีรหัสผ่านเพื่อลงนาม</string>
<string name="d_active">ใช้งานอยู่</string>
<string name="d_switch">สลับ</string>
<string name="d_log_out">ออกจากระบบ</string>
@@ -777,6 +777,8 @@
<string name="d_keep_in_tray_sub">Pencereyi kapatmak Amber'i sistem tepsisine küçültür, böylece istekleri yanıtlamaya devam eder.</string>
<string name="d_notifications">Bildirimler</string>
<string name="d_notifications_sub">Bir istek onayınızı gerektirdiğinde sistem bildirimi göster.</string>
<string name="d_start_on_boot">Açılışta başlat</string>
<string name="d_start_on_boot_sub">Oturum açtığınızda Amber'i otomatik başlatır. Kilitli başlar; imzalamak için parolanızı girin.</string>
<string name="d_active">Etkin</string>
<string name="d_switch">Değiştir</string>
<string name="d_log_out">Çıkış yap</string>
@@ -757,6 +757,8 @@
<string name="d_keep_in_tray_sub">Đóng cửa sổ sẽ thu nhỏ Amber vào khay hệ thống để tiếp tục phản hồi các yêu cầu.</string>
<string name="d_notifications">Thông báo</string>
<string name="d_notifications_sub">Hiển thị thông báo hệ thống khi có yêu cầu cần bạn phê duyệt.</string>
<string name="d_start_on_boot">Khởi động cùng hệ thống</string>
<string name="d_start_on_boot_sub">Khởi động Amber tự động khi bạn đăng nhập. Nó khởi động ở trạng thái khóa — nhập cụm mật khẩu để ký.</string>
<string name="d_active">Đang hoạt động</string>
<string name="d_switch">Chuyển</string>
<string name="d_log_out">Đăng xuất</string>
@@ -762,6 +762,8 @@
<string name="d_keep_in_tray_sub">关闭窗口会将 Amber 最小化到系统托盘,以便它继续响应请求。</string>
<string name="d_notifications">通知</string>
<string name="d_notifications_sub">当请求需要你批准时显示系统通知。</string>
<string name="d_start_on_boot">开机自动启动</string>
<string name="d_start_on_boot_sub">登录时自动启动 Amber。启动时处于锁定状态——输入密码短语后才能签名。</string>
<string name="d_active">当前</string>
<string name="d_switch">切换</string>
<string name="d_log_out">退出登录</string>
@@ -0,0 +1,34 @@
package com.greenart7c3.nostrsigner.desktop
import com.greenart7c3.nostrsigner.desktop.core.AutoStart
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class AutoStartTest {
@Test
fun unitIsHardenedButJvmCompatible() {
val unit = AutoStart.unitContent("/opt/Amber/bin/Amber")
assertTrue(unit.startsWith("[Unit]"))
assertTrue(unit.contains("ExecStart=/opt/Amber/bin/Amber"))
assertTrue(unit.contains("PartOf=graphical-session.target"))
assertTrue(unit.contains("WantedBy=graphical-session.target"))
assertTrue(unit.contains("Restart=on-failure"))
assertTrue(unit.contains("NoNewPrivileges=yes"))
assertTrue(unit.contains("ProtectSystem=strict"))
assertTrue(unit.contains("ProtectHome=read-only"))
assertTrue(unit.contains("SystemCallFilter=@system-service"))
assertTrue(unit.contains("LimitCORE=0"))
assertTrue(unit.contains("ReadWritePaths=-%h/.local/share/amber -%h/.local/share/applications %t"))
assertTrue(unit.contains("BindPaths=-/tmp/.X11-unix"))
// The JVM's JIT needs writable executable memory: this hardening flag
// would kill the service instantly and must never be emitted.
assertFalse(unit.contains("MemoryDenyWriteExecute=yes"))
}
@Test
fun unitQuotesExecStartWithSpaces() {
val unit = AutoStart.unitContent("/opt/My Apps/Amber/bin/Amber")
assertTrue(unit.contains("ExecStart=\"/opt/My Apps/Amber/bin/Amber\""))
}
}