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