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:
@@ -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<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) {
|
||||
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()
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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() {
|
||||
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)
|
||||
|
||||
@@ -47,7 +47,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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user