Reject unsendable Nostr DMs visibly

This commit is contained in:
jack
2026-07-25 23:49:42 +02:00
parent cb205359bf
commit 0bb3196972
6 changed files with 277 additions and 28 deletions
+4
View File
@@ -51,6 +51,10 @@ struct NostrProtocol {
static let maximumPrivateEnvelopeCiphertextBytes = 64 * 1024
/// Bound the inner authenticated message JSON before allocation/parsing.
/// This is intentionally an envelope limit, not the generic composer
/// limit. Raising it alone would not make larger private-message packets
/// compatible with released readers; callers must surface a rejected
/// packet or envelope as a failed send.
static let maximumPrivateEnvelopePlaintextBytes = 32 * 1024
/// The outer authenticated seal JSON contains a Base64-encoded encrypted
+74 -23
View File
@@ -323,6 +323,11 @@ final class NostrTransport: Transport, @unchecked Sendable {
SecureLogger.debug("NostrTransport: preparing PM to \(recipientNpub.prefix(16))… id=\(messageID.prefix(8))", category: .session)
guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: messageID, recipientPeerID: peerID, senderPeerID: senderPeerID) else {
SecureLogger.error("NostrTransport: failed to embed PM packet", category: .session)
handlePrivateEnvelopeFailure(
events: [],
registerPending: false,
policy: .userMessage(messageID: messageID)
)
return
}
sendPrivateEnvelope(
@@ -387,22 +392,48 @@ extension NostrTransport {
}
// MARK: Geohash DMs (per-geohash identity)
func sendPrivateMessageGeohash(content: String, toRecipientHex recipientHex: String, from identity: NostrIdentity, messageID: String) {
Task { @MainActor in
guard !recipientHex.isEmpty else { return }
SecureLogger.debug("GeoDM: send PM mid=\(messageID.prefix(8))", category: .session)
guard let embedded = NostrEmbeddedBitChat.encodePMForNostrNoRecipient(content: content, messageID: messageID, senderPeerID: senderPeerID) else {
SecureLogger.error("NostrTransport: failed to embed geohash PM packet", category: .session)
return
}
sendPrivateEnvelope(
content: embedded,
recipientHex: recipientHex,
senderIdentity: identity,
registerPending: true,
failurePolicy: .userMessage(messageID: messageID)
/// Returns true only when the complete migration pair entered the relay
/// delivery queue. GeoDM callers use this synchronous admission result
/// before showing "sent"; deterministic packet/envelope failures must not
/// be hidden behind an unobserved MainActor task.
@MainActor
@discardableResult
func sendPrivateMessageGeohash(
content: String,
toRecipientHex recipientHex: String,
from identity: NostrIdentity,
messageID: String
) -> Bool {
let failurePolicy = PrivateEnvelopeFailurePolicy.userMessage(messageID: messageID)
guard !recipientHex.isEmpty else {
handlePrivateEnvelopeFailure(
events: [],
registerPending: false,
policy: failurePolicy
)
return false
}
SecureLogger.debug("GeoDM: send PM mid=\(messageID.prefix(8))", category: .session)
guard let embedded = NostrEmbeddedBitChat.encodePMForNostrNoRecipient(
content: content,
messageID: messageID,
senderPeerID: senderPeerID
) else {
SecureLogger.error("NostrTransport: failed to embed geohash PM packet", category: .session)
handlePrivateEnvelopeFailure(
events: [],
registerPending: false,
policy: failurePolicy
)
return false
}
return sendPrivateEnvelope(
content: embedded,
recipientHex: recipientHex,
senderIdentity: identity,
registerPending: true,
failurePolicy: failurePolicy
)
}
}
@@ -424,20 +455,35 @@ extension NostrTransport {
/// Creates and sends a BitChat private-envelope event over Nostr.
@MainActor
@discardableResult
private func sendPrivateEnvelope(
content: String,
recipientHex: String,
senderIdentity: NostrIdentity,
registerPending: Bool = false,
failurePolicy: PrivateEnvelopeFailurePolicy
) {
guard let events = try? NostrProtocol.createPrivateEnvelopePublicationBatch(
content: content,
recipientPubkey: recipientHex,
senderIdentity: senderIdentity
) else {
SecureLogger.error("NostrTransport: failed to build Nostr private-envelope batch", category: .session)
return
) -> Bool {
let events: [NostrEvent]
do {
events = try NostrProtocol.createPrivateEnvelopePublicationBatch(
content: content,
recipientPubkey: recipientHex,
senderIdentity: senderIdentity
)
} catch {
SecureLogger.error(
"NostrTransport: failed to build Nostr private-envelope batch: \(error)",
category: .session
)
// Construction failures are deterministic. User-authored messages
// must become visibly failed; control payloads have no valid event
// pair to retain and retry.
handlePrivateEnvelopeFailure(
events: [],
registerPending: false,
policy: failurePolicy
)
return false
}
let accepted = dependencies.sendPrivateEnvelopeBatch(events) { [self] in
handlePrivateEnvelopeFailure(
@@ -456,9 +502,10 @@ extension NostrTransport {
registerPending: registerPending,
policy: failurePolicy
)
return
return false
}
registerPendingPrivateEnvelopesIfNeeded(events, registerPending: registerPending)
return true
}
@MainActor
@@ -477,6 +524,10 @@ extension NostrTransport {
))
))
case .retry(let retryKey):
// A deterministic packet/envelope construction failure has no
// events to retry. Only relay admission/delivery failures reach
// this branch with the complete atomic pair.
guard !events.isEmpty else { return }
envelopeRetryQueue.enqueue(
key: retryKey,
events: events,
@@ -86,7 +86,13 @@ protocol ChatPrivateConversationContext: AnyObject {
@discardableResult
func routeReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) -> Bool
func sendMeshReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID)
func sendGeohashPrivateMessage(_ content: String, toRecipientHex recipientHex: String, from identity: NostrIdentity, messageID: String)
@discardableResult
func sendGeohashPrivateMessage(
_ content: String,
toRecipientHex recipientHex: String,
from identity: NostrIdentity,
messageID: String
) -> Bool
func sendGeohashDeliveryAck(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity)
func sendGeohashReadReceipt(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity)
@@ -174,7 +180,13 @@ extension ChatViewModel: ChatPrivateConversationContext {
meshService.sendReadReceipt(receipt, to: peerID)
}
func sendGeohashPrivateMessage(_ content: String, toRecipientHex recipientHex: String, from identity: NostrIdentity, messageID: String) {
@discardableResult
func sendGeohashPrivateMessage(
_ content: String,
toRecipientHex recipientHex: String,
from identity: NostrIdentity,
messageID: String
) -> Bool {
makeGeohashNostrTransport().sendPrivateMessageGeohash(
content: content,
toRecipientHex: recipientHex,
@@ -399,13 +411,21 @@ final class ChatPrivateConversationCoordinator {
"GeoDM: local send mid=\(messageID.prefix(8))… to=\(recipientHex.prefix(8))… conv=\(peerID)",
category: .session
)
context.sendGeohashPrivateMessage(
let accepted = context.sendGeohashPrivateMessage(
content,
toRecipientHex: recipientHex,
from: identity,
messageID: messageID
)
context.setPrivateDeliveryStatus(.sent, forMessageID: messageID, peerID: peerID)
let status: DeliveryStatus = accepted
? .sent
: .failed(
reason: String(
localized: "content.delivery.reason.not_delivered",
comment: "Failure reason shown when a private message could not enter the relay delivery queue"
)
)
context.setPrivateDeliveryStatus(status, forMessageID: messageID, peerID: peerID)
} catch {
context.setPrivateDeliveryStatus(
.failed(