From b507cb59865a7a03f882569d975d7c27c6ab6dfc Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Apr 2026 21:06:18 +0000 Subject: [PATCH] feat(cli): move private key out of identity.json into keychain / NIP-49 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit identity.json previously stored `privKeyHex` and `nsec` as plaintext fields. 0600 file perms keep other OS users out, but not another app running as the same user — and that is the threat model the CLI actually cares about. Introduce a SecretStore indirection: * identity.json now persists only the public parts plus a typed `secret: IdentitySecret` envelope (keychain | ncryptsec | plaintext). * macOS uses `/usr/bin/security` (Keychain ACLs bind the item to the binary that stored it, so other same-user apps need user consent). * Linux uses `secret-tool` if a Secret Service is running on the session D-Bus; the gain there is at-rest encryption while the keyring is locked. * On any platform without a keychain, auto falls back to NIP-49 (scrypt + XChaCha20) with the passphrase read from --passphrase-file, then $AMY_PASSPHRASE, then a TTY prompt. Another same-user app can read the blob but cannot decrypt it without the passphrase. * `--secret-backend=plaintext` is an explicit opt-in for dev scripts and the interop test harness. Legacy identity.json files that still carry top-level privKeyHex/nsec are read transparently and auto-migrate on the next save. whoami, `create` / `login` existence checks, and init-re-run now use a metadata-only load path so they do not trigger a keychain prompt or ask for a passphrase just to echo the npub. Tests: cli/tests/ setups wire --secret-backend=plaintext through the amy_a / amy_d wrappers so headless CI runs do not stall on a TTY passphrase prompt. https://claude.ai/code/session_01SqdMfLdXvb3GskFLcEj739 --- .../com/vitorpamplona/amethyst/cli/Config.kt | 82 +++- .../com/vitorpamplona/amethyst/cli/Main.kt | 79 +++- .../amethyst/cli/commands/CreateCommand.kt | 2 +- .../amethyst/cli/commands/InitCommands.kt | 44 ++- .../amethyst/cli/commands/LoginCommand.kt | 2 +- .../amethyst/cli/secrets/IdentitySecret.kt | 83 ++++ .../cli/secrets/PassphraseProvider.kt | 62 +++ .../amethyst/cli/secrets/SecretStore.kt | 366 ++++++++++++++++++ cli/tests/dm/setup.sh | 10 +- cli/tests/headless/helpers.sh | 5 +- 10 files changed, 691 insertions(+), 44 deletions(-) create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/secrets/IdentitySecret.kt create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/secrets/PassphraseProvider.kt create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/secrets/SecretStore.kt 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 1b6c40658..819ca2815 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 @@ -78,6 +81,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, + ) } } @@ -133,9 +154,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 relaysFile = File(root, "relays.json") @@ -155,10 +181,55 @@ class DataDir( 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) { - SecureFileIO.writeTextAtomic(identityFile, 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 loadRelays(): RelayConfig = if (relaysFile.exists()) Json.mapper.readValue(relaysFile.readText()) else RelayConfig() @@ -174,10 +245,13 @@ class DataDir( } 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 5eafa600a..08d211fb9 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() @@ -186,13 +183,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/commands/CreateCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/CreateCommand.kt index b128d8999..a9f9c4a8f 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 @@ -47,7 +47,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/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/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() {