diff --git a/cli/README.md b/cli/README.md index 14450c09dc..b585b492a5 100644 --- a/cli/README.md +++ b/cli/README.md @@ -137,9 +137,10 @@ Run `amy --help` for the canonical list. As of today: | `marmot await message GID --match TEXT` | Block until a message containing `TEXT` lands. | | `marmot await rename GID --name NAME` | Block until GID's name matches. | | `marmot await epoch GID --min N` | Block until GID's MLS epoch is ≥ N. | -| `dm send RECIPIENT TEXT` | Send a NIP-17 gift-wrapped DM (kind:14 inside kind:1059). Publishes to the recipient's kind:10050 → kind:10002 read → our bootstrap pool. | -| `dm list [--peer NPUB] [--since TS] [--limit N] [--timeout SECS]` | Drain and decrypt gift wraps on our inbox relays. With neither `--peer` nor `--since` the gift-wrap cursor in `state.json` is advanced to the newest message seen. | -| `dm await --peer NPUB --match TEXT [--timeout SECS]` | Block until a DM from NPUB containing TEXT arrives. Timeout exits 124. | +| `dm send RECIPIENT TEXT [--allow-fallback]` | Send a NIP-17 gift-wrapped text DM (kind:14 inside kind:1059). Default delivers only to the recipient's kind:10050 (per NIP-17); pass `--allow-fallback` to fall back to kind:10002 read marker → bootstrap pool. | +| `dm send-file RECIPIENT URL --key HEX --nonce HEX [--mime-type M] [--hash H] [--original-hash H] [--size N] [--dim WxH] [--blurhash S] [--allow-fallback]` | Send a NIP-17 encrypted-file message (kind:15 inside kind:1059). The file must already be uploaded; `--key`/`--nonce` carry the AES-GCM material that recipients use to decrypt the bytes at `URL`. | +| `dm list [--peer NPUB] [--since TS] [--limit N] [--timeout SECS]` | Drain and decrypt gift wraps on our inbox relays. Returns kind:14 (text) and kind:15 (file) messages with a `type` discriminator. With neither `--peer` nor `--since` the gift-wrap cursor in `state.json` is advanced to the newest message seen. | +| `dm await --peer NPUB --match TEXT [--timeout SECS]` | Block until a DM from NPUB containing TEXT arrives (matches text content for kind:14, URL for kind:15). Timeout exits 124. | All `await` verbs accept `--timeout SECS` (default 30). Timeout exits 124 so scripts can distinguish "condition never happened" from "the command diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/DmCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/DmCommands.kt index 483d8b7947..bf0d48326c 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/DmCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/DmCommands.kt @@ -28,28 +28,44 @@ import com.vitorpamplona.amethyst.cli.Json import com.vitorpamplona.amethyst.commons.relayClient.nip17Dm.filterGiftWrapsToPubkey import com.vitorpamplona.amethyst.commons.relayClient.nip17Dm.unwrapAndUnsealOrNull import com.vitorpamplona.quartz.marmot.RecipientRelayFetcher +import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip17Dm.NIP17Factory +import com.vitorpamplona.quartz.nip17Dm.base.BaseDMGroupEvent +import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import kotlinx.coroutines.delay /** - * NIP-17 direct-message verbs. All three reuse the Quartz gift-wrap pipeline + * NIP-17 direct-message verbs. All verbs reuse the Quartz gift-wrap pipeline * (NIP-17 chat message → NIP-59 seal → NIP-59 gift wrap via NIP-44) — this - * command file is pure orchestration. + * file is pure orchestration over `quartz/` and `commons/`. + * + * Both kind:14 (text) and kind:15 (encrypted file header) are recognised on + * receive; `dm send-file` publishes kind:15 with the AES-GCM key + nonce + * carried in tags per NIP-17. + * + * NIP-17 says clients "shouldn't try" to send when the recipient hasn't + * published a kind:10050. By default we honour that: an empty kind:10050 + * is a hard error. `--allow-fallback` opts back into the kind:10002 read + * marker → bootstrap chain for the cases (interop tests, brand-new + * accounts) where strict mode is too strict. */ object DmCommands { suspend fun dispatch( dataDir: DataDir, tail: Array, ): Int { - if (tail.isEmpty()) return Json.error("bad_args", "dm …") + if (tail.isEmpty()) return Json.error("bad_args", "dm …") val rest = tail.drop(1).toTypedArray() return when (tail[0]) { "send" -> send(dataDir, rest) + "send-file" -> sendFile(dataDir, rest) "list" -> list(dataDir, rest) "await" -> await(dataDir, rest) else -> Json.error("bad_args", "dm ${tail[0]}") @@ -60,50 +76,124 @@ object DmCommands { dataDir: DataDir, rest: Array, ): Int { - if (rest.size < 2) return Json.error("bad_args", "dm send ") + if (rest.size < 2) return Json.error("bad_args", "dm send [--allow-fallback]") val text = rest[1] + val args = Args(rest.drop(2).toTypedArray()) + val allowFallback = args.bool("allow-fallback") + val ctx = Context.open(dataDir) try { ctx.prepare() val recipient = ctx.requireUserHex(rest[0]) - val template = ChatMessageEvent.build(text, listOf(PTag(recipient))) val result = NIP17Factory().createMessageNIP17(template, ctx.signer) - - val recipientsOut = mutableListOf>() - for (wrap in result.wraps) { - val target = wrap.recipientPubKey() ?: continue - val relays = resolveDmRelays(ctx, target) - if (relays.isEmpty()) { - // A DM needs at least one relay that either the recipient - // or we agree on. If we can't even fall back to bootstrap - // we can't say "sent" honestly — surface it as an error. - return Json.error("no_dm_relays", target) - } - val ack = ctx.publish(wrap, relays) - recipientsOut.add( - mapOf( - "pubkey" to target, - "wrap_id" to wrap.id, - "published_to" to ack.filterValues { it }.keys.map { it.url }, - "relays_tried" to relays.map { it.url }, - ), - ) - } - - Json.writeLine( - mapOf( - "event_id" to result.msg.id, - "kind" to ChatMessageEvent.KIND, - "recipients" to recipientsOut, - ), - ) - return 0 + return publishWraps(ctx, result, allowFallback) } finally { ctx.close() } } + private suspend fun sendFile( + dataDir: DataDir, + rest: Array, + ): Int { + if (rest.size < 2) { + return Json.error( + "bad_args", + "dm send-file --key --nonce [--mime-type ] [--hash ] " + + "[--original-hash ] [--size ] [--dim ] [--blurhash ] [--allow-fallback]", + ) + } + val url = rest[1] + val args = Args(rest.drop(2).toTypedArray()) + val keyHex = args.requireFlag("key") + val nonceHex = args.requireFlag("nonce") + val keyBytes = + runCatching { keyHex.hexToByteArray() }.getOrElse { + return Json.error("bad_args", "--key must be hex (got ${keyHex.length} chars)") + } + val nonceBytes = + runCatching { nonceHex.hexToByteArray() }.getOrElse { + return Json.error("bad_args", "--nonce must be hex (got ${nonceHex.length} chars)") + } + + val mimeType = args.flag("mime-type") + val hash = args.flag("hash") + val originalHash = args.flag("original-hash") + val size = args.flags["size"]?.toIntOrNull() + val blurhash = args.flag("blurhash") + val dimension = + args.flag("dim")?.let { raw -> + val match = + Regex("^(\\d+)x(\\d+)$").matchEntire(raw) + ?: return Json.error("bad_args", "--dim must be WxH (got '$raw')") + com.vitorpamplona.quartz.nip94FileMetadata.tags + .DimensionTag(match.groupValues[1].toInt(), match.groupValues[2].toInt()) + } + val allowFallback = args.bool("allow-fallback") + + val ctx = Context.open(dataDir) + try { + ctx.prepare() + val recipient = ctx.requireUserHex(rest[0]) + val cipher = + com.vitorpamplona.quartz.utils.ciphers + .AESGCM(keyBytes, nonceBytes) + val template = + ChatMessageEncryptedFileHeaderEvent.build( + to = listOf(PTag(recipient)), + url = url, + cipher = cipher, + mimeType = mimeType, + hash = hash, + size = size, + dimension = dimension, + blurhash = blurhash, + originalHash = originalHash, + ) + val result = NIP17Factory().createEncryptedFileNIP17(template, ctx.signer) + return publishWraps(ctx, result, allowFallback) + } finally { + ctx.close() + } + } + + private suspend fun publishWraps( + ctx: Context, + result: NIP17Factory.Result, + allowFallback: Boolean, + ): Int { + val recipientsOut = mutableListOf>() + for (wrap in result.wraps) { + val target = wrap.recipientPubKey() ?: continue + val resolution = resolveDmRelays(ctx, target, allowFallback) + if (resolution.relays.isEmpty()) { + return Json.error( + "no_dm_relays", + "$target has no kind:10050; pass --allow-fallback to use NIP-65 read or bootstrap", + ) + } + val ack = ctx.publish(wrap, resolution.relays) + recipientsOut.add( + mapOf( + "pubkey" to target, + "wrap_id" to wrap.id, + "published_to" to ack.filterValues { it }.keys.map { it.url }, + "relays_tried" to resolution.relays.map { it.url }, + "relay_source" to resolution.source, + ), + ) + } + Json.writeLine( + mapOf( + "event_id" to result.msg.id, + "kind" to result.msg.kind, + "recipients" to recipientsOut, + ), + ) + return 0 + } + private suspend fun list( dataDir: DataDir, rest: Array, @@ -126,10 +216,6 @@ object DmCommands { .ifEmpty { ctx.bootstrapRelays() } if (inbox.isEmpty()) return Json.error("no_inbox_relays", "configure relays or bootstrap defaults first") - // Stateless queries (either --since or --peer provided) don't - // touch the cursor — the caller is asking a specific question. - // A no-flag invocation is the "advance my cursor" path, matching - // the Marmot syncIncoming convention. val advanceCursor = peerInput == null && sinceFlag == null val since = sinceFlag ?: ctx.state.giftWrapSince @@ -141,7 +227,7 @@ object DmCommands { val raw = ctx.drain(filters, timeoutMs = timeoutSecs * 1000) - val messages = decryptChatMessages(ctx, raw, peerHex) + val messages = decryptDms(ctx, raw, peerHex) val out = messages .sortedBy { it.createdAt } @@ -187,10 +273,6 @@ object DmCommands { if (inbox.isEmpty()) return Json.error("no_inbox_relays", "configure relays or bootstrap defaults first") val deadline = System.currentTimeMillis() + timeoutSecs * 1000 - // Remember how far we've already scanned on this invocation so - // subsequent polls only pull newly-arrived wraps. The 2-day - // lookback inside filterGiftWrapsToPubkey takes care of NIP-59's - // randomised created_at. var since = ctx.state.giftWrapSince while (System.currentTimeMillis() < deadline) { @@ -201,8 +283,11 @@ object DmCommands { .mapValues { (_, v) -> v.map { it.filter } } val raw = ctx.drain(filters, timeoutMs = 3_000) - val messages = decryptChatMessages(ctx, raw, peerHex) - val hit = messages.firstOrNull { it.content.contains(match) } + val messages = decryptDms(ctx, raw, peerHex) + // Match against the text body for kind:14 and against the URL + // for kind:15 — both are exposed as `searchText` so callers + // can grep for either with one --match flag. + val hit = messages.firstOrNull { match in it.searchText } if (hit != null) { Json.writeLine(hit.toJson()) return 0 @@ -217,28 +302,60 @@ object DmCommands { } } - /** Where to deliver a gift-wrap addressed to [recipient]. */ + /** + * Per NIP-17: kind:1059 should only be delivered to relays the recipient + * has advertised in their kind:10050. When that list is empty: + * - strict (default): refuse with no_dm_relays — caller must fix or + * explicitly opt into a fallback. + * - allowFallback=true: fall through to the NIP-65 read marker and then + * to our bootstrap pool. + */ private suspend fun resolveDmRelays( ctx: Context, recipient: HexKey, - ): Set { + allowFallback: Boolean, + ): RelaySet { val seed = ctx.bootstrapRelays() val lists = RecipientRelayFetcher.fetchRelayLists(ctx.client, recipient, seed) - // 10050 inbox → 10002 read fallback → bootstrap as final safety net. - return lists.dmInboxOrFallback().toSet().ifEmpty { seed } + val dmInbox = lists.dmInbox.toSet() + if (dmInbox.isNotEmpty()) return RelaySet(dmInbox, "kind_10050") + if (!allowFallback) return RelaySet(emptySet(), "kind_10050") + val nip65Read = lists.nip65Read().toSet() + if (nip65Read.isNotEmpty()) return RelaySet(nip65Read, "nip65_read") + return RelaySet(seed, "bootstrap") } - private data class DecryptedDm( - val id: HexKey, - val wrapId: HexKey, - val from: HexKey, - val to: List, + private data class RelaySet( + val relays: Set, + val source: String, + ) + + private sealed interface DecryptedDm { + val id: HexKey + val wrapId: HexKey + val from: HexKey + val to: List + val createdAt: Long + val relay: String + val searchText: String + + fun toJson(): Map + } + + private data class TextDm( + override val id: HexKey, + override val wrapId: HexKey, + override val from: HexKey, + override val to: List, val content: String, - val createdAt: Long, - val relay: String, - ) { - fun toJson(): Map = + override val createdAt: Long, + override val relay: String, + ) : DecryptedDm { + override val searchText: String get() = content + + override fun toJson(): Map = mapOf( + "type" to "text", "id" to id, "wrap_id" to wrapId, "from" to from, @@ -249,31 +366,113 @@ object DmCommands { ) } - private suspend fun decryptChatMessages( + private data class FileDm( + override val id: HexKey, + override val wrapId: HexKey, + override val from: HexKey, + override val to: List, + val url: String, + val mimeType: String?, + val encryptionAlgo: String?, + val decryptionKey: String?, + val decryptionNonce: String?, + val hash: String?, + val originalHash: String?, + val size: Int?, + val dimensions: String?, + val blurhash: String?, + override val createdAt: Long, + override val relay: String, + ) : DecryptedDm { + override val searchText: String get() = url + + override fun toJson(): Map { + val out = + mutableMapOf( + "type" to "file", + "id" to id, + "wrap_id" to wrapId, + "from" to from, + "to" to to, + "url" to url, + "created_at" to createdAt, + "relay" to relay, + ) + if (mimeType != null) out["mime_type"] = mimeType + if (encryptionAlgo != null) out["encryption_algorithm"] = encryptionAlgo + if (decryptionKey != null) out["decryption_key"] = decryptionKey + if (decryptionNonce != null) out["decryption_nonce"] = decryptionNonce + if (hash != null) out["hash"] = hash + if (originalHash != null) out["original_hash"] = originalHash + if (size != null) out["size"] = size + if (dimensions != null) out["dim"] = dimensions + if (blurhash != null) out["blurhash"] = blurhash + return out + } + } + + private suspend fun decryptDms( ctx: Context, - raw: List>, + raw: List>, peerHex: HexKey?, ): List { val seen = HashSet() val out = mutableListOf() for ((relay, event) in raw) { if (event !is GiftWrapEvent) continue - val inner = event.unwrapAndUnsealOrNull(ctx.signer) as? ChatMessageEvent ?: continue + // Quartz's Rumor.mergeWith forces the inner event's pubkey to the + // seal author's, so the NIP-17 §7 step-3 impersonation check is + // already enforced upstream — no need to redo it here. + val inner = event.unwrapAndUnsealOrNull(ctx.signer) ?: continue + if (inner !is BaseDMGroupEvent) continue if (!seen.add(inner.id)) continue - val members = inner.groupMembers() - if (peerHex != null && peerHex !in members) continue - out.add( - DecryptedDm( + if (peerHex != null && peerHex !in inner.groupMembers()) continue + out.add(toDecrypted(inner, event.id, relay.url) ?: continue) + } + return out + } + + private fun toDecrypted( + inner: BaseDMGroupEvent, + wrapId: HexKey, + relayUrl: String, + ): DecryptedDm? = + when (inner) { + is ChatMessageEvent -> { + TextDm( id = inner.id, - wrapId = event.id, + wrapId = wrapId, from = inner.pubKey, to = inner.recipientsPubKey(), content = inner.content, createdAt = inner.createdAt, - relay = relay.url, - ), - ) + relay = relayUrl, + ) + } + + is ChatMessageEncryptedFileHeaderEvent -> { + FileDm( + id = inner.id, + wrapId = wrapId, + from = inner.pubKey, + to = inner.recipientsPubKey(), + url = inner.url(), + mimeType = inner.mimeType(), + encryptionAlgo = inner.algo(), + decryptionKey = inner.key()?.toHexKey(), + decryptionNonce = inner.nonce()?.toHexKey(), + hash = inner.hash(), + originalHash = inner.originalHash(), + size = inner.size(), + dimensions = inner.dimensions()?.let { "${it.width}x${it.height}" }, + blurhash = inner.blurhash(), + createdAt = inner.createdAt, + relay = relayUrl, + ) + } + + else -> { + null + } } - return out - } }