Merge remote-tracking branch 'origin/main' into claude/cli-event-store-database-ef2U2

# Conflicts:
#	cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Config.kt
This commit is contained in:
Claude
2026-04-25 04:58:41 +00:00
20 changed files with 1294 additions and 163 deletions
+26 -85
View File
@@ -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 }}
+136
View File
@@ -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/<gid>.mls, line endings
# in identity.json).
#
# Asset naming: amy-<version>-<family>-<arch>.<ext>. 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.
@@ -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,
)
}
@@ -49,6 +49,7 @@
<string name="login_with_a_private_key_to_be_able_to_boost_posts">Używasz klucza publicznego, a klucze publiczne są tylko do odczytu. Zaloguj się za pomocą klucza prywatnego, aby zwiększyć liczbę postów</string>
<string name="login_with_a_private_key_to_like_posts">Używasz klucza publicznego, a klucze publiczne są tylko do odczytu. Zaloguj się za pomocą klucza prywatnego, aby polubić posty</string>
<string name="no_zap_amount_setup_long_press_to_change">Nie ustawiono kwoty zap-a. Naciśnij i przytrzymaj, aby zmienić</string>
<string name="chat_zap_amount_suffix">przesłał %1$s satoszy</string>
<string name="chat_zap_anonymous">Anonimowo</string>
<string name="chat_raid_is_raiding">prowadzi nalot</string>
<string name="chat_clip_created_a_clip">stworzył klip</string>
@@ -338,6 +339,7 @@
<string name="drawer_accounts">Konta</string>
<string name="drawer_section_navigate">Nawigacja</string>
<string name="drawer_section_you">Ty</string>
<string name="drawer_section_feeds">Kanały</string>
<string name="drawer_section_create">Utwórz</string>
<string name="drawer_section_system">System</string>
<string name="account_switch_select_account">Wybierz Konto</string>
@@ -1353,6 +1355,7 @@
<string name="new_exclusive_geo_note">Nowy GEO-ekskluzywny Wpis</string>
<string name="new_long_form_post">Nowy artykuł</string>
<string name="new_poll">Nowa ankieta</string>
<string name="new_zap_poll">Nowa ankieta</string>
<string name="new_regular_poll">Nowa standardowa ankieta</string>
<string name="new_picture">Nowe zdjęcie</string>
<string name="new_short_video">Nowy krótki filmik</string>
@@ -1616,7 +1619,7 @@
<string name="my_lists_and_sets">Moje Listy/Zestawy</string>
<string name="my_lists">Moje listy</string>
<string name="people_list_label">Użytkownicy</string>
<string name="select_list_to_filter">Wybierz listę profili, aby filtrować aktualności</string>
<string name="select_list_to_filter">Wybierz opcję, aby filtrować aktualności</string>
<string name="feed_group_feeds">Kanały</string>
<string name="feed_group_hashtags">Hashtagi</string>
<string name="feed_group_interest_sets">Grupy zainteresowań</string>
@@ -1820,6 +1823,7 @@
<string name="kind_follow_packs">Pakiety subskrybentów</string>
<string name="kind_reposts_16">Udostępnienia (16)</string>
<string name="kind_geohash_follows">Obserwacja geohashów</string>
<string name="kind_gift_wraps">GiftWraps</string>
<string name="kind_git_issue">Problem z Git</string>
<string name="kind_git_patch">Łatka Git</string>
<string name="kind_git_repo">Repozytorium Git</string>
@@ -2239,4 +2243,5 @@
<string name="emoji_private_badge">Prywatna emotikona</string>
<string name="emoji_public_explainer">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.</string>
<string name="emoji_private_explainer">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.</string>
<string name="gif">Gif</string>
</resources>
@@ -780,6 +780,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
<string name="app_notification_chess_challenge_accepted">%1$s je sprejel vaš šahovski izziv</string>
<string name="app_notification_chess_your_turn">Poteza uporabnika %1$s — vi ste na vrsti</string>
<string name="app_notification_chess_summary">Šahovske novosti</string>
<string name="app_notification_mentions_summary">Nove omembe</string>
<!-- Call notifications and UI -->
<string name="app_notification_calls_channel_name">Dohodni klici</string>
<string name="app_notification_calls_channel_description">Obvestila o dohodnih glasovnih in video klicih</string>
+184
View File
@@ -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-<ver>-<fam>-<arch>.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<String> =
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<Exec>("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 <int> is deprecated; use zip-<level>.
"--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<Sync>("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<String> = emptyList(),
) = tasks.register<Exec>(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",
),
)
@@ -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<Identity>(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>(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)
}
}
}
@@ -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<String>): 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<String>()
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<String>,
idx: Int,
): Pair<GlobalFlag?, ConsumedFlag> {
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] <cmd> [args...]
| amy [--data-dir PATH]
| [--secret-backend auto|keychain|ncryptsec|plaintext]
| [--passphrase-file PATH]
| <cmd> [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)
@@ -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<PosixFilePermission> = PosixFilePermissions.fromString("rw-------")
private val dirPerms: Set<PosixFilePermission> = PosixFilePermissions.fromString("rwx------")
private val fileAttr: Array<FileAttribute<*>> =
if (posixSupported) arrayOf(PosixFilePermissions.asFileAttribute(filePerms)) else emptyArray()
private val dirAttr: Array<FileAttribute<*>> =
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<Path>()
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)
}
}
@@ -51,7 +51,7 @@ object CreateCommand {
dataDir: DataDir,
rest: Array<String>,
): Int {
if (dataDir.loadIdentityOrNull() != null) {
if (dataDir.identityExists()) {
return Json.error("exists", "identity already exists at ${dataDir.identityFile}")
}
val args = Args(rest)
@@ -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,
),
)
@@ -52,7 +52,7 @@ object LoginCommand {
if (rest.isEmpty()) {
return Json.error("bad_args", "login <nsec|ncryptsec|mnemonic|npub|nprofile|hex|nip05> [--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")
}
@@ -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,
)
@@ -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
}
}
@@ -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)
}
@@ -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<String> = file(nostrGroupId).takeIf { it.exists() }?.readLines()?.filter { it.isNotBlank() } ?: emptyList()
+7 -3
View File
@@ -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
+2 -2
View File
@@ -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')
+4 -1
View File
@@ -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() {
+64 -3
View File
@@ -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-<version>-<family>-<arch>.<ext> (Compose Desktop app)
# amy-<version>-<family>-<arch>.<ext> (Amy CLI — cli/ module)
#
# Contract: amethyst-desktop-<version>-<family>-<arch>.<ext>
# <version> = tag stripped of leading 'v' (e.g. "1.08.0")
# <family> = macos | windows | linux
# <arch> = x64 | arm64
@@ -12,7 +15,8 @@
#
# Usage:
# source scripts/asset-name.sh
# collect_assets <family> <arch> <version> <dest_dir>
# collect_assets <family> <arch> <version> <dest_dir> # desktop
# collect_cli_assets <family> <arch> <version> <dest_dir> # 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 <family> <arch> <version> <ext>
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 <dest_dir> using the canonical naming scheme.
# Usage: collect_assets <family> <arch> <version> <dest_dir>
# 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 <dest_dir> using the canonical
# amy-<version>-<family>-<arch>.<ext> 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 <family> <arch> <version> <dest_dir>
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-<version>-<family>-<arch>.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
}