feat(cli): NIP-17 file DMs (kind:15) on receive + send, strict 10050

Receive:
- decryptDms now accepts both ChatMessageEvent (kind:14) and
  ChatMessageEncryptedFileHeaderEvent (kind:15). The result is a sealed
  DecryptedDm hierarchy (TextDm / FileDm) with a `type: "text"|"file"`
  discriminator in JSON. File messages emit url, mime_type,
  encryption_algorithm, decryption_key, decryption_nonce, hash,
  original_hash, size, dim, blurhash. `dm await --match X` searches the
  text body for kind:14 and the URL for kind:15.

Send:
- New `dm send-file RECIPIENT URL --key HEX --nonce HEX [...]` verb.
  Builds a kind:15 ChatMessageEncryptedFileHeaderEvent via the existing
  ChatMessageEncryptedFileHeaderEvent.build + AESGCM(key, nonce), then
  publishes through NIP17Factory.createEncryptedFileNIP17. The file is
  expected to already be uploaded; carrying the upload itself in `amy`
  is the next change.

Spec compliance (NIP-17 §6, "if no kind:10050, shouldn't try"):
- resolveDmRelays now returns kind:10050 only by default. If the
  recipient has none, send/send-file exit with `no_dm_relays` and a
  message recommending `--allow-fallback`. The fallback chain (kind:10002
  read marker → bootstrap) is preserved behind that opt-in flag for
  interop tests and brand-new accounts.

Each recipient row in the send response now includes `relay_source`
("kind_10050" / "nip65_read" / "bootstrap") so callers can tell where
the wraps actually went.

Note: Quartz already enforces the NIP-17 §7 step-3 impersonation guard
(seal pubkey is forced over the rumor's claimed pubkey inside
`Rumor.mergeWith`), so no extra check is needed in the CLI.
This commit is contained in:
Claude
2026-04-23 19:34:52 +00:00
parent 1e5b5d6276
commit 305ce20b52
2 changed files with 275 additions and 75 deletions
+4 -3
View File
@@ -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
@@ -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<String>,
): Int {
if (tail.isEmpty()) return Json.error("bad_args", "dm <send|list|await> …")
if (tail.isEmpty()) return Json.error("bad_args", "dm <send|send-file|list|await> …")
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<String>,
): Int {
if (rest.size < 2) return Json.error("bad_args", "dm send <recipient> <text>")
if (rest.size < 2) return Json.error("bad_args", "dm send <recipient> <text> [--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<Map<String, Any?>>()
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<String>,
): Int {
if (rest.size < 2) {
return Json.error(
"bad_args",
"dm send-file <recipient> <url> --key <hex> --nonce <hex> [--mime-type <m>] [--hash <hex>] " +
"[--original-hash <hex>] [--size <n>] [--dim <WxH>] [--blurhash <s>] [--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<Map<String, Any?>>()
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<String>,
@@ -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<NormalizedRelayUrl> {
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<HexKey>,
private data class RelaySet(
val relays: Set<NormalizedRelayUrl>,
val source: String,
)
private sealed interface DecryptedDm {
val id: HexKey
val wrapId: HexKey
val from: HexKey
val to: List<HexKey>
val createdAt: Long
val relay: String
val searchText: String
fun toJson(): Map<String, Any?>
}
private data class TextDm(
override val id: HexKey,
override val wrapId: HexKey,
override val from: HexKey,
override val to: List<HexKey>,
val content: String,
val createdAt: Long,
val relay: String,
) {
fun toJson(): Map<String, Any?> =
override val createdAt: Long,
override val relay: String,
) : DecryptedDm {
override val searchText: String get() = content
override fun toJson(): Map<String, Any?> =
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<HexKey>,
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<String, Any?> {
val out =
mutableMapOf<String, Any?>(
"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<Pair<NormalizedRelayUrl, com.vitorpamplona.quartz.nip01Core.core.Event>>,
raw: List<Pair<NormalizedRelayUrl, Event>>,
peerHex: HexKey?,
): List<DecryptedDm> {
val seen = HashSet<HexKey>()
val out = mutableListOf<DecryptedDm>()
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
}
}