feat(cli): move private key out of identity.json into keychain / NIP-49

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
This commit is contained in:
Claude
2026-04-24 21:06:18 +00:00
parent b77e6aa54f
commit b507cb5986
10 changed files with 691 additions and 44 deletions
@@ -21,6 +21,9 @@
package com.vitorpamplona.amethyst.cli package com.vitorpamplona.amethyst.cli
import com.fasterxml.jackson.module.kotlin.readValue 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.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
@@ -78,6 +81,24 @@ data class Identity(
nsec = null, nsec = null,
npub = pubHex.hexToByteArray().toNpub(), 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 * Root of the on-disk layout. Any absolute path chosen by `--data-dir` (or
* `$AMETHYST_CLI_DATA`) — defaults to `./amy`. * `$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( class DataDir(
val root: File, val root: File,
val secrets: SecretStore,
) { ) {
val identityFile = File(root, "identity.json") val identityFile = File(root, "identity.json")
val relaysFile = File(root, "relays.json") val relaysFile = File(root, "relays.json")
@@ -155,10 +181,55 @@ class DataDir(
SecureFileIO.tighten(keyPackageBundleFile) 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) { 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>(identityFile.readText())
file.secret?.let { secrets.delete(it) }
}
identityFile.delete()
}
} }
fun loadRelays(): RelayConfig = if (relaysFile.exists()) Json.mapper.readValue(relaysFile.readText()) else RelayConfig() fun loadRelays(): RelayConfig = if (relaysFile.exists()) Json.mapper.readValue(relaysFile.readText()) else RelayConfig()
@@ -174,10 +245,13 @@ class DataDir(
} }
companion object { companion object {
fun resolve(flag: String?): DataDir { fun resolve(
flag: String?,
secrets: SecretStore,
): DataDir {
val envPath = System.getenv("AMETHYST_CLI_DATA") val envPath = System.getenv("AMETHYST_CLI_DATA")
val path = flag ?: envPath ?: "./amy" 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 package com.vitorpamplona.amethyst.cli
import com.vitorpamplona.amethyst.cli.commands.Commands import com.vitorpamplona.amethyst.cli.commands.Commands
import com.vitorpamplona.amethyst.cli.secrets.SecretStore
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import kotlin.system.exitProcess import kotlin.system.exitProcess
@@ -70,35 +71,31 @@ private suspend fun dispatch(argv: Array<String>): Int {
return 0 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. // only their own args.
val filteredArgs = mutableListOf<String>() val filteredArgs = mutableListOf<String>()
var dataDirFlag: String? = null var dataDirFlag: String? = null
var secretBackendFlag: String? = null
var passphraseFileFlag: String? = null
var i = 0 var i = 0
while (i < argv.size) { while (i < argv.size) {
when (val a = argv[i]) { val a = argv[i]
"--data-dir" -> { val (matched, consumed) = extractGlobalFlag(a, argv, i)
dataDirFlag = argv.getOrNull(i + 1) when (matched) {
i += 2 GlobalFlag.DATA_DIR -> dataDirFlag = consumed.value
} GlobalFlag.SECRET_BACKEND -> secretBackendFlag = consumed.value
GlobalFlag.PASSPHRASE_FILE -> passphraseFileFlag = consumed.value
else -> { null -> filteredArgs.add(a)
if (a.startsWith("--data-dir=")) {
dataDirFlag = a.removePrefix("--data-dir=")
i++
} else {
filteredArgs.add(a)
i++
}
}
} }
i += consumed.tokensConsumed
} }
if (filteredArgs.isEmpty()) { if (filteredArgs.isEmpty()) {
printUsage() printUsage()
return 2 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 head = filteredArgs[0]
val tail = filteredArgs.drop(1).toTypedArray() 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<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() { private fun printUsage() {
System.err.println( System.err.println(
""" """
|amy — Amethyst command-line interface |amy — Amethyst command-line interface
| |
|Usage: |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: |Identity:
| init [--nsec NSEC] create or import a bare identity (no defaults published) | init [--nsec NSEC] create or import a bare identity (no defaults published)
@@ -47,7 +47,7 @@ object CreateCommand {
dataDir: DataDir, dataDir: DataDir,
rest: Array<String>, rest: Array<String>,
): Int { ): Int {
if (dataDir.loadIdentityOrNull() != null) { if (dataDir.identityExists()) {
return Json.error("exists", "identity already exists at ${dataDir.identityFile}") return Json.error("exists", "identity already exists at ${dataDir.identityFile}")
} }
val args = Args(rest) val args = Args(rest)
@@ -30,20 +30,30 @@ object InitCommands {
dataDir: DataDir, dataDir: DataDir,
args: Args, args: Args,
): Int { ): Int {
val existing = dataDir.loadIdentityOrNull() // On re-run we return metadata only. Unlocking the stored secret here
val id = // would trigger a keychain prompt / passphrase dialog even though the
existing ?: run { // caller clearly already has the identity set up.
val nsec = args.flag("nsec") dataDir.loadIdentityFileOrNull()?.let { existing ->
val created = if (nsec != null) Identity.fromNsec(nsec) else Identity.create() Json.writeLine(
dataDir.saveIdentity(created) mapOf(
created "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( Json.writeLine(
mapOf( mapOf(
"npub" to id.npub, "npub" to created.npub,
"hex" to id.pubKeyHex, "hex" to created.pubKeyHex,
"nsec" to id.nsec, "nsec" to created.nsec,
"existing" to (existing != null), "existing" to false,
"data_dir" to dataDir.root.absolutePath, "data_dir" to dataDir.root.absolutePath,
), ),
) )
@@ -51,14 +61,16 @@ object InitCommands {
} }
suspend fun whoami(dataDir: DataDir): Int { suspend fun whoami(dataDir: DataDir): Int {
val id = dataDir.loadIdentityOrNull() // Intentionally metadata-only so `whoami` doesn't pop a keychain prompt
if (id == null) { // 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.") return Json.error("no_identity", "No identity at ${dataDir.identityFile}. Run `init` first.")
} }
Json.writeLine( Json.writeLine(
mapOf( mapOf(
"npub" to id.npub, "npub" to file.npub,
"hex" to id.pubKeyHex, "hex" to file.pubKeyHex,
"data_dir" to dataDir.root.absolutePath, "data_dir" to dataDir.root.absolutePath,
), ),
) )
@@ -52,7 +52,7 @@ object LoginCommand {
if (rest.isEmpty()) { if (rest.isEmpty()) {
return Json.error("bad_args", "login <nsec|ncryptsec|mnemonic|npub|nprofile|hex|nip05> [--password X]") 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") 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)
}
+7 -3
View File
@@ -71,15 +71,19 @@ preflight_dm() {
# --- amy identity wrappers --------------------------------------------------- # --- amy identity wrappers ---------------------------------------------------
# Two identities: A (sender) and D (recipient). We reuse A_DIR for parity # Two identities: A (sender) and D (recipient). We reuse A_DIR for parity
# with the existing harness files; D_DIR is new. # 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 ------------------------------------------------------ # --- identity bootstrap ------------------------------------------------------
ensure_identity_for() { ensure_identity_for() {
local who="$1" dir="$2" local who="$1" dir="$2"
step "initialising Identity $who (amy at $dir)" step "initialising Identity $who (amy at $dir)"
local out 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 fail_msg "amy init failed for $who: $out"; exit 1
} }
local npub hex local npub hex
+4 -1
View File
@@ -3,7 +3,10 @@
# helpers.sh — thin wrappers that keep the per-test code tight. # helpers.sh — thin wrappers that keep the per-test code tight.
# --- amy wrapper ------------------------------------------------------------- # --- 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. # Run amy, log stderr, surface JSON on stdout, remember last result.
amy_json() { amy_json() {