diff --git a/cli/README.md b/cli/README.md index a93bf1997f..2b24b2ea5d 100644 --- a/cli/README.md +++ b/cli/README.md @@ -230,6 +230,16 @@ Filter flags are shared by `fetch` and `subscribe`: `--kind K[,K]`, `--author U[ |---|---| | `amy fetch [filter flags] [--timeout SECS]` | One-shot query — collect until every relay sends EOSE (or `--timeout`, default 8s), dedupe, sort newest-first, print and exit. `--limit` defaults to 100. | | `amy subscribe [filter flags] [--timeout SECS]` | Live stream — print each matching event as it arrives (NDJSON under `--json`). Runs until `--timeout` SECS or until interrupted. | +| `amy count [filter flags] [--timeout SECS]` | NIP-45 COUNT — per-relay match counts, no event download. | + +### Encryption + +| Command | What it does | +|---|---| +| `amy encrypt --to USER [TEXT] [--nip04]` | NIP-44 (default) or NIP-04 encrypt with the active account's key. Reads stdin when TEXT is omitted or `-`. USER accepts npub/nprofile/hex/NIP-05. | +| `amy decrypt --from USER [CIPHERTEXT] [--nip04]` | Inverse of `encrypt`. | +| `amy gift wrap --to USER [EVENT-JSON] [--relay …]` | NIP-59: seal a signed inner event for USER and wrap it in a kind:1059 gift wrap. Prints the wrap; `--relay` also broadcasts it. | +| `amy gift unwrap [GIFTWRAP-JSON]` | Decrypt + unseal a kind:1059 wrap addressed to the active account; prints the inner event. | ### Identity diff --git a/cli/ROADMAP.md b/cli/ROADMAP.md index 3d6f4c93ee..9acbcb0a51 100644 --- a/cli/ROADMAP.md +++ b/cli/ROADMAP.md @@ -86,9 +86,9 @@ vs streaming `subscribe`). Stateless verbs run with no account or network. | `publish` | `amy publish` | ✅ | broadcast a pre-made event JSON (verified first). | | `req` (one-shot) | `amy fetch` | ✅ | filter → collect-until-EOSE, dedupe, sort, cap. | | `req` (stream) | `amy subscribe` | ✅ | filter → live NDJSON stream to stdout. | -| `count` | `amy count` | 🆕 | NIP-45. | -| `encrypt` / `decrypt` | `amy encrypt\|decrypt` | 🆕 | raw NIP-44 / NIP-04. | -| `gift` | `amy gift wrap\|unwrap` | 🆕 | NIP-59 primitive (gift-wrap path already used by `dm`). | +| `count` | `amy count` | ✅ | NIP-45, per-relay counts. | +| `encrypt` / `decrypt` | `amy encrypt\|decrypt` | ✅ | raw NIP-44 (default) / NIP-04. | +| `gift` | `amy gift wrap\|unwrap` | ✅ | NIP-59 seal+wrap / unwrap+unseal. | | `relay` (NIP-11) | `amy relay info` | 🆕 | amy's `relay` is config today; add an `info` verb. | | `outbox` | `amy outbox` | 🆕 | NIP-65 relay discovery for a user. | | `blossom` | `amy blossom` | 🆕 | upload/download/list/delete (client already used by `dm`/`nsite`). | diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt index f7d7bc6eda..fc2fccebfa 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -232,6 +232,22 @@ private suspend fun dispatch(argv: Array): Int { Commands.subscribe(dataDir, tail) } + "count" -> { + Commands.count(dataDir, tail) + } + + "encrypt" -> { + Commands.encrypt(dataDir, tail) + } + + "decrypt" -> { + Commands.decrypt(dataDir, tail) + } + + "gift" -> { + Commands.gift(dataDir, tail) + } + else -> { System.err.println("unknown subcommand: $head") printUsage() @@ -430,6 +446,17 @@ private fun printUsage() { | [--timeout SECS] | subscribe [] live stream: print each event as it arrives | [--relay URL[,URL…]] [--timeout SECS] (NDJSON). Runs until --timeout or interrupt. + | count [] NIP-45 COUNT: per-relay match counts, no + | [--relay URL[,URL…]] [--timeout SECS] event download. + | + |Encryption (active account's key): + | encrypt --to USER [TEXT] [--nip04] NIP-44 (default) or NIP-04 encrypt. Reads + | stdin when TEXT is omitted or `-`. + | decrypt --from USER [CIPHERTEXT] [--nip04] inverse of encrypt. + | gift wrap --to USER [EVENT-JSON] NIP-59: seal + wrap a signed inner event for + | [--relay URL[,URL…]] USER (add --relay to broadcast the wrap). + | gift unwrap [GIFTWRAP-JSON] decrypt + unseal a kind:1059 wrap addressed + | to the active account. | |Static websites (NIP-5A kind:15128/35128): | nsite fetch AUTHOR [--d ID] [--path P] resolve one path over Nostr + Blossom and diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Commands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Commands.kt index 2b14554b10..7d7f42020a 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Commands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Commands.kt @@ -155,4 +155,24 @@ object Commands { dataDir: DataDir, tail: Array, ): Int = SubscribeCommand.run(dataDir, tail) + + suspend fun count( + dataDir: DataDir, + tail: Array, + ): Int = CountCommand.run(dataDir, tail) + + suspend fun encrypt( + dataDir: DataDir, + tail: Array, + ): Int = EncryptCommand.run(dataDir, tail) + + suspend fun decrypt( + dataDir: DataDir, + tail: Array, + ): Int = DecryptCommand.run(dataDir, tail) + + suspend fun gift( + dataDir: DataDir, + tail: Array, + ): Int = GiftCommands.dispatch(dataDir, tail) } diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/CountCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/CountCommand.kt new file mode 100644 index 0000000000..10b5c60ee1 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/CountCommand.kt @@ -0,0 +1,78 @@ +/* + * 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.commands + +import com.vitorpamplona.amethyst.cli.Args +import com.vitorpamplona.amethyst.cli.Context +import com.vitorpamplona.amethyst.cli.DataDir +import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.count + +/** + * `amy count [] [--relay URL[,URL…]] [--timeout SECS]` + * + * NIP-45 COUNT: ask each relay how many events match the filter, without + * downloading them (nak's `count`). Reports each relay's reply plus a + * per-relay max as `total` (counts can't be deduplicated across relays, so + * summing would over-count overlapping stores — the max is the safer single + * number). + * + * Thin assembly only: the COUNT round-trip lives in quartz + * (`INostrClient.count`); this file builds the filter and shapes output. + */ +object CountCommand { + suspend fun run( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val timeoutMs = (args.flag("timeout")?.toLongOrNull() ?: 15L) * 1000 + val filter = RawEventSupport.buildFilter(args) + + val ctx = Context.open(dataDir) + try { + ctx.prepare() + val relays = RawEventSupport.queryTargets(ctx, args) + if (relays.isEmpty()) return Output.error("no_relays", "no relays available; pass --relay or run `amy relay add`") + + val results = ctx.client.count(relays.associateWith { listOf(filter) }, timeoutMs) + + Output.emit( + mapOf( + "queried_relays" to relays.map { it.url }, + "responded" to results.size, + "total" to (results.values.maxOfOrNull { it.count } ?: 0), + "per_relay" to + results.map { (relay, res) -> + mapOf( + "relay" to relay.url, + "count" to res.count, + "approximate" to res.approximate, + ) + }, + ), + ) + return 0 + } finally { + ctx.close() + } + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/CryptoCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/CryptoCommands.kt new file mode 100644 index 0000000000..1cc0aaa9b8 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/CryptoCommands.kt @@ -0,0 +1,88 @@ +/* + * 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.commands + +import com.vitorpamplona.amethyst.cli.Args +import com.vitorpamplona.amethyst.cli.Context +import com.vitorpamplona.amethyst.cli.DataDir +import com.vitorpamplona.amethyst.cli.Output + +/** + * `amy encrypt --to USER [TEXT] [--nip04]` and + * `amy decrypt --from USER [CIPHERTEXT] [--nip04]` — raw NIP-44 (default) or + * NIP-04 message encryption with the active account's key (nak's + * `encrypt`/`decrypt`). + * + * The plaintext/ciphertext is taken from the positional argument or stdin + * (when omitted or `-`). USER accepts npub/nprofile/hex/NIP-05. + * + * Thin assembly only: the crypto lives in quartz (`NostrSigner.nip44*` / + * `nip04*`); this file resolves the peer and shuttles strings. + */ +object EncryptCommand { + suspend fun run( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val to = args.flag("to") ?: return Output.error("bad_args", "encrypt requires --to USER") + val nip04 = args.bool("nip04") + val text = RawEventSupport.readArgOrStdin(args) + if (text.isEmpty()) return Output.error("bad_args", "no plaintext on the argument or stdin") + + val ctx = Context.open(dataDir) + try { + ctx.prepare() + val peer = ctx.requireUserHex(to) + val ciphertext = + if (nip04) ctx.signer.nip04Encrypt(text, peer) else ctx.signer.nip44Encrypt(text, peer) + Output.emit(mapOf("algorithm" to if (nip04) "nip04" else "nip44", "ciphertext" to ciphertext)) + return 0 + } finally { + ctx.close() + } + } +} + +object DecryptCommand { + suspend fun run( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val from = args.flag("from") ?: return Output.error("bad_args", "decrypt requires --from USER") + val nip04 = args.bool("nip04") + val ciphertext = RawEventSupport.readArgOrStdin(args) + if (ciphertext.isEmpty()) return Output.error("bad_args", "no ciphertext on the argument or stdin") + + val ctx = Context.open(dataDir) + try { + ctx.prepare() + val peer = ctx.requireUserHex(from) + val plaintext = + if (nip04) ctx.signer.nip04Decrypt(ciphertext, peer) else ctx.signer.nip44Decrypt(ciphertext, peer) + Output.emit(mapOf("algorithm" to if (nip04) "nip04" else "nip44", "plaintext" to plaintext)) + return 0 + } finally { + ctx.close() + } + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GiftCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GiftCommands.kt new file mode 100644 index 0000000000..4622010fb9 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GiftCommands.kt @@ -0,0 +1,135 @@ +/* + * 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.commands + +import com.vitorpamplona.amethyst.cli.Args +import com.vitorpamplona.amethyst.cli.Context +import com.vitorpamplona.amethyst.cli.DataDir +import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent + +/** + * `amy gift wrap --to USER [EVENT-JSON]` and `amy gift unwrap [GIFTWRAP-JSON]` + * — the NIP-59 gift-wrap primitive (nak's `gift`). + * + * wrap seals a signed inner event for USER and wraps it in a kind:1059 + * gift wrap (random ephemeral sender). Prints the wrap; pass + * `--relay URL[,URL…]` to also broadcast it. + * unwrap decrypts a kind:1059 gift wrap with the active account's key, + * unseals it, and prints the inner event. + * + * Inner/wrap JSON comes from the positional argument or stdin (`-`). + * + * Thin assembly only: seal/wrap/unwrap all live in quartz + * (`SealedRumorEvent`, `GiftWrapEvent`). + */ +object GiftCommands { + suspend fun dispatch( + dataDir: DataDir, + tail: Array, + ): Int { + if (tail.isEmpty()) return Output.error("bad_args", "gift ") + val rest = tail.drop(1).toTypedArray() + return when (tail[0]) { + "wrap" -> wrap(dataDir, rest) + "unwrap" -> unwrap(dataDir, rest) + else -> Output.error("bad_args", "gift ${tail[0]} (expected wrap|unwrap)") + } + } + + private suspend fun wrap( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val to = args.flag("to") ?: return Output.error("bad_args", "gift wrap requires --to USER") + val json = RawEventSupport.readArgOrStdin(args) + if (json.isEmpty()) return Output.error("bad_args", "no inner event JSON on the argument or stdin") + val inner = + try { + Event.fromJson(json) + } catch (e: Exception) { + return Output.error("bad_args", "could not parse inner event JSON: ${e.message}") + } + + val ctx = Context.open(dataDir) + try { + ctx.prepare() + val peer = ctx.requireUserHex(to) + val seal = SealedRumorEvent.create(event = inner, encryptTo = peer, signer = ctx.signer) + val giftWrap = GiftWrapEvent.create(event = seal, recipientPubKey = peer) + val wrapNode = Output.mapper.readTree(giftWrap.toJson()) + + val targets = RawEventSupport.relayFlag(args) + if (targets.isEmpty()) { + Output.emit(mapOf("event" to wrapNode, "published" to false)) + return 0 + } + val ack = ctx.publish(giftWrap, targets) + Output.emit( + mapOf( + "event" to wrapNode, + "published" to true, + "published_to" to ack.filterValues { it }.keys.map { it.url }, + "rejected_by" to ack.filterValues { !it }.keys.map { it.url }, + ), + ) + return 0 + } finally { + ctx.close() + } + } + + private suspend fun unwrap( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val json = RawEventSupport.readArgOrStdin(args) + if (json.isEmpty()) return Output.error("bad_args", "no gift wrap JSON on the argument or stdin") + val giftWrap = + try { + Event.fromJson(json) as? GiftWrapEvent + ?: return Output.error("bad_args", "not a kind:1059 gift wrap event") + } catch (e: Exception) { + return Output.error("bad_args", "could not parse gift wrap JSON: ${e.message}") + } + + val ctx = Context.open(dataDir) + try { + ctx.prepare() + val unwrapped = + try { + giftWrap.unwrapThrowing(ctx.signer) + } catch (e: Exception) { + return Output.error("decrypt_failed", "could not unwrap (is this gift addressed to the active account?): ${e.message}") + } + // The wrap holds a seal (kind:13); unseal it to recover the rumor. + val inner = if (unwrapped is SealedRumorEvent) unwrapped.unsealThrowing(ctx.signer) else unwrapped + Output.emit(mapOf("event" to Output.mapper.readTree(inner.toJson()))) + return 0 + } finally { + ctx.close() + } + } +}