diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f03e5fbfb..905fe4aa0 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -31,14 +31,26 @@ jobs: - name: Linter (gradle) run: ./gradlew spotlessCheck - test: + test-and-build: needs: lint strategy: fail-fast: false matrix: - os: [ubuntu-latest, macos-latest, windows-latest] + include: + - os: ubuntu-latest + desktop-task: packageDeb + desktop-artifact-name: Desktop Linux DEB + desktop-artifact-path: desktopApp/build/compose/binaries/main/deb/*.deb + - os: macos-latest + desktop-task: packageDmg + desktop-artifact-name: Desktop macOS DMG + desktop-artifact-path: desktopApp/build/compose/binaries/main/dmg/*.dmg + - os: windows-latest + desktop-task: packageMsi + desktop-artifact-name: Desktop Windows MSI + desktop-artifact-path: desktopApp/build/compose/binaries/main/msi/*.msi runs-on: ${{ matrix.os }} - timeout-minutes: 30 + timeout-minutes: 60 defaults: run: shell: bash @@ -53,12 +65,12 @@ jobs: java-version: 21 cache: gradle - - name: Test (gradle) - run: ./gradlew test --no-daemon + - name: Test + Build Desktop (gradle) + run: ./gradlew test :desktopApp:${{ matrix.desktop-task }} --no-daemon - - name: Stop Gradle Daemon - if: always() - run: ./gradlew --stop + - name: Build Android Benchmark APKs (gradle) + if: matrix.os == 'ubuntu-latest' + run: ./gradlew assembleBenchmark --no-daemon - name: Android Test Report uses: asadmansr/android-test-report-action@v1.2.0 @@ -71,100 +83,29 @@ jobs: name: Test Reports path: amethyst/build/reports - build-android: - needs: test - runs-on: ubuntu-latest - timeout-minutes: 45 - steps: - - name: Checkout code - uses: actions/checkout@v6 - - - name: Set up JDK 21 - uses: actions/setup-java@v5 - with: - distribution: 'zulu' - java-version: 21 - cache: gradle - - - name: Build APK (gradle) - run: ./gradlew assembleDebug - - - name: Upload Play APK + - name: Upload Desktop Distribution uses: actions/upload-artifact@v7 with: - name: Play Debug APK - path: amethyst/build/outputs/apk/play/debug/amethyst-play-universal-debug.apk - - - name: Upload FDroid APK - uses: actions/upload-artifact@v7 - with: - name: FDroid Debug APK - path: amethyst/build/outputs/apk/fdroid/debug/amethyst-fdroid-universal-debug.apk - - - name: Build Benchmark APK (gradle) - run: ./gradlew assembleBenchmark + name: ${{ matrix.desktop-artifact-name }} + path: ${{ matrix.desktop-artifact-path }} - name: Upload Play APK Benchmark uses: actions/upload-artifact@v7 + if: matrix.os == 'ubuntu-latest' with: name: Play Benchmark APK path: amethyst/build/outputs/apk/play/benchmark/amethyst-play-universal-benchmark.apk - name: Upload FDroid APK Benchmark uses: actions/upload-artifact@v7 + if: matrix.os == 'ubuntu-latest' with: name: FDroid Benchmark APK path: amethyst/build/outputs/apk/fdroid/benchmark/amethyst-fdroid-universal-benchmark.apk - name: Upload Compose Reports uses: actions/upload-artifact@v7 + if: matrix.os == 'ubuntu-latest' with: name: Compose Reports path: amethyst/build/compose_compiler - - build-desktop: - needs: test - strategy: - fail-fast: false - matrix: - include: - - os: ubuntu-latest - task: packageDeb - artifact-name: Desktop Linux DEB - artifact-path: desktopApp/build/compose/binaries/main/deb/*.deb - - os: macos-latest - task: packageDmg - artifact-name: Desktop macOS DMG - artifact-path: desktopApp/build/compose/binaries/main/dmg/*.dmg - - os: windows-latest - task: packageMsi - artifact-name: Desktop Windows MSI - artifact-path: desktopApp/build/compose/binaries/main/msi/*.msi - runs-on: ${{ matrix.os }} - timeout-minutes: 30 - defaults: - run: - shell: bash - steps: - - name: Checkout code - uses: actions/checkout@v6 - - - name: Set up JDK 21 - uses: actions/setup-java@v5 - with: - distribution: 'zulu' - java-version: 21 - cache: gradle - - - name: Build Desktop Distribution - run: ./gradlew :desktopApp:${{ matrix.task }} - - - name: Stop Gradle Daemon - if: always() - run: ./gradlew --stop - - - name: Upload Desktop Distribution - uses: actions/upload-artifact@v7 - with: - name: ${{ matrix.artifact-name }} - path: ${{ matrix.artifact-path }} diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index d4abc1b4c..0ac241bfb 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -173,6 +173,142 @@ jobs: ls -la dist >> "$GITHUB_STEP_SUMMARY" echo '```' >> "$GITHUB_STEP_SUMMARY" + # --------------------------------------------------------------------------- + # Amy CLI build matrix. Each leg produces a self-contained amy bundle with a + # minimal jlink'd JRE — no system Java required on the user's machine. + # + # amyImage task (all legs): cli/build/amy-image/amy/ → amy-*.tar.gz + # jpackageDeb / jpackageRpm: cli/build/jpackage/amy_*.deb + amy-*.rpm + # + # macOS legs ship only the tarball. We deliberately avoid jpackage --type + # app-image on macOS because it produces an .app bundle (burying the binary + # at Contents/MacOS/amy) — wrong UX for a CLI. + # + # Windows is intentionally deferred — cli/ has not been validated on Windows + # yet (data-dir path handling, file locking on groups/.mls, line endings + # in identity.json). + # + # Asset naming: amy---.. See scripts/asset-name.sh. + # --------------------------------------------------------------------------- + build-cli: + strategy: + fail-fast: false + matrix: + include: + - { os: macos-13, arch: x64, family: macos, tasks: "amyImage" } + - { os: macos-14, arch: arm64, family: macos, tasks: "amyImage" } + - { os: ubuntu-latest, arch: x64, family: linux, tasks: "amyImage jpackageDeb jpackageRpm" } + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + defaults: + run: + shell: bash + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Set up JDK 21 + uses: actions/setup-java@v5 + with: + distribution: 'zulu' + java-version: 21 + + - name: Resolve tag + version + id: ver + env: + DRY_RUN: ${{ github.event.inputs.dry_run || 'false' }} + TEST_TAG: ${{ github.event.inputs.test_tag || '' }} + run: | + set -euo pipefail + if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then + TAG="${TEST_TAG:-v0.0.0-dryrun}" + else + TAG="${GITHUB_REF_NAME}" + fi + VER="${TAG#v}" + TOML_VER=$(grep -E '^app\s*=' gradle/libs.versions.toml | head -1 | cut -d'"' -f2) + # On dry-run we only require that TOML has a version; on real tag push we require exact match. + if [[ "${GITHUB_EVENT_NAME}" != "workflow_dispatch" ]]; then + if [[ "$TOML_VER" != "$VER" ]]; then + echo "::error::gradle/libs.versions.toml app=$TOML_VER but tag is $TAG" + exit 1 + fi + fi + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + echo "version=$VER" >> "$GITHUB_OUTPUT" + + - name: Install RPM tooling (linux only) + if: matrix.family == 'linux' + run: sudo apt-get update && sudo apt-get install -y rpm fakeroot + + - name: Build amy artifacts + uses: nick-fields/retry@ad984534de44a9489a53aefd81eb77f87c70dc60 # v4.0.0 + with: + max_attempts: 2 + timeout_minutes: 15 + command: ./gradlew --no-daemon :cli:${{ matrix.tasks }} + + - name: Collect + rename assets + run: | + set -euo pipefail + # shellcheck source=scripts/asset-name.sh + source scripts/asset-name.sh + collect_cli_assets "${{ matrix.family }}" "${{ matrix.arch }}" "${{ steps.ver.outputs.version }}" dist + + - name: Enforce CLI size budget (200 MB per asset) + run: | + set -euo pipefail + # The plan at cli/plans/2026-04-21-cli-distribution.md §size-budget + # targets < 80 MB, but :commons currently leaks Compose + Skiko as + # transitive deps (~40 MB of unused UI jars). Budget is set to + # 200 MB until commons is split into core + ui modules — track that + # as a follow-up. Until then, this gate just catches pathological + # regressions (e.g. accidental :amethyst dep pulling Android libs). + fail=0 + for f in dist/*; do + if [[ -f "$f" ]]; then + size=$(wc -c < "$f") + mb=$(( size / 1048576 )) + if (( size > 209715200 )); then + echo "::error file=$f::asset is ${mb} MB — exceeds 200 MB amy budget" + fail=1 + else + echo "OK: $f — ${mb} MB" + fi + fi + done + [[ "$fail" == 0 ]] + + - name: Classify release + id: classify + run: | + TAG="${{ steps.ver.outputs.tag }}" + if [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "prerelease=false" >> "$GITHUB_OUTPUT" + else + echo "prerelease=true" >> "$GITHUB_OUTPUT" + fi + + - name: Upload to GH Release (skip on dry-run) + if: github.event_name != 'workflow_dispatch' || github.event.inputs.dry_run != 'true' + uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0 + with: + files: dist/* + tag_name: ${{ steps.ver.outputs.tag }} + prerelease: ${{ steps.classify.outputs.prerelease }} + draft: false + fail_on_unmatched_files: true + generate_release_notes: false # Android job writes release notes (last-writer-wins race) + + - name: Dry-run summary + if: github.event_name == 'workflow_dispatch' && github.event.inputs.dry_run == 'true' + run: | + echo "### Dry-run: amy ${{ matrix.family }}/${{ matrix.arch }}" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo '```' >> "$GITHUB_STEP_SUMMARY" + ls -la dist >> "$GITHUB_STEP_SUMMARY" + echo '```' >> "$GITHUB_STEP_SUMMARY" + # --------------------------------------------------------------------------- # Android build + sign + direct-upload. Logic preserved from previous workflow; # uses softprops/action-gh-release@v2 instead of deprecated upload-release-asset. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/SwipeToDelete.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/SwipeToDelete.kt index f4535feef..8e8dc20d3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/SwipeToDelete.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/SwipeToDelete.kt @@ -34,7 +34,6 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SwipeToDismissBox import androidx.compose.material3.SwipeToDismissBoxState -import androidx.compose.material3.SwipeToDismissBoxValue.EndToStart import androidx.compose.material3.SwipeToDismissBoxValue.Settled import androidx.compose.material3.SwipeToDismissBoxValue.StartToEnd import androidx.compose.material3.Text @@ -64,22 +63,6 @@ fun SwipeToDeleteContainer( ) { val dismissState = rememberSwipeToDismissBoxState( - confirmValueChange = { - when (it) { - StartToEnd -> { - onStartToEnd() - } - - EndToStart -> { - return@rememberSwipeToDismissBoxState false - } - - Settled -> { - return@rememberSwipeToDismissBoxState false - } - } - return@rememberSwipeToDismissBoxState true - }, positionalThreshold = { it * .40f }, ) @@ -88,6 +71,7 @@ fun SwipeToDeleteContainer( modifier = modifier, backgroundContent = { DismissBackground(dismissState) }, enableDismissFromEndToStart = false, + onDismiss = { if (it == StartToEnd) onStartToEnd() }, content = content, ) } diff --git a/amethyst/src/main/res/values-pl-rPL/strings.xml b/amethyst/src/main/res/values-pl-rPL/strings.xml index 16a3f0941..188d8a4ef 100644 --- a/amethyst/src/main/res/values-pl-rPL/strings.xml +++ b/amethyst/src/main/res/values-pl-rPL/strings.xml @@ -49,6 +49,7 @@ Używasz klucza publicznego, a klucze publiczne są tylko do odczytu. Zaloguj się za pomocą klucza prywatnego, aby zwiększyć liczbę postów Używasz klucza publicznego, a klucze publiczne są tylko do odczytu. Zaloguj się za pomocą klucza prywatnego, aby polubić posty Nie ustawiono kwoty zap-a. Naciśnij i przytrzymaj, aby zmienić + przesłał %1$s satoszy Anonimowo prowadzi nalot stworzył klip @@ -338,6 +339,7 @@ Konta Nawigacja Ty + Kanały Utwórz System Wybierz Konto @@ -1353,6 +1355,7 @@ Nowy GEO-ekskluzywny Wpis Nowy artykuł Nowa ankieta + Nowa ankieta Nowa standardowa ankieta Nowe zdjęcie Nowy krótki filmik @@ -1616,7 +1619,7 @@ Moje Listy/Zestawy Moje listy Użytkownicy - Wybierz listę profili, aby filtrować aktualności + Wybierz opcję, aby filtrować aktualności Kanały Hashtagi Grupy zainteresowań @@ -1820,6 +1823,7 @@ Pakiety subskrybentów Udostępnienia (16) Obserwacja geohashów + GiftWraps Problem z Git Łatka Git Repozytorium Git @@ -2239,4 +2243,5 @@ Prywatna emotikona Publiczne emotikony są widoczne dla wszystkich i pojawiają się w menu odezw oraz w oknie podpowiedzi \":\", jeśli ten pakiet znajduje się na liście emotikonów. Prywatne emotikony są przechowywane zaszyfrowane na transmiterach i widoczne tylko dla Ciebie. Znajdują się w menu odzewów oraz w autouzupełnianiu po wpisaniu znaku „:” – tak samo jak emotikony publiczne. + Gif diff --git a/amethyst/src/main/res/values-sl-rSI/strings.xml b/amethyst/src/main/res/values-sl-rSI/strings.xml index f68c185db..ef81acd61 100644 --- a/amethyst/src/main/res/values-sl-rSI/strings.xml +++ b/amethyst/src/main/res/values-sl-rSI/strings.xml @@ -780,6 +780,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem %1$s je sprejel vaš šahovski izziv Poteza uporabnika %1$s — vi ste na vrsti Šahovske novosti + Nove omembe Dohodni klici Obvestila o dohodnih glasovnih in video klicih diff --git a/cli/build.gradle.kts b/cli/build.gradle.kts index 30b9a910f..55871b14b 100644 --- a/cli/build.gradle.kts +++ b/cli/build.gradle.kts @@ -34,3 +34,187 @@ application { mainClass.set("com.vitorpamplona.amethyst.cli.MainKt") applicationName = "amy" } + +// --------------------------------------------------------------------------- +// Native distribution (jlink + jpackage) +// +// Produces a self-contained `amy` bundle with a minimal jlink'd JRE embedded — +// no JDK required on the user machine. Outputs land under cli/build/: +// - amy-image/amy/ portable, flat directory (bin/ + lib/ + runtime/) +// tar this up on every OS → amy---.tar.gz +// - jpackage/amy_*.deb Debian/Ubuntu package (Linux runners only) +// - jpackage/amy-*.rpm Fedora/RHEL package (Linux runners only) +// +// We deliberately build our own app-image instead of using `jpackage --type +// app-image`, because on macOS jpackage produces an `.app` bundle (with the +// binary buried at Contents/MacOS/amy) — awful UX for a CLI. The flat tree we +// build matches the Linux jpackage layout on every OS. +// +// Packaging for release is wired in .github/workflows/create-release.yml. +// See cli/plans/2026-04-21-cli-distribution.md for the overall plan. +// --------------------------------------------------------------------------- + +val appVersion: String = project.version.toString() + +// RPM rejects dashes in version strings — replace with tilde (~), which RPM +// treats as prerelease-lower-than: 1.08.0~rc1 < 1.08.0. +val rpmVersion: String = appVersion.replace("-", "~") + +val mainJarName: String = "cli-$appVersion.jar" +val mainClassName: String = "com.vitorpamplona.amethyst.cli.MainKt" + +// Minimal JDK 21 module set for amy. Keep this tight — every module adds +// megabytes to the bundle. If a transitive dep needs more, `jlink` fails loudly +// at build time with "module X not found". +val jlinkModules: String = listOf( + "java.base", + "java.logging", + "java.naming", + "java.net.http", + "java.sql", + "java.xml", + "jdk.crypto.ec", + "jdk.unsupported", +).joinToString(",") + +fun javaToolBin(name: String): Provider = + javaToolchains.launcherFor { + languageVersion.set(JavaLanguageVersion.of(21)) + }.map { + val exe = if (org.gradle.internal.os.OperatingSystem.current().isWindows) "$name.exe" else name + it.metadata.installationPath.file("bin/$exe").asFile.absolutePath + } + +val jlinkRuntimeDir = layout.buildDirectory.dir("jlink-runtime") +val amyImageRoot = layout.buildDirectory.dir("amy-image") +val amyImageDir = layout.buildDirectory.dir("amy-image/amy") +val installLibDir = layout.buildDirectory.dir("install/amy/lib") +val jpackageOutDir = layout.buildDirectory.dir("jpackage") + +val jlinkRuntime = + tasks.register("jlinkRuntime") { + group = "distribution" + description = "Build a minimal JRE for amy via jlink." + + outputs.dir(jlinkRuntimeDir) + + val jlinkBin = javaToolBin("jlink") + val outDir = jlinkRuntimeDir + val modules = jlinkModules + + doFirst { + // jlink refuses to write into an existing directory. + outDir.get().asFile.deleteRecursively() + executable = jlinkBin.get() + args( + "--add-modules", modules, + "--no-header-files", + "--no-man-pages", + "--strip-debug", + // JDK 21+: --compress is deprecated; use zip-. + "--compress", "zip-6", + "--output", outDir.get().asFile.absolutePath, + ) + } + } + +// Flat app-image: bin/amy launcher + lib/*.jar + runtime/ (the jlink'd JRE). +// Cross-platform — the release workflow tars this up on every OS. +val amyImage = + tasks.register("amyImage") { + group = "distribution" + description = "Assemble a portable amy app-image (bin/ + lib/ + runtime/)." + + dependsOn(tasks.named("installDist"), jlinkRuntime) + + into(amyImageDir) + + // jars from installDist + from(installLibDir) { + into("lib") + } + // jlink'd JRE + from(jlinkRuntimeDir) { + into("runtime") + } + + val mainJar = mainJarName + val mainClass = mainClassName + val unixLauncher = + """ + #!/bin/sh + # amy launcher — uses the bundled jlink'd JRE so no system Java is required. + DIR="${'$'}(cd "${'$'}(dirname "${'$'}0")/.." && pwd)" + exec "${'$'}DIR/runtime/bin/java" -cp "${'$'}DIR/lib/*" $mainClass "${'$'}@" + """.trimIndent() + "\n" + + doLast { + val binDir = amyImageDir.get().asFile.resolve("bin") + binDir.mkdirs() + val launcher = binDir.resolve("amy") + launcher.writeText(unixLauncher) + launcher.setExecutable(true, false) + } + } + +fun registerJpackage( + taskName: String, + type: String, + extraArgs: List = emptyList(), +) = tasks.register(taskName) { + group = "distribution" + description = "Run jpackage --type $type for amy." + + dependsOn(tasks.named("installDist"), jlinkRuntime) + + inputs.dir(installLibDir) + inputs.dir(jlinkRuntimeDir) + outputs.dir(jpackageOutDir) + + val jpackageBin = javaToolBin("jpackage") + val inDir = installLibDir + val runtimeDir = jlinkRuntimeDir + val outDir = jpackageOutDir + val versionArg = if (type == "rpm") rpmVersion else appVersion + val extra = extraArgs + + doFirst { + outDir.get().asFile.mkdirs() + executable = jpackageBin.get() + args( + "--type", type, + "--name", "amy", + "--app-version", versionArg, + "--vendor", "Amethyst Contributors", + "--description", "Amethyst CLI — a non-interactive Nostr client.", + "--input", inDir.get().asFile.absolutePath, + "--runtime-image", runtimeDir.get().asFile.absolutePath, + "--main-jar", mainJarName, + "--main-class", mainClassName, + "--dest", outDir.get().asFile.absolutePath, + ) + args(extra) + } +} + +// .deb for Debian/Ubuntu. Installs under /opt/amy/ with /opt/amy/bin/amy as +// the launcher. We intentionally do NOT request --linux-shortcut (no .desktop +// entry for a CLI). +registerJpackage( + "jpackageDeb", + "deb", + extraArgs = listOf( + "--linux-package-name", "amy", + "--linux-deb-maintainer", "vitor@vitorpamplona.com", + ), +) + +// .rpm for Fedora/RHEL/openSUSE. +registerJpackage( + "jpackageRpm", + "rpm", + extraArgs = listOf( + "--linux-package-name", "amy", + "--linux-rpm-license-type", "MIT", + ), +) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Config.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Config.kt index e260013c2..34e1b3a5a 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Config.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Config.kt @@ -21,6 +21,9 @@ package com.vitorpamplona.amethyst.cli import com.fasterxml.jackson.module.kotlin.readValue +import com.vitorpamplona.amethyst.cli.secrets.IdentityFile +import com.vitorpamplona.amethyst.cli.secrets.IdentitySecret +import com.vitorpamplona.amethyst.cli.secrets.SecretStore import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair @@ -76,6 +79,24 @@ data class Identity( nsec = null, npub = pubHex.hexToByteArray().toNpub(), ) + + /** + * Rebuild an in-memory identity after a load. Accepts the public + * parts that live on disk and a private key resolved from the + * backend (or null for read-only accounts). Re-derives `nsec` so + * callers that print it (e.g. `amy init`) keep working. + */ + fun fromDisk( + pubKeyHex: String, + npub: String, + privKeyHex: String?, + ): Identity = + Identity( + privKeyHex = privKeyHex, + pubKeyHex = pubKeyHex, + nsec = privKeyHex?.hexToByteArray()?.toNsec(), + npub = npub, + ) } } @@ -88,9 +109,14 @@ data class RunState( /** * Root of the on-disk layout. Any absolute path chosen by `--data-dir` (or * `$AMETHYST_CLI_DATA`) — defaults to `./amy`. + * + * [secrets] is the [SecretStore] that mediates private-key persistence. + * Owning it here keeps the call sites that already thread [DataDir] from + * having to learn about a second parameter. */ class DataDir( val root: File, + val secrets: SecretStore, ) { val identityFile = File(root, "identity.json") val stateFile = File(root, "state.json") @@ -102,27 +128,80 @@ class DataDir( val eventsDir = File(root, "events-store") init { - root.mkdirs() - groupsDir.mkdirs() + SecureFileIO.secureMkdirs(root) + SecureFileIO.secureMkdirs(groupsDir) + // Tighten perms on any data already on disk from an older, unhardened CLI. + SecureFileIO.tighten(identityFile) + SecureFileIO.tighten(stateFile) + SecureFileIO.tighten(marmotDir) + SecureFileIO.tighten(keyPackageBundleFile) } - fun loadIdentityOrNull(): Identity? = if (identityFile.exists()) Json.mapper.readValue(identityFile.readText()) else null + /** + * Read the on-disk metadata without touching any backend. Safe to use + * for "does an identity exist?" / "what's the npub?" checks that must + * not pop a keychain prompt or ask for a passphrase. + */ + fun loadIdentityFileOrNull(): IdentityFile? = if (identityFile.exists()) Json.mapper.readValue(identityFile.readText()) else null + fun identityExists(): Boolean = identityFile.exists() + + /** + * Load the identity from disk. Resolves the private key through the + * configured [SecretStore] (prompting for a passphrase if needed) and + * auto-migrates pre-secret-store files that still carry `privKeyHex`/ + * `nsec` at the top level — the migrated content is written back via + * [saveIdentity] on the next explicit save, not eagerly on load. + */ + fun loadIdentityOrNull(): Identity? { + val file = loadIdentityFileOrNull() ?: return null + val privHex: String? = + when { + file.secret != null -> secrets.resolve(file.secret) + file.privKeyHex != null -> file.privKeyHex + file.nsec != null -> file.nsec.bechToBytes().toHexKey() + else -> null // read-only + } + return Identity.fromDisk(pubKeyHex = file.pubKeyHex, npub = file.npub, privKeyHex = privHex) + } + + /** + * Persist [id]. When [id] carries a private key, [SecretStore.store] is + * called to push it to the selected backend (keychain / ncryptsec / + * plaintext); only the resulting [IdentitySecret] reference is written + * to disk. Read-only identities persist `secret: null`. + */ fun saveIdentity(id: Identity) { - identityFile.writeText(Json.mapper.writeValueAsString(id)) + val secret: IdentitySecret? = id.privKeyHex?.let { secrets.store(id.pubKeyHex, it) } + val file = IdentityFile(pubKeyHex = id.pubKeyHex, npub = id.npub, secret = secret) + SecureFileIO.writeTextAtomic(identityFile, Json.mapper.writeValueAsString(file)) + } + + /** Remove the identity file and any backend-held secret. */ + fun deleteIdentity() { + if (identityFile.exists()) { + runCatching { + val file = Json.mapper.readValue(identityFile.readText()) + file.secret?.let { secrets.delete(it) } + } + identityFile.delete() + } } fun loadRunState(): RunState = if (stateFile.exists()) Json.mapper.readValue(stateFile.readText()) else RunState() fun saveRunState(s: RunState) { - stateFile.writeText(Json.mapper.writeValueAsString(s)) + SecureFileIO.writeTextAtomic(stateFile, Json.mapper.writeValueAsString(s)) } companion object { - fun resolve(flag: String?): DataDir { + fun resolve( + flag: String?, + secrets: SecretStore, + ): DataDir { val envPath = System.getenv("AMETHYST_CLI_DATA") val path = flag ?: envPath ?: "./amy" - return DataDir(File(path).absoluteFile) + return DataDir(File(path).absoluteFile, secrets) } } } diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt index 424b2f129..a0c847830 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.cli import com.vitorpamplona.amethyst.cli.commands.Commands +import com.vitorpamplona.amethyst.cli.secrets.SecretStore import kotlinx.coroutines.runBlocking import kotlin.system.exitProcess @@ -70,35 +71,31 @@ private suspend fun dispatch(argv: Array): Int { return 0 } - // Pull --data-dir out of argv before subcommand parsing so subcommands see + // Pull global flags out of argv before subcommand parsing so subcommands see // only their own args. val filteredArgs = mutableListOf() var dataDirFlag: String? = null + var secretBackendFlag: String? = null + var passphraseFileFlag: String? = null var i = 0 while (i < argv.size) { - when (val a = argv[i]) { - "--data-dir" -> { - dataDirFlag = argv.getOrNull(i + 1) - i += 2 - } - - else -> { - if (a.startsWith("--data-dir=")) { - dataDirFlag = a.removePrefix("--data-dir=") - i++ - } else { - filteredArgs.add(a) - i++ - } - } + val a = argv[i] + val (matched, consumed) = extractGlobalFlag(a, argv, i) + when (matched) { + GlobalFlag.DATA_DIR -> dataDirFlag = consumed.value + GlobalFlag.SECRET_BACKEND -> secretBackendFlag = consumed.value + GlobalFlag.PASSPHRASE_FILE -> passphraseFileFlag = consumed.value + null -> filteredArgs.add(a) } + i += consumed.tokensConsumed } if (filteredArgs.isEmpty()) { printUsage() return 2 } - val dataDir = DataDir.resolve(dataDirFlag) + val secrets = SecretStore.from(backendFlag = secretBackendFlag, passphraseFile = passphraseFileFlag) + val dataDir = DataDir.resolve(dataDirFlag, secrets) val head = filteredArgs[0] val tail = filteredArgs.drop(1).toTypedArray() @@ -190,13 +187,59 @@ private suspend fun marmotDispatch( } } +private enum class GlobalFlag( + val long: String, +) { + DATA_DIR("--data-dir"), + SECRET_BACKEND("--secret-backend"), + PASSPHRASE_FILE("--passphrase-file"), +} + +private data class ConsumedFlag( + val value: String?, + val tokensConsumed: Int, +) + +/** + * Match a single argv token against the global-flag whitelist. Returns + * `(matchedFlag, parsed)` — when [matchedFlag] is null the token is a + * subcommand/positional that the caller should forward untouched. + */ +private fun extractGlobalFlag( + token: String, + argv: Array, + idx: Int, +): Pair { + for (flag in GlobalFlag.values()) { + if (token == flag.long) { + return flag to ConsumedFlag(argv.getOrNull(idx + 1), 2) + } + val prefix = "${flag.long}=" + if (token.startsWith(prefix)) { + return flag to ConsumedFlag(token.removePrefix(prefix), 1) + } + } + return null to ConsumedFlag(null, 1) +} + private fun printUsage() { System.err.println( """ |amy — Amethyst command-line interface | |Usage: - | amy [--data-dir PATH] [args...] + | amy [--data-dir PATH] + | [--secret-backend auto|keychain|ncryptsec|plaintext] + | [--passphrase-file PATH] + | [args...] + | + |Private-key storage: + | Default (`auto`) uses the OS keychain when one is available + | (macOS `security`, or Linux `secret-tool` on a session D-Bus) + | and falls back to a NIP-49 ncryptsec blob otherwise. For the + | ncryptsec backend the passphrase is taken from --passphrase-file, + | then ${'$'}AMY_PASSPHRASE, then a TTY prompt. `plaintext` writes the + | private key directly into identity.json (still 0600) — dev only. | |Identity: | init [--nsec NSEC] create or import a bare identity (no defaults published) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/SecureFileIO.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/SecureFileIO.kt new file mode 100644 index 000000000..b2d2be10e --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/SecureFileIO.kt @@ -0,0 +1,163 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.cli + +import java.io.File +import java.io.OutputStream +import java.nio.file.AtomicMoveNotSupportedException +import java.nio.file.FileAlreadyExistsException +import java.nio.file.FileSystems +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.StandardCopyOption +import java.nio.file.StandardOpenOption +import java.nio.file.attribute.FileAttribute +import java.nio.file.attribute.PosixFilePermission +import java.nio.file.attribute.PosixFilePermissions + +/** + * Owner-only writes for the on-disk CLI data (identity, relay config, MLS + * state, decrypted group messages). POSIX filesystems get 0600 files / 0700 + * directories; non-POSIX (Windows) falls back to `File.setReadable/Writable/ + * Executable(…, false)` to strip other-user access. + * + * Atomic overwrites use a sibling tempfile + `ATOMIC_MOVE`, so a crash never + * leaves a partial world-readable file behind. + * + * Caveat: these permissions block *other OS users*. They do not block another + * app running as the same user — that threat requires encryption at rest. + */ +object SecureFileIO { + private val posixSupported: Boolean = + FileSystems.getDefault().supportedFileAttributeViews().contains("posix") + + private val filePerms: Set = PosixFilePermissions.fromString("rw-------") + private val dirPerms: Set = PosixFilePermissions.fromString("rwx------") + + private val fileAttr: Array> = + if (posixSupported) arrayOf(PosixFilePermissions.asFileAttribute(filePerms)) else emptyArray() + private val dirAttr: Array> = + if (posixSupported) arrayOf(PosixFilePermissions.asFileAttribute(dirPerms)) else emptyArray() + + /** Create [dir] and any missing parents with owner-only permissions. */ + fun secureMkdirs(dir: File) { + val target = dir.toPath() + val missing = ArrayDeque() + var p: Path? = target + while (p != null && !Files.exists(p)) { + missing.addFirst(p) + p = p.parent + } + for (m in missing) { + try { + Files.createDirectory(m, *dirAttr) + } catch (_: FileAlreadyExistsException) { + // benign race with another process + } + applyPerms(m, isDir = true) + } + if (missing.isEmpty() && Files.exists(target)) applyPerms(target, isDir = true) + } + + /** Apply owner-only perms to [file] if it exists. Used to tighten pre-existing data on upgrade. */ + fun tighten(file: File) { + val path = file.toPath() + if (!Files.exists(path)) return + applyPerms(path, isDir = Files.isDirectory(path)) + } + + fun writeTextAtomic( + file: File, + text: String, + ) = writeBytesAtomic(file, text.toByteArray(Charsets.UTF_8)) + + fun writeBytesAtomic( + file: File, + bytes: ByteArray, + ) = writeAtomic(file) { it.write(bytes) } + + /** Overwrite [file] via a sibling tempfile so partial writes can't replace the target. */ + fun writeAtomic( + file: File, + write: (OutputStream) -> Unit, + ) { + val target = file.toPath() + val parent = target.parent ?: error("file must have a parent directory: $file") + secureMkdirs(parent.toFile()) + val temp = Files.createTempFile(parent, ".${file.name}.", ".tmp", *fileAttr) + var moved = false + try { + Files.newOutputStream(temp).use(write) + applyPerms(temp, isDir = false) + try { + Files.move(temp, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING) + } catch (_: AtomicMoveNotSupportedException) { + Files.move(temp, target, StandardCopyOption.REPLACE_EXISTING) + } + moved = true + applyPerms(target, isDir = false) + } finally { + if (!moved) Files.deleteIfExists(temp) + } + } + + /** Append to [file], creating it with owner-only perms if missing. */ + fun appendText( + file: File, + text: String, + ) { + val path = file.toPath() + val parent = path.parent ?: error("file must have a parent directory: $file") + secureMkdirs(parent.toFile()) + if (!Files.exists(path)) { + try { + Files.createFile(path, *fileAttr) + } catch (_: FileAlreadyExistsException) { + // benign race + } + } + applyPerms(path, isDir = false) + Files.newOutputStream(path, StandardOpenOption.APPEND).use { + it.write(text.toByteArray(Charsets.UTF_8)) + } + } + + private fun applyPerms( + path: Path, + isDir: Boolean, + ) { + if (posixSupported) { + try { + Files.setPosixFilePermissions(path, if (isDir) dirPerms else filePerms) + } catch (_: UnsupportedOperationException) { + // fall through to legacy path + } + return + } + val f = path.toFile() + f.setReadable(false, false) + f.setWritable(false, false) + f.setExecutable(false, false) + f.setReadable(true, true) + f.setWritable(true, true) + if (isDir) f.setExecutable(true, true) + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/CreateCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/CreateCommand.kt index 56fb0826d..dcdfdf193 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/CreateCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/CreateCommand.kt @@ -51,7 +51,7 @@ object CreateCommand { dataDir: DataDir, rest: Array, ): Int { - if (dataDir.loadIdentityOrNull() != null) { + if (dataDir.identityExists()) { return Json.error("exists", "identity already exists at ${dataDir.identityFile}") } val args = Args(rest) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/InitCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/InitCommands.kt index 2c8cf973e..ccb773404 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/InitCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/InitCommands.kt @@ -30,20 +30,30 @@ object InitCommands { dataDir: DataDir, args: Args, ): Int { - val existing = dataDir.loadIdentityOrNull() - val id = - existing ?: run { - val nsec = args.flag("nsec") - val created = if (nsec != null) Identity.fromNsec(nsec) else Identity.create() - dataDir.saveIdentity(created) - created - } + // On re-run we return metadata only. Unlocking the stored secret here + // would trigger a keychain prompt / passphrase dialog even though the + // caller clearly already has the identity set up. + dataDir.loadIdentityFileOrNull()?.let { existing -> + Json.writeLine( + mapOf( + "npub" to existing.npub, + "hex" to existing.pubKeyHex, + "nsec" to null, + "existing" to true, + "data_dir" to dataDir.root.absolutePath, + ), + ) + return 0 + } + val nsec = args.flag("nsec") + val created = if (nsec != null) Identity.fromNsec(nsec) else Identity.create() + dataDir.saveIdentity(created) Json.writeLine( mapOf( - "npub" to id.npub, - "hex" to id.pubKeyHex, - "nsec" to id.nsec, - "existing" to (existing != null), + "npub" to created.npub, + "hex" to created.pubKeyHex, + "nsec" to created.nsec, + "existing" to false, "data_dir" to dataDir.root.absolutePath, ), ) @@ -51,14 +61,16 @@ object InitCommands { } suspend fun whoami(dataDir: DataDir): Int { - val id = dataDir.loadIdentityOrNull() - if (id == null) { + // Intentionally metadata-only so `whoami` doesn't pop a keychain prompt + // or ask for a NIP-49 passphrase just to echo the npub. + val file = dataDir.loadIdentityFileOrNull() + if (file == null) { return Json.error("no_identity", "No identity at ${dataDir.identityFile}. Run `init` first.") } Json.writeLine( mapOf( - "npub" to id.npub, - "hex" to id.pubKeyHex, + "npub" to file.npub, + "hex" to file.pubKeyHex, "data_dir" to dataDir.root.absolutePath, ), ) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/LoginCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/LoginCommand.kt index 3cbd1f88b..a8ae8119d 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/LoginCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/LoginCommand.kt @@ -52,7 +52,7 @@ object LoginCommand { if (rest.isEmpty()) { return Json.error("bad_args", "login [--password X]") } - if (dataDir.loadIdentityOrNull() != null) { + if (dataDir.identityExists()) { return Json.error("exists", "identity already exists at ${dataDir.identityFile}; use a fresh --data-dir or delete it first") } diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/secrets/IdentitySecret.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/secrets/IdentitySecret.kt new file mode 100644 index 000000000..f91000fd0 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/secrets/IdentitySecret.kt @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.cli.secrets + +import com.fasterxml.jackson.annotation.JsonInclude +import com.fasterxml.jackson.annotation.JsonSubTypes +import com.fasterxml.jackson.annotation.JsonTypeInfo + +/** + * Where the private key physically lives. Persisted inside `identity.json` + * as the `secret` field. Read-only identities persist `secret: null`. + * + * Variants: + * - [Keychain] — private key held in the OS keychain; file stores only a + * reference (service + account). On macOS the Keychain ACL binds the item + * to the binary that stored it so other apps need user consent; on Linux + * Secret Service any app on the user's D-Bus session can retrieve it once + * the keyring is unlocked — the file-level gain there is at-rest + * encryption while the keyring is locked. + * - [Ncryptsec] — NIP-49 scrypt+XChaCha20 blob. The passphrase is supplied + * at runtime (env / file / TTY) and no other same-user app can decrypt + * the blob without that passphrase. Protects against same-user malware + * on every platform at the cost of requiring a passphrase per session. + * - [Plaintext] — opt-in escape hatch for dev scripts that want to diff + * identity.json. Equivalent to the old pre-hardening behaviour plus the + * 0600 file mode from `SecureFileIO`. + */ +@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY, property = "type") +@JsonSubTypes( + JsonSubTypes.Type(value = IdentitySecret.Keychain::class, name = "keychain"), + JsonSubTypes.Type(value = IdentitySecret.Ncryptsec::class, name = "ncryptsec"), + JsonSubTypes.Type(value = IdentitySecret.Plaintext::class, name = "plaintext"), +) +sealed interface IdentitySecret { + data class Keychain( + val backend: String, + val service: String, + val account: String, + ) : IdentitySecret + + data class Ncryptsec( + val ncryptsec: String, + ) : IdentitySecret + + data class Plaintext( + val privKeyHex: String, + ) : IdentitySecret +} + +/** + * On-disk shape of `identity.json`. The public fields are always present; + * the private key is stored indirectly via [secret]. The two `legacy*` fields + * are honoured when reading a pre-secret-store file so existing users are + * auto-migrated on the next save-capable command. + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +data class IdentityFile( + val pubKeyHex: String, + val npub: String, + val secret: IdentitySecret? = null, + // Tolerated on read for forward-compat with pre-secret-store data-dirs; + // never written by current code. + val privKeyHex: String? = null, + val nsec: String? = null, +) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/secrets/PassphraseProvider.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/secrets/PassphraseProvider.kt new file mode 100644 index 000000000..81aa61439 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/secrets/PassphraseProvider.kt @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.cli.secrets + +import java.io.File + +/** + * Resolves a passphrase for NIP-49 operations. Precedence (highest first): + * + * 1. `--passphrase-file PATH` — trimmed trailing newline; convenient for + * scripted test harnesses where a fifo/tmpfile is set up per invocation. + * 2. `$AMY_PASSPHRASE` — quick and agent-friendly. Visible in `/proc/PID/environ` + * to other same-user processes, so prefer the file form on shared machines. + * 3. TTY prompt — last resort; requires `System.console()` (so not under + * `runBlocking` from a bare `java -cp` invocation without a terminal). + */ +class PassphraseProvider( + private val fileFlag: String? = null, + private val envName: String = "AMY_PASSPHRASE", +) { + fun read( + prompt: String, + confirm: Boolean = false, + ): String { + fileFlag?.let { path -> + val text = File(path).readText().trimEnd('\n', '\r') + if (text.isEmpty()) throw IllegalArgumentException("--passphrase-file $path is empty") + return text + } + System.getenv(envName)?.takeIf { it.isNotEmpty() }?.let { return it } + val console = + System.console() + ?: throw IllegalStateException( + "No TTY and no passphrase source: set $envName or pass --passphrase-file PATH.", + ) + val first = console.readPassword("$prompt: ").concatToString() + if (first.isEmpty()) throw IllegalArgumentException("empty passphrase") + if (confirm) { + val second = console.readPassword("Confirm passphrase: ").concatToString() + if (first != second) throw IllegalArgumentException("passphrases do not match") + } + return first + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/secrets/SecretStore.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/secrets/SecretStore.kt new file mode 100644 index 000000000..ef1f57e11 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/secrets/SecretStore.kt @@ -0,0 +1,366 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.cli.secrets + +import com.vitorpamplona.quartz.nip49PrivKeyEnc.Nip49 +import java.io.File +import java.util.concurrent.TimeUnit + +/** + * Backend responsible for the round-trip `privKeyHex ⇄ IdentitySecret`. + * + * Every backend operates on a single identity's material. Creating or rotating + * a key goes through [store]; a subsequent load routes the persisted + * [IdentitySecret] back through [resolve]. Backends are selected in + * [SecretStore] — callers should not instantiate concrete backends directly. + */ +internal interface SecretBackend { + val name: String + + /** Quick probe that avoids committing to the backend before we know it works. */ + fun isAvailable(): Boolean + + fun store( + pubKeyHex: String, + privKeyHex: String, + ): IdentitySecret + + fun resolve(secret: IdentitySecret): String + + fun delete(secret: IdentitySecret) +} + +/** + * Facade over [SecretBackend]s. Decides which backend handles a new save + * (based on the `--secret-backend` flag or platform auto-detection) and + * routes a stored [IdentitySecret] back to its originating backend on load. + */ +class SecretStore internal constructor( + private val passphrase: PassphraseProvider, + private val backendOverride: String?, +) { + /** Pick a backend for a new private-key save. */ + internal fun selectBackend(): SecretBackend = + when (backendOverride) { + null, "auto" -> { + pickKeychain() ?: NcryptsecBackend(passphrase) + } + + "keychain" -> { + pickKeychain() ?: throw IllegalStateException( + "no OS keychain backend available on this platform (need /usr/bin/security on macOS " + + "or secret-tool + an active Secret Service on Linux)", + ) + } + + "ncryptsec" -> { + NcryptsecBackend(passphrase) + } + + "plaintext" -> { + PlaintextBackend + } + + else -> { + throw IllegalArgumentException("unknown --secret-backend: $backendOverride") + } + } + + /** Route a stored secret descriptor back to its originating backend. */ + internal fun backendFor(secret: IdentitySecret): SecretBackend = + when (secret) { + is IdentitySecret.Keychain -> { + when (secret.backend) { + MacosKeychainBackend.BACKEND_ID -> MacosKeychainBackend + SecretServiceBackend.BACKEND_ID -> SecretServiceBackend + else -> throw IllegalStateException("unknown keychain backend: ${secret.backend}") + } + } + + is IdentitySecret.Ncryptsec -> { + NcryptsecBackend(passphrase) + } + + is IdentitySecret.Plaintext -> { + PlaintextBackend + } + } + + private fun pickKeychain(): SecretBackend? = sequenceOf(MacosKeychainBackend, SecretServiceBackend).firstOrNull { it.isAvailable() } + + fun store( + pubKeyHex: String, + privKeyHex: String, + ): IdentitySecret = selectBackend().store(pubKeyHex, privKeyHex) + + fun resolve(secret: IdentitySecret): String = backendFor(secret).resolve(secret) + + fun delete(secret: IdentitySecret) { + try { + backendFor(secret).delete(secret) + } catch (e: Exception) { + System.err.println("[cli] secret delete failed: ${e.message}") + } + } + + companion object { + /** + * Build a [SecretStore] from command-line flags + environment. + * + * - [backendFlag] = `auto` | `keychain` | `ncryptsec` | `plaintext` + * - [passphraseFile] reads passphrase from a file (for scripted test + * harnesses); otherwise `$AMY_PASSPHRASE` is consulted, then a TTY + * prompt. Only used by [NcryptsecBackend]. + */ + fun from( + backendFlag: String?, + passphraseFile: String?, + ): SecretStore = SecretStore(PassphraseProvider(passphraseFile), backendFlag) + } +} + +// --------------------------------------------------------------------------- +// macOS Keychain backend — shells out to /usr/bin/security. +// --------------------------------------------------------------------------- + +internal object MacosKeychainBackend : SecretBackend { + const val BACKEND_ID = "macos" + private const val SERVICE_PREFIX = "amy-nostr" + private const val SECURITY_BIN = "/usr/bin/security" + + override val name: String = "keychain:$BACKEND_ID" + + override fun isAvailable(): Boolean { + val os = System.getProperty("os.name")?.lowercase().orEmpty() + if (!os.contains("mac") && !os.contains("darwin")) return false + return File(SECURITY_BIN).canExecute() + } + + override fun store( + pubKeyHex: String, + privKeyHex: String, + ): IdentitySecret { + val service = SERVICE_PREFIX + // -U updates in place if an item with (-s, -a) already exists. The private + // key appears in the command line momentarily — same-user /proc reads + // could observe it, which is the exact threat we are defending against. + // `security` has no non-interactive stdin path for add-generic-password, + // so this is the standard trade-off every keychain helper makes. + val res = + runProc( + SECURITY_BIN, + "add-generic-password", + "-U", + "-s", + service, + "-a", + pubKeyHex, + "-l", + "amy Nostr identity ${pubKeyHex.take(8)}", + "-D", + "amy nostr key", + "-w", + privKeyHex, + ) + if (res.exit != 0) { + throw RuntimeException("security add-generic-password failed (exit=${res.exit}): ${res.stderr.trim()}") + } + return IdentitySecret.Keychain(backend = BACKEND_ID, service = service, account = pubKeyHex) + } + + override fun resolve(secret: IdentitySecret): String { + require(secret is IdentitySecret.Keychain) + val res = runProc(SECURITY_BIN, "find-generic-password", "-s", secret.service, "-a", secret.account, "-w") + if (res.exit != 0) { + throw RuntimeException( + "security find-generic-password failed (exit=${res.exit}): ${res.stderr.trim()} — " + + "the user may have denied the access prompt", + ) + } + return res.stdout.trim() + } + + override fun delete(secret: IdentitySecret) { + require(secret is IdentitySecret.Keychain) + runProc(SECURITY_BIN, "delete-generic-password", "-s", secret.service, "-a", secret.account) + } +} + +// --------------------------------------------------------------------------- +// Linux Secret Service backend — shells out to `secret-tool`. +// --------------------------------------------------------------------------- + +internal object SecretServiceBackend : SecretBackend { + const val BACKEND_ID = "secret-service" + private const val SERVICE_ATTR = "amy-nostr" + + override val name: String = "keychain:$BACKEND_ID" + + override fun isAvailable(): Boolean { + val os = System.getProperty("os.name")?.lowercase().orEmpty() + if (!os.contains("linux")) return false + // secret-tool requires a running Secret Service daemon which lives on + // the session D-Bus. Headless servers typically lack both. + if (System.getenv("DBUS_SESSION_BUS_ADDRESS").isNullOrEmpty() && + System.getenv("XDG_RUNTIME_DIR").isNullOrEmpty() + ) { + return false + } + return which("secret-tool") != null + } + + override fun store( + pubKeyHex: String, + privKeyHex: String, + ): IdentitySecret { + val res = + runProc( + "secret-tool", + "store", + "--label=amy Nostr identity ${pubKeyHex.take(8)}", + "service", + SERVICE_ATTR, + "account", + pubKeyHex, + stdin = privKeyHex.toByteArray(), + ) + if (res.exit != 0) { + throw RuntimeException("secret-tool store failed (exit=${res.exit}): ${res.stderr.trim()}") + } + return IdentitySecret.Keychain(backend = BACKEND_ID, service = SERVICE_ATTR, account = pubKeyHex) + } + + override fun resolve(secret: IdentitySecret): String { + require(secret is IdentitySecret.Keychain) + val res = runProc("secret-tool", "lookup", "service", secret.service, "account", secret.account) + if (res.exit != 0 || res.stdout.isBlank()) { + throw RuntimeException("secret-tool lookup failed (exit=${res.exit}): ${res.stderr.trim()}") + } + return res.stdout.trim() + } + + override fun delete(secret: IdentitySecret) { + require(secret is IdentitySecret.Keychain) + runProc("secret-tool", "clear", "service", secret.service, "account", secret.account) + } + + private fun which(cmd: String): File? { + val path = System.getenv("PATH") ?: return null + for (dir in path.split(File.pathSeparator)) { + val f = File(dir, cmd) + if (f.canExecute()) return f + } + return null + } +} + +// --------------------------------------------------------------------------- +// NIP-49 passphrase-encrypted backend — uses quartz's Nip49 (scrypt+XChaCha20). +// --------------------------------------------------------------------------- + +internal class NcryptsecBackend( + private val passphrase: PassphraseProvider, +) : SecretBackend { + override val name: String = "ncryptsec" + + override fun isAvailable(): Boolean = true + + override fun store( + pubKeyHex: String, + privKeyHex: String, + ): IdentitySecret { + val pw = passphrase.read(prompt = "Passphrase for new identity", confirm = true) + val blob = Nip49().encrypt(privKeyHex, pw) + return IdentitySecret.Ncryptsec(ncryptsec = blob) + } + + override fun resolve(secret: IdentitySecret): String { + require(secret is IdentitySecret.Ncryptsec) + val pw = passphrase.read(prompt = "Passphrase to unlock identity", confirm = false) + return try { + Nip49().decrypt(secret.ncryptsec, pw) + } catch (e: Exception) { + throw RuntimeException("NIP-49 decrypt failed — wrong passphrase?", e) + } + } + + override fun delete(secret: IdentitySecret) { + // Nothing external to forget; the blob is inside identity.json and + // goes away when the file is removed by the caller. + } +} + +// --------------------------------------------------------------------------- +// Plaintext backend — dev escape hatch, equivalent to the old pre-hardening +// behaviour (but still written via SecureFileIO, so 0600 on disk). +// --------------------------------------------------------------------------- + +internal object PlaintextBackend : SecretBackend { + override val name: String = "plaintext" + + override fun isAvailable(): Boolean = true + + override fun store( + pubKeyHex: String, + privKeyHex: String, + ): IdentitySecret = IdentitySecret.Plaintext(privKeyHex = privKeyHex) + + override fun resolve(secret: IdentitySecret): String { + require(secret is IdentitySecret.Plaintext) + return secret.privKeyHex + } + + override fun delete(secret: IdentitySecret) { + // Nothing external. + } +} + +// --------------------------------------------------------------------------- +// Process helper — shared by the keychain backends. +// --------------------------------------------------------------------------- + +internal data class ProcResult( + val exit: Int, + val stdout: String, + val stderr: String, +) + +internal fun runProc( + vararg argv: String, + stdin: ByteArray? = null, + timeoutSecs: Long = 15, +): ProcResult { + val pb = ProcessBuilder(*argv).redirectErrorStream(false) + val proc = pb.start() + if (stdin != null) { + proc.outputStream.use { it.write(stdin) } + } else { + proc.outputStream.close() + } + val out = proc.inputStream.bufferedReader().readText() + val err = proc.errorStream.bufferedReader().readText() + val finished = proc.waitFor(timeoutSecs, TimeUnit.SECONDS) + if (!finished) { + proc.destroyForcibly() + throw RuntimeException("timed out after ${timeoutSecs}s: ${argv.joinToString(" ")}") + } + return ProcResult(proc.exitValue(), out, err) +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/FileStores.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/FileStores.kt index cef8eb47b..0fa614895 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/FileStores.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/FileStores.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.cli.stores +import com.vitorpamplona.amethyst.cli.SecureFileIO import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageBundleStore import com.vitorpamplona.quartz.marmot.mls.group.MarmotMessageStore import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupStateStore @@ -37,13 +38,18 @@ private fun File.deleteOrWarn(tag: String) { * document that implementations MUST encrypt at rest, but this CLI is a * throwaway interop driver running against scratch keys and local scratch * state. Do not point it at real account material. + * + * Writes go through [SecureFileIO] so files land with owner-only filesystem + * permissions and overwrites are atomic. That blocks other OS users; it does + * not block another app running as the same user — that still requires + * encryption at rest. */ class FileMlsGroupStateStore( private val dir: File, ) : MlsGroupStateStore { init { - dir.mkdirs() + SecureFileIO.secureMkdirs(dir) } private fun stateFile(id: String) = File(dir, "$id.state") @@ -54,7 +60,7 @@ class FileMlsGroupStateStore( nostrGroupId: String, state: ByteArray, ) { - stateFile(nostrGroupId).writeBytes(state) + SecureFileIO.writeBytesAtomic(stateFile(nostrGroupId), state) } override suspend fun load(nostrGroupId: String): ByteArray? = stateFile(nostrGroupId).takeIf { it.exists() }?.readBytes() @@ -76,8 +82,7 @@ class FileMlsGroupStateStore( ) { // Layout: [u32 count][(u32 len, bytes) …] — tiny framing so readers // can recover independent byte arrays without TLS plumbing. - val f = retainedFile(nostrGroupId) - f.outputStream().use { out -> + SecureFileIO.writeAtomic(retainedFile(nostrGroupId)) { out -> val buf = java.nio.ByteBuffer.allocate(4) buf.putInt(retainedSecrets.size) out.write(buf.array()) @@ -112,8 +117,7 @@ class FileKeyPackageBundleStore( private val file: File, ) : KeyPackageBundleStore { override suspend fun save(snapshot: ByteArray) { - file.parentFile?.mkdirs() - file.writeBytes(snapshot) + SecureFileIO.writeBytesAtomic(file, snapshot) } override suspend fun load(): ByteArray? = file.takeIf { it.exists() }?.readBytes() @@ -127,7 +131,7 @@ class FileMarmotMessageStore( private val dir: File, ) : MarmotMessageStore { init { - dir.mkdirs() + SecureFileIO.secureMkdirs(dir) } private fun file(id: String) = File(dir, "$id.messages") @@ -138,7 +142,7 @@ class FileMarmotMessageStore( ) { // Each line is one inner event JSON. The store doc tolerates duplicates // so we don't bother deduping here — readers can do it. - file(nostrGroupId).appendText(innerEventJson.replace("\n", " ") + "\n") + SecureFileIO.appendText(file(nostrGroupId), innerEventJson.replace("\n", " ") + "\n") } override suspend fun loadMessages(nostrGroupId: String): List = file(nostrGroupId).takeIf { it.exists() }?.readLines()?.filter { it.isNotBlank() } ?: emptyList() diff --git a/cli/tests/dm/setup.sh b/cli/tests/dm/setup.sh index 148982375..ae72dc5ef 100644 --- a/cli/tests/dm/setup.sh +++ b/cli/tests/dm/setup.sh @@ -71,15 +71,19 @@ preflight_dm() { # --- amy identity wrappers --------------------------------------------------- # Two identities: A (sender) and D (recipient). We reuse A_DIR for parity # with the existing harness files; D_DIR is new. -amy_a() { "$AMY_BIN" --data-dir "$A_DIR" "$@"; } -amy_d() { "$AMY_BIN" --data-dir "$D_DIR" "$@"; } +# +# `--secret-backend=plaintext` keeps these throwaway interop runs headless — +# the default `auto` would try the OS keychain (not available in CI) and then +# ask for a NIP-49 passphrase. Plaintext still writes 0600-owner-only. +amy_a() { "$AMY_BIN" --data-dir "$A_DIR" --secret-backend plaintext "$@"; } +amy_d() { "$AMY_BIN" --data-dir "$D_DIR" --secret-backend plaintext "$@"; } # --- identity bootstrap ------------------------------------------------------ ensure_identity_for() { local who="$1" dir="$2" step "initialising Identity $who (amy at $dir)" local out - out=$("$AMY_BIN" --data-dir "$dir" init) || { + out=$("$AMY_BIN" --data-dir "$dir" --secret-backend plaintext init) || { fail_msg "amy init failed for $who: $out"; exit 1 } local npub hex diff --git a/cli/tests/dm/tests-dm.sh b/cli/tests/dm/tests-dm.sh index 51e1f448b..f8f81ddd8 100644 --- a/cli/tests/dm/tests-dm.sh +++ b/cli/tests/dm/tests-dm.sh @@ -91,7 +91,7 @@ test_03_dm_send_rejects_no_inbox() { # Generate a throwaway identity but do NOT publish its kind:10050. local tmpdir; tmpdir=$(mktemp -d "${STATE_DIR}/ghost.XXXXXX") local ghost_out ghost_npub - ghost_out=$("$AMY_BIN" --data-dir "$tmpdir" init) || { + ghost_out=$("$AMY_BIN" --data-dir "$tmpdir" --secret-backend plaintext init) || { record_result "$id" fail "ghost init failed"; rm -rf "$tmpdir"; return } ghost_npub=$(printf '%s' "$ghost_out" | jq -r '.npub') @@ -121,7 +121,7 @@ test_04_dm_send_allow_fallback() { # publish should succeed even though the ghost has no 10050. local tmpdir; tmpdir=$(mktemp -d "${STATE_DIR}/ghost.XXXXXX") local ghost_out ghost_npub - ghost_out=$("$AMY_BIN" --data-dir "$tmpdir" init) || { + ghost_out=$("$AMY_BIN" --data-dir "$tmpdir" --secret-backend plaintext init) || { record_result "$id" fail "ghost init failed"; rm -rf "$tmpdir"; return } ghost_npub=$(printf '%s' "$ghost_out" | jq -r '.npub') diff --git a/cli/tests/headless/helpers.sh b/cli/tests/headless/helpers.sh index 7c9e55dc0..616f1fda7 100644 --- a/cli/tests/headless/helpers.sh +++ b/cli/tests/headless/helpers.sh @@ -3,7 +3,10 @@ # helpers.sh — thin wrappers that keep the per-test code tight. # --- amy wrapper ------------------------------------------------------------- -amy_a() { "$AMY_BIN" --data-dir "$A_DIR" "$@"; } +# `--secret-backend=plaintext` keeps these throwaway interop runs headless — +# the default `auto` would try the OS keychain (not available in CI) and then +# ask for a NIP-49 passphrase. Plaintext still writes 0600-owner-only. +amy_a() { "$AMY_BIN" --data-dir "$A_DIR" --secret-backend plaintext "$@"; } # Run amy, log stderr, surface JSON on stdout, remember last result. amy_json() { diff --git a/scripts/asset-name.sh b/scripts/asset-name.sh index eab8f6bed..aac7b3147 100755 --- a/scripts/asset-name.sh +++ b/scripts/asset-name.sh @@ -1,7 +1,10 @@ #!/usr/bin/env bash -# Single source of truth for Amethyst Desktop release asset naming. +# Single source of truth for Amethyst release asset naming. +# +# Two product lines share this contract: +# amethyst-desktop---. (Compose Desktop app) +# amy---. (Amy CLI — cli/ module) # -# Contract: amethyst-desktop---. # = tag stripped of leading 'v' (e.g. "1.08.0") # = macos | windows | linux # = x64 | arm64 @@ -12,7 +15,8 @@ # # Usage: # source scripts/asset-name.sh -# collect_assets +# collect_assets # desktop +# collect_cli_assets # amy CLI # # Expected examples: # amethyst-desktop-1.08.0-macos-x64.dmg @@ -23,6 +27,11 @@ # amethyst-desktop-1.08.0-linux-x64.rpm # amethyst-desktop-1.08.0-linux-x64.AppImage # amethyst-desktop-1.08.0-linux-x64.tar.gz +# amy-1.08.0-macos-arm64.tar.gz +# amy-1.08.0-macos-x64.tar.gz +# amy-1.08.0-linux-x64.tar.gz +# amy-1.08.0-linux-x64.deb +# amy-1.08.0-linux-x64.rpm set -euo pipefail @@ -33,6 +42,13 @@ asset_name() { printf 'amethyst-desktop-%s-%s-%s.%s' "$version" "$family" "$arch" "$ext" } +# CLI variant of asset_name — same shape, different product prefix. +# Usage: cli_asset_name +cli_asset_name() { + local family="$1" arch="$2" version="$3" ext="$4" + printf 'amy-%s-%s-%s.%s' "$version" "$family" "$arch" "$ext" +} + # Copy + rename build outputs into using the canonical naming scheme. # Usage: collect_assets # Expects build outputs under desktopApp/build/... (Compose binaries + custom tasks + portable archives). @@ -66,3 +82,48 @@ collect_assets() { done shopt -u nullglob } + +# Copy + rename amy CLI build outputs into using the canonical +# amy---. scheme. +# +# Expected inputs: +# cli/build/amy-image/amy/ flat app-image built by :cli:amyImage +# (bin/amy + lib/*.jar + runtime/) +# cli/build/jpackage/*.deb from :cli:jpackageDeb (Linux only) +# cli/build/jpackage/*.rpm from :cli:jpackageRpm (Linux only) +# +# Usage: collect_cli_assets +collect_cli_assets() { + local family="$1" arch="$2" version="$3" dest="$4" + mkdir -p "$dest" + local abs_dest + abs_dest="$(cd "$dest" && pwd)" + shopt -s nullglob + + # 1. Tar the flat app-image into amy---.tar.gz. + # This is the portable-across-OS asset — macOS runners produce only + # this one. + local app_image="cli/build/amy-image/amy" + if [ -d "$app_image" ]; then + local tarball="$abs_dest/$(cli_asset_name "$family" "$arch" "$version" tar.gz)" + ( cd "$(dirname "$app_image")" && tar czf "$tarball" "$(basename "$app_image")" ) + echo "Collected: $tarball" + fi + + # 2. Linux native installers (.deb, .rpm). jpackage writes them directly + # to cli/build/jpackage/ with its own naming, so we re-copy under the + # canonical scheme. + local src base ext dst + for src in \ + cli/build/jpackage/*.deb \ + cli/build/jpackage/*.rpm \ + ; do + [ -f "$src" ] || continue + base="$(basename "$src")" + ext="${base##*.}" + dst="$abs_dest/$(cli_asset_name "$family" "$arch" "$version" "$ext")" + cp "$src" "$dst" + echo "Collected: $dst" + done + shopt -u nullglob +}