Reject unsendable Nostr DMs visibly

This commit is contained in:
jack
2026-07-26 14:53:19 +02:00
committed by jack
parent 6fd44086b3
commit 00af91ea17
6 changed files with 277 additions and 28 deletions
+4
View File
@@ -52,6 +52,10 @@ struct NostrProtocol {
static let maximumPrivateEnvelopeCiphertextBytes = 64 * 1024 static let maximumPrivateEnvelopeCiphertextBytes = 64 * 1024
/// Bound the inner authenticated message JSON before allocation/parsing. /// 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 static let maximumPrivateEnvelopePlaintextBytes = 32 * 1024
/// The outer authenticated seal JSON contains a Base64-encoded encrypted /// 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) 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 { guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: messageID, recipientPeerID: peerID, senderPeerID: senderPeerID) else {
SecureLogger.error("NostrTransport: failed to embed PM packet", category: .session) SecureLogger.error("NostrTransport: failed to embed PM packet", category: .session)
handlePrivateEnvelopeFailure(
events: [],
registerPending: false,
policy: .userMessage(messageID: messageID)
)
return return
} }
sendPrivateEnvelope( sendPrivateEnvelope(
@@ -387,22 +392,48 @@ extension NostrTransport {
} }
// MARK: Geohash DMs (per-geohash identity) // MARK: Geohash DMs (per-geohash identity)
func sendPrivateMessageGeohash(content: String, toRecipientHex recipientHex: String, from identity: NostrIdentity, messageID: String) { /// Returns true only when the complete migration pair entered the relay
Task { @MainActor in /// delivery queue. GeoDM callers use this synchronous admission result
guard !recipientHex.isEmpty else { return } /// before showing "sent"; deterministic packet/envelope failures must not
SecureLogger.debug("GeoDM: send PM mid=\(messageID.prefix(8))", category: .session) /// be hidden behind an unobserved MainActor task.
guard let embedded = NostrEmbeddedBitChat.encodePMForNostrNoRecipient(content: content, messageID: messageID, senderPeerID: senderPeerID) else { @MainActor
SecureLogger.error("NostrTransport: failed to embed geohash PM packet", category: .session) @discardableResult
return func sendPrivateMessageGeohash(
} content: String,
sendPrivateEnvelope( toRecipientHex recipientHex: String,
content: embedded, from identity: NostrIdentity,
recipientHex: recipientHex, messageID: String
senderIdentity: identity, ) -> Bool {
registerPending: true, let failurePolicy = PrivateEnvelopeFailurePolicy.userMessage(messageID: messageID)
failurePolicy: .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. /// Creates and sends a BitChat private-envelope event over Nostr.
@MainActor @MainActor
@discardableResult
private func sendPrivateEnvelope( private func sendPrivateEnvelope(
content: String, content: String,
recipientHex: String, recipientHex: String,
senderIdentity: NostrIdentity, senderIdentity: NostrIdentity,
registerPending: Bool = false, registerPending: Bool = false,
failurePolicy: PrivateEnvelopeFailurePolicy failurePolicy: PrivateEnvelopeFailurePolicy
) { ) -> Bool {
guard let events = try? NostrProtocol.createPrivateEnvelopePublicationBatch( let events: [NostrEvent]
content: content, do {
recipientPubkey: recipientHex, events = try NostrProtocol.createPrivateEnvelopePublicationBatch(
senderIdentity: senderIdentity content: content,
) else { recipientPubkey: recipientHex,
SecureLogger.error("NostrTransport: failed to build Nostr private-envelope batch", category: .session) senderIdentity: senderIdentity
return )
} 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 let accepted = dependencies.sendPrivateEnvelopeBatch(events) { [self] in
handlePrivateEnvelopeFailure( handlePrivateEnvelopeFailure(
@@ -456,9 +502,10 @@ extension NostrTransport {
registerPending: registerPending, registerPending: registerPending,
policy: failurePolicy policy: failurePolicy
) )
return return false
} }
registerPendingPrivateEnvelopesIfNeeded(events, registerPending: registerPending) registerPendingPrivateEnvelopesIfNeeded(events, registerPending: registerPending)
return true
} }
@MainActor @MainActor
@@ -477,6 +524,10 @@ extension NostrTransport {
)) ))
)) ))
case .retry(let retryKey): 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( envelopeRetryQueue.enqueue(
key: retryKey, key: retryKey,
events: events, events: events,
@@ -86,7 +86,13 @@ protocol ChatPrivateConversationContext: AnyObject {
@discardableResult @discardableResult
func routeReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) -> Bool func routeReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) -> Bool
func sendMeshReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) 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 sendGeohashDeliveryAck(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity)
func sendGeohashReadReceipt(_ 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) 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( makeGeohashNostrTransport().sendPrivateMessageGeohash(
content: content, content: content,
toRecipientHex: recipientHex, toRecipientHex: recipientHex,
@@ -399,13 +411,21 @@ final class ChatPrivateConversationCoordinator {
"GeoDM: local send mid=\(messageID.prefix(8))… to=\(recipientHex.prefix(8))… conv=\(peerID)", "GeoDM: local send mid=\(messageID.prefix(8))… to=\(recipientHex.prefix(8))… conv=\(peerID)",
category: .session category: .session
) )
context.sendGeohashPrivateMessage( let accepted = context.sendGeohashPrivateMessage(
content, content,
toRecipientHex: recipientHex, toRecipientHex: recipientHex,
from: identity, from: identity,
messageID: messageID 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 { } catch {
context.setPrivateDeliveryStatus( context.setPrivateDeliveryStatus(
.failed( .failed(
@@ -176,6 +176,7 @@ private final class MockChatPrivateConversationContext: ChatPrivateConversationC
private(set) var geoPrivateMessages: [(content: String, recipientHex: String, messageID: String)] = [] private(set) var geoPrivateMessages: [(content: String, recipientHex: String, messageID: String)] = []
private(set) var geoDeliveryAcks: [(messageID: String, recipientHex: String)] = [] private(set) var geoDeliveryAcks: [(messageID: String, recipientHex: String)] = []
private(set) var geoReadReceipts: [(messageID: String, recipientHex: String)] = [] private(set) var geoReadReceipts: [(messageID: String, recipientHex: String)] = []
var geohashPrivateMessageAccepted = true
func routePrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) { func routePrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {
routedPrivateMessages.append((content, peerID, messageID)) routedPrivateMessages.append((content, peerID, messageID))
@@ -191,8 +192,14 @@ private final class MockChatPrivateConversationContext: ChatPrivateConversationC
meshReadReceipts.append((receipt.originalMessageID, peerID)) meshReadReceipts.append((receipt.originalMessageID, peerID))
} }
func sendGeohashPrivateMessage(_ content: String, toRecipientHex recipientHex: String, from identity: NostrIdentity, messageID: String) { func sendGeohashPrivateMessage(
_ content: String,
toRecipientHex recipientHex: String,
from identity: NostrIdentity,
messageID: String
) -> Bool {
geoPrivateMessages.append((content, recipientHex, messageID)) geoPrivateMessages.append((content, recipientHex, messageID))
return geohashPrivateMessageAccepted
} }
func sendGeohashDeliveryAck(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) { func sendGeohashDeliveryAck(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
@@ -279,6 +286,11 @@ private func isRead(_ status: DeliveryStatus?, by expected: String) -> Bool {
return false return false
} }
private func isFailed(_ status: DeliveryStatus?) -> Bool {
if case .failed = status { return true }
return false
}
private func makeFavoriteRelationship( private func makeFavoriteRelationship(
noiseKey: Data, noiseKey: Data,
nostrPublicKey: String? = nil, nostrPublicKey: String? = nil,
@@ -412,6 +424,25 @@ struct ChatPrivateConversationCoordinatorContextTests {
#expect(context.privateChats[convKey]?.count == 1) #expect(context.privateChats[convKey]?.count == 1)
} }
@Test @MainActor
func sendGeohashDM_rejectedAdmissionNeverBecomesSent() async {
let context = MockChatPrivateConversationContext()
let coordinator = ChatPrivateConversationCoordinator(context: context)
let recipientHex = String(repeating: "33", count: 32)
let peerID = PeerID(nostr_: recipientHex)
context.activeChannel = .location(
GeohashChannel(level: .city, geohash: "u4pruy")
)
context.nostrKeyMapping[peerID] = recipientHex
context.geohashPrivateMessageAccepted = false
coordinator.sendGeohashDM("rejected", to: peerID)
#expect(context.geoPrivateMessages.map(\.content) == ["rejected"])
#expect(context.privateChats[peerID]?.count == 1)
#expect(isFailed(context.privateChats[peerID]?.first?.deliveryStatus))
}
@Test @MainActor @Test @MainActor
func handleViewingThisChat_clearsUnreadAndSendsRoutedReadReceiptOnce() async { func handleViewingThisChat_clearsUnreadAndSendsRoutedReadReceiptOnce() async {
let context = MockChatPrivateConversationContext() let context = MockChatPrivateConversationContext()
+21
View File
@@ -429,6 +429,27 @@ struct NostrProtocolTests {
} }
} }
@Test func privateEnvelopePublicationBatchRejectsMaximumComposerPayload() throws {
let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate()
let composerMaximum = String(
repeating: "x",
count: InputValidator.Limits.maxMessageLength
)
#expect(
composerMaximum.utf8.count
> NostrProtocol.maximumPrivateEnvelopePlaintextBytes
)
expectInvalidCiphertext {
_ = try NostrProtocol.createPrivateEnvelopePublicationBatch(
content: composerMaximum,
recipientPubkey: recipient.publicKeyHex,
senderIdentity: sender
)
}
}
@Test func privateEnvelopeRejectsOversizedNestedJSONBeforeParsing() { @Test func privateEnvelopeRejectsOversizedNestedJSONBeforeParsing() {
let oversizedJSON = String( let oversizedJSON = String(
repeating: "{", repeating: "{",
@@ -302,6 +302,128 @@ struct NostrTransportTests {
#expect(reported) #expect(reported)
} }
@Test("Envelope construction failure rejects visibly without publishing")
@MainActor
func envelopeConstructionFailureRejectsVisibly() async throws {
let keychain = MockKeychain()
let idBridge = NostrIdentityBridge(keychain: keychain)
let sender = try NostrIdentity.generate()
let eventProbe = NostrTransportEventProbe()
let publicationProbe = NostrTransportProbe()
let transport = NostrTransport(
keychain: keychain,
idBridge: idBridge,
dependencies: makeDependencies(
currentIdentity: { sender },
sendPrivateEnvelopeBatch: { events, _ in
publicationProbe.record(batch: events)
}
)
)
transport.senderPeerID = PeerID(str: "0123456789abcdef")
transport.eventDelegate = eventProbe
// Non-empty but invalid hex reaches envelope construction after the
// embedded BitChat packet succeeds, reproducing the throwing batch
// builder path without allocating an oversized test fixture.
let accepted = transport.sendPrivateMessageGeohash(
content: "must fail before sent",
toRecipientHex: "not-a-hex-public-key",
from: sender,
messageID: "build-reject"
)
#expect(!accepted)
#expect(publicationProbe.sentEvents.isEmpty)
#expect(eventProbe.failedMessageIDs == ["build-reject"])
}
@Test("Unencodable user message rejects visibly without publishing")
@MainActor
func unencodableUserMessageRejectsVisibly() async throws {
let keychain = MockKeychain()
let idBridge = NostrIdentityBridge(keychain: keychain)
let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate()
let eventProbe = NostrTransportEventProbe()
let publicationProbe = NostrTransportProbe()
let transport = NostrTransport(
keychain: keychain,
idBridge: idBridge,
dependencies: makeDependencies(
currentIdentity: { sender },
sendPrivateEnvelopeBatch: { events, _ in
publicationProbe.record(batch: events)
}
)
)
transport.senderPeerID = PeerID(str: "0123456789abcdef")
transport.eventDelegate = eventProbe
// PrivateMessagePacket uses the deployed UInt8 content length. Do not
// create a larger wire shape that released clients cannot decode.
let accepted = transport.sendPrivateMessageGeohash(
content: String(repeating: "x", count: 256),
toRecipientHex: recipient.publicKeyHex,
from: sender,
messageID: "packet-reject"
)
#expect(!accepted)
#expect(publicationProbe.sentEvents.isEmpty)
#expect(eventProbe.failedMessageIDs == ["packet-reject"])
}
@Test("Unencodable direct message emits a visible failure")
@MainActor
func unencodableDirectMessageEmitsVisibleFailure() async throws {
let keychain = MockKeychain()
let idBridge = NostrIdentityBridge(keychain: keychain)
let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate()
let noiseKey = Data((224..<256).map(UInt8.init))
let peerID = PeerID(hexData: noiseKey)
let relationship = makeRelationship(
peerNoisePublicKey: noiseKey,
peerNostrPublicKey: recipient.npub,
peerNickname: "Oversized peer"
)
let eventProbe = NostrTransportEventProbe()
let publicationProbe = NostrTransportProbe()
let transport = NostrTransport(
keychain: keychain,
idBridge: idBridge,
dependencies: makeDependencies(
favoriteStatusForNoiseKey: {
$0 == noiseKey ? relationship : nil
},
currentIdentity: { sender },
sendPrivateEnvelopeBatch: { events, _ in
publicationProbe.record(batch: events)
}
)
)
transport.senderPeerID = PeerID(str: "0123456789abcdef")
transport.eventDelegate = eventProbe
transport.sendPrivateMessage(
String(repeating: "x", count: 256),
to: peerID,
recipientNickname: "Oversized peer",
messageID: "direct-packet-reject"
)
let failed = await TestHelpers.waitUntil(
{
eventProbe.failedMessageIDs
== ["direct-packet-reject"]
},
timeout: 5.0
)
#expect(failed)
#expect(publicationProbe.sentEvents.isEmpty)
}
@Test("Rejected favorite notification retains and retries the exact pair") @Test("Rejected favorite notification retains and retries the exact pair")
@MainActor @MainActor
func rejectedFavoriteNotificationRetriesExactPair() async throws { func rejectedFavoriteNotificationRetriesExactPair() async throws {