mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 14:45:21 +00:00
Relabel private Nostr envelopes honestly
This commit is contained in:
@@ -243,7 +243,7 @@ private func drainMainQueue() async {
|
||||
|
||||
/// Exercises `ChatNostrCoordinator` against `MockChatNostrContext` with no
|
||||
/// `ChatViewModel`. Scoped to the inbound event pipeline (dedup, presence,
|
||||
/// public-message ingest), gift-wrap DM ingest, key mapping, channel-switch
|
||||
/// public-message ingest), private-envelope ingest, key mapping, channel-switch
|
||||
/// teardown, embedded ack flows, and — now that favorites and notifications
|
||||
/// are injected through the context — the favorite-notification ingest and
|
||||
/// the sampled-geohash notification cooldown. Flows that hit live singletons
|
||||
@@ -335,7 +335,7 @@ struct ChatNostrCoordinatorContextTests {
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func handleGiftWrap_routesEmbeddedPrivateMessageAndDeduplicates() async throws {
|
||||
func handlePrivateEnvelope_routesEmbeddedPrivateMessageAndDeduplicates() async throws {
|
||||
let context = MockChatNostrContext()
|
||||
let coordinator = ChatNostrCoordinator(context: context)
|
||||
|
||||
@@ -346,17 +346,22 @@ struct ChatNostrCoordinatorContextTests {
|
||||
messageID: "gm-1",
|
||||
senderPeerID: PeerID(str: "aabbccddeeff0011")
|
||||
))
|
||||
let giftWrap = try NostrProtocol.createPrivateMessage(
|
||||
let envelopes = try NostrProtocol.createPrivateEnvelopePublicationBatch(
|
||||
content: embedded,
|
||||
recipientPubkey: recipient.publicKeyHex,
|
||||
senderIdentity: sender
|
||||
senderIdentity: sender,
|
||||
now: NostrProtocol.legacyPrivateEnvelopePublicationDeadline.addingTimeInterval(-1)
|
||||
)
|
||||
|
||||
coordinator.inbound.handleGiftWrap(giftWrap, id: recipient)
|
||||
for envelope in envelopes {
|
||||
coordinator.inbound.handlePrivateEnvelope(envelope, id: recipient)
|
||||
}
|
||||
|
||||
let convKey = PeerID(nostr_: sender.publicKeyHex)
|
||||
#expect(context.recordedNostrEventIDs == [giftWrap.id])
|
||||
#expect(context.recordedNostrEventIDs == envelopes.map(\.id))
|
||||
#expect(context.nostrKeyMapping[convKey] == sender.publicKeyHex)
|
||||
// The primary and compatibility envelopes carry the same authenticated
|
||||
// embedded payload and must invoke message side effects only once.
|
||||
#expect(context.handledPrivateMessages.count == 1)
|
||||
#expect(context.handledPrivateMessages.first?.senderPubkey == sender.publicKeyHex)
|
||||
#expect(context.handledPrivateMessages.first?.convKey == convKey)
|
||||
@@ -368,34 +373,88 @@ struct ChatNostrCoordinatorContextTests {
|
||||
#expect(pm.messageID == "gm-1")
|
||||
#expect(pm.content == "psst")
|
||||
|
||||
// The same gift wrap is dropped on replay.
|
||||
coordinator.inbound.handleGiftWrap(giftWrap, id: recipient)
|
||||
#expect(context.recordedNostrEventIDs == [giftWrap.id])
|
||||
// The same private envelope is dropped on replay.
|
||||
coordinator.inbound.handlePrivateEnvelope(envelopes[0], id: recipient)
|
||||
#expect(context.recordedNostrEventIDs == envelopes.map(\.id))
|
||||
#expect(context.handledPrivateMessages.count == 1)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func processNostrMessage_invalidSignatureDoesNotPoisonDedup() async throws {
|
||||
func migrationEnvelopePairs_processEachMessageAndAckOnlyOnce() throws {
|
||||
let context = MockChatNostrContext()
|
||||
let coordinator = ChatNostrCoordinator(context: context)
|
||||
let recipient = try NostrIdentity.generate()
|
||||
let sender = try NostrIdentity.generate()
|
||||
let beforeDeadline = NostrProtocol.legacyPrivateEnvelopePublicationDeadline
|
||||
.addingTimeInterval(-1)
|
||||
|
||||
let messageContent = try #require(NostrEmbeddedBitChat.encodePMForNostrNoRecipient(
|
||||
content: "migration message",
|
||||
messageID: "migration-message-id",
|
||||
senderPeerID: PeerID(str: "aabbccddeeff0011")
|
||||
))
|
||||
let deliveredContent = try #require(NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(
|
||||
type: .delivered,
|
||||
messageID: "migration-ack-id",
|
||||
senderPeerID: PeerID(str: "aabbccddeeff0011")
|
||||
))
|
||||
let readContent = try #require(NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(
|
||||
type: .readReceipt,
|
||||
messageID: "migration-ack-id",
|
||||
senderPeerID: PeerID(str: "aabbccddeeff0011")
|
||||
))
|
||||
|
||||
for content in [messageContent, deliveredContent, readContent] {
|
||||
let envelopes = try NostrProtocol.createPrivateEnvelopePublicationBatch(
|
||||
content: content,
|
||||
recipientPubkey: recipient.publicKeyHex,
|
||||
senderIdentity: sender,
|
||||
now: beforeDeadline
|
||||
)
|
||||
#expect(envelopes.count == 2)
|
||||
for envelope in envelopes {
|
||||
coordinator.inbound.handlePrivateEnvelope(envelope, id: recipient)
|
||||
}
|
||||
}
|
||||
|
||||
#expect(context.handledPrivateMessages.count == 1)
|
||||
#expect(context.handledDelivered.count == 1)
|
||||
#expect(context.handledReadReceipts.count == 1)
|
||||
|
||||
// A later same-format re-envelope is a delivery retry, not the
|
||||
// migration twin, so it must still reach downstream message-ID dedup
|
||||
// and acknowledgement resend logic.
|
||||
let primaryRetry = try NostrProtocol.createPrivateEnvelope(
|
||||
content: messageContent,
|
||||
recipientPubkey: recipient.publicKeyHex,
|
||||
senderIdentity: sender
|
||||
)
|
||||
coordinator.inbound.handlePrivateEnvelope(primaryRetry, id: recipient)
|
||||
#expect(context.handledPrivateMessages.count == 2)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func processAccountPrivateEnvelope_invalidSignatureDoesNotPoisonDedup() async throws {
|
||||
let context = MockChatNostrContext()
|
||||
let coordinator = ChatNostrCoordinator(context: context)
|
||||
|
||||
let recipient = try NostrIdentity.generate()
|
||||
let sender = try NostrIdentity.generate()
|
||||
let giftWrap = try NostrProtocol.createPrivateMessage(
|
||||
let envelope = try NostrProtocol.createPrivateEnvelope(
|
||||
content: "verify:noop",
|
||||
recipientPubkey: recipient.publicKeyHex,
|
||||
senderIdentity: sender
|
||||
)
|
||||
var invalidGiftWrap = giftWrap
|
||||
invalidGiftWrap.sig = String(repeating: "0", count: 128)
|
||||
var invalidEnvelope = envelope
|
||||
invalidEnvelope.sig = String(repeating: "0", count: 128)
|
||||
|
||||
// A forged-signature copy is rejected WITHOUT entering the dedup set...
|
||||
await coordinator.inbound.processNostrMessage(invalidGiftWrap)
|
||||
await coordinator.inbound.processAccountPrivateEnvelope(invalidEnvelope)
|
||||
#expect(context.recordedNostrEventIDs.isEmpty)
|
||||
|
||||
// ...so the genuine event with the same ID still processes and records.
|
||||
await coordinator.inbound.processNostrMessage(giftWrap)
|
||||
#expect(context.recordedNostrEventIDs == [giftWrap.id])
|
||||
await coordinator.inbound.processAccountPrivateEnvelope(envelope)
|
||||
#expect(context.recordedNostrEventIDs == [envelope.id])
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
|
||||
@@ -393,20 +393,25 @@ struct ChatViewModelNostrExtensionTests {
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func subscribeGiftWrap_rejectsOversizedEmbeddedPacket() async throws {
|
||||
func privateEnvelope_rejectsOversizedEmbeddedPacketBeforePublication() async throws {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let sender = try NostrIdentity.generate()
|
||||
let recipient = try NostrIdentity.generate()
|
||||
|
||||
let oversized = Data(repeating: 0x41, count: FileTransferLimits.maxFramedFileBytes + 1)
|
||||
let content = "bitchat1:" + base64URLEncode(oversized)
|
||||
let giftWrap = try NostrProtocol.createPrivateMessage(
|
||||
content: content,
|
||||
recipientPubkey: recipient.publicKeyHex,
|
||||
senderIdentity: sender
|
||||
)
|
||||
|
||||
viewModel.subscribeGiftWrap(giftWrap, id: recipient)
|
||||
do {
|
||||
_ = try NostrProtocol.createPrivateEnvelope(
|
||||
content: content,
|
||||
recipientPubkey: recipient.publicKeyHex,
|
||||
senderIdentity: sender
|
||||
)
|
||||
Issue.record("Expected oversized private-envelope plaintext to be rejected")
|
||||
} catch NostrError.invalidCiphertext {
|
||||
// Rejected before encryption/publication, as intended.
|
||||
} catch {
|
||||
Issue.record("Expected NostrError.invalidCiphertext, got \(error)")
|
||||
}
|
||||
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
#expect(viewModel.privateChats.isEmpty)
|
||||
@@ -451,7 +456,7 @@ struct ChatViewModelNostrExtensionTests {
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func subscribeGiftWrap_deliveredAckUpdatesExistingMessage() async throws {
|
||||
func subscribePrivateEnvelope_deliveredAckUpdatesExistingMessage() async throws {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let sender = try NostrIdentity.generate()
|
||||
let recipient = try NostrIdentity.generate()
|
||||
@@ -473,13 +478,13 @@ struct ChatViewModelNostrExtensionTests {
|
||||
], for: convKey)
|
||||
|
||||
let content = try ackContent(type: .delivered, messageID: messageID, senderPeerID: PeerID(str: "0123456789abcdef"))
|
||||
let giftWrap = try NostrProtocol.createPrivateMessage(
|
||||
let envelope = try NostrProtocol.createPrivateEnvelope(
|
||||
content: content,
|
||||
recipientPubkey: recipient.publicKeyHex,
|
||||
senderIdentity: sender
|
||||
)
|
||||
|
||||
viewModel.subscribeGiftWrap(giftWrap, id: recipient)
|
||||
viewModel.subscribePrivateEnvelope(envelope, id: recipient)
|
||||
|
||||
let didUpdate = await TestHelpers.waitUntil(
|
||||
{ isDelivered(status: deliveryStatus(in: viewModel, peerID: convKey, messageID: messageID)) },
|
||||
@@ -489,7 +494,7 @@ struct ChatViewModelNostrExtensionTests {
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func subscribeGiftWrap_readAckUpdatesExistingMessage() async throws {
|
||||
func subscribePrivateEnvelope_readAckUpdatesExistingMessage() async throws {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let sender = try NostrIdentity.generate()
|
||||
let recipient = try NostrIdentity.generate()
|
||||
@@ -511,13 +516,13 @@ struct ChatViewModelNostrExtensionTests {
|
||||
], for: convKey)
|
||||
|
||||
let content = try ackContent(type: .readReceipt, messageID: messageID, senderPeerID: PeerID(str: "0123456789abcdef"))
|
||||
let giftWrap = try NostrProtocol.createPrivateMessage(
|
||||
let envelope = try NostrProtocol.createPrivateEnvelope(
|
||||
content: content,
|
||||
recipientPubkey: recipient.publicKeyHex,
|
||||
senderIdentity: sender
|
||||
)
|
||||
|
||||
viewModel.subscribeGiftWrap(giftWrap, id: recipient)
|
||||
viewModel.subscribePrivateEnvelope(envelope, id: recipient)
|
||||
|
||||
let didUpdate = await TestHelpers.waitUntil(
|
||||
{ isRead(status: deliveryStatus(in: viewModel, peerID: convKey, messageID: messageID)) },
|
||||
@@ -527,28 +532,28 @@ struct ChatViewModelNostrExtensionTests {
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func handleGiftWrap_privateMessageStoresConversationAndMapping() async throws {
|
||||
func handlePrivateEnvelope_privateMessageStoresConversationAndMapping() async throws {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let sender = try NostrIdentity.generate()
|
||||
let recipient = try NostrIdentity.generate()
|
||||
let messageID = "gift-private"
|
||||
let messageID = "envelope-private"
|
||||
let convKey = PeerID(nostr_: sender.publicKeyHex)
|
||||
|
||||
let content = try privateMessageContent(
|
||||
text: "Hello from gift wrap",
|
||||
text: "Hello from private envelope",
|
||||
messageID: messageID,
|
||||
senderPeerID: PeerID(str: "0123456789abcdef")
|
||||
)
|
||||
let giftWrap = try NostrProtocol.createPrivateMessage(
|
||||
let envelope = try NostrProtocol.createPrivateEnvelope(
|
||||
content: content,
|
||||
recipientPubkey: recipient.publicKeyHex,
|
||||
senderIdentity: sender
|
||||
)
|
||||
|
||||
viewModel.handleGiftWrap(giftWrap, id: recipient)
|
||||
viewModel.handlePrivateEnvelope(envelope, id: recipient)
|
||||
|
||||
let didStore = await TestHelpers.waitUntil(
|
||||
{ viewModel.privateChats[convKey]?.first?.content == "Hello from gift wrap" },
|
||||
{ viewModel.privateChats[convKey]?.first?.content == "Hello from private envelope" },
|
||||
timeout: 5.0
|
||||
)
|
||||
#expect(didStore)
|
||||
@@ -557,11 +562,11 @@ struct ChatViewModelNostrExtensionTests {
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func handleGiftWrap_blockedSenderSkipsMessageStorage() async throws {
|
||||
func handlePrivateEnvelope_blockedSenderSkipsMessageStorage() async throws {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let sender = try NostrIdentity.generate()
|
||||
let recipient = try NostrIdentity.generate()
|
||||
let messageID = "gift-blocked"
|
||||
let messageID = "envelope-blocked"
|
||||
let convKey = PeerID(nostr_: sender.publicKeyHex)
|
||||
|
||||
viewModel.identityManager.setNostrBlocked(sender.publicKeyHex, isBlocked: true)
|
||||
@@ -571,13 +576,13 @@ struct ChatViewModelNostrExtensionTests {
|
||||
messageID: messageID,
|
||||
senderPeerID: PeerID(str: "0123456789abcdef")
|
||||
)
|
||||
let giftWrap = try NostrProtocol.createPrivateMessage(
|
||||
let envelope = try NostrProtocol.createPrivateEnvelope(
|
||||
content: content,
|
||||
recipientPubkey: recipient.publicKeyHex,
|
||||
senderIdentity: sender
|
||||
)
|
||||
|
||||
viewModel.handleGiftWrap(giftWrap, id: recipient)
|
||||
viewModel.handlePrivateEnvelope(envelope, id: recipient)
|
||||
|
||||
try? await Task.sleep(nanoseconds: 50_000_000)
|
||||
#expect(viewModel.privateChats[convKey] == nil)
|
||||
@@ -585,12 +590,12 @@ struct ChatViewModelNostrExtensionTests {
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func handleGiftWrap_deliveredAckUpdatesExistingMessage() async throws {
|
||||
func handlePrivateEnvelope_deliveredAckUpdatesExistingMessage() async throws {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let sender = try NostrIdentity.generate()
|
||||
let recipient = try NostrIdentity.generate()
|
||||
let convKey = PeerID(nostr_: sender.publicKeyHex)
|
||||
let messageID = "gift-delivered"
|
||||
let messageID = "envelope-delivered"
|
||||
|
||||
viewModel.seedPrivateChat([
|
||||
BitchatMessage(
|
||||
@@ -607,13 +612,13 @@ struct ChatViewModelNostrExtensionTests {
|
||||
], for: convKey)
|
||||
|
||||
let content = try ackContent(type: .delivered, messageID: messageID, senderPeerID: PeerID(str: "0123456789abcdef"))
|
||||
let giftWrap = try NostrProtocol.createPrivateMessage(
|
||||
let envelope = try NostrProtocol.createPrivateEnvelope(
|
||||
content: content,
|
||||
recipientPubkey: recipient.publicKeyHex,
|
||||
senderIdentity: sender
|
||||
)
|
||||
|
||||
viewModel.handleGiftWrap(giftWrap, id: recipient)
|
||||
viewModel.handlePrivateEnvelope(envelope, id: recipient)
|
||||
|
||||
let didUpdate = await TestHelpers.waitUntil(
|
||||
{ isDelivered(status: deliveryStatus(in: viewModel, peerID: convKey, messageID: messageID)) },
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{"content":"v2:SorfnTaoQ_Rv0XLKA8b3FZojgaFvnWgx66JR6Gj20ztnorQyL-hUYBZwAa5ohFC6ioR9hlJARJm0cgNTvWgSZuNTcfSAvoMdSG6mXK73kzsp99x351zUB_lc1_5ZXm0qqePAEz3Dl5jj5EsfAb8ZzsCd-BZENCsTwqjqC_hCFl7RitlRfIL2Tq_n4TUFEFandDCplNz3W4L7V07V89aGEoUlhzcwPU44BcxfvQjeqVKq0IRf2AetypoOduPrjSqkv674ubWaRcHtw3Asrqgo5XSYbpOlSk1PF_TttrQSPZGViNe5MuiK2P-7d-XqKXuDf_bUgAzW884KXogbct-wtIJZJbVM-utMd-dHrpC1mY81lgpS4_kPuhj0Z6Ro9hU5nCcEk2K4_vNoSM9m9QbcXP34h47qSPsw9ikmz8UoD00-1fXQVB4YJcBVUSVI06IzbZEWulo8SPXvQ4pJjV3nwPgYqRgwnrNWMNfeuKojlF8yA17UNOWvD2U7r1cs84HL8dxztzX9NdN0DGxvjMILvt3D4eWrMbcSrsIkBgyvV-uskEPd4eX3fc9GmX8MPkMgxRErcxbuq6JBUNXikOJXNH_qspOt4UIw5dCIajrHsKycKd8A_3rSgLEQteirOWMGaD2gOJEzpbe4iT72dmPkvRq7k-wDjLbO5dOSuUvpFM-ipkB07ATJz_1uUcRpl8fD_oSlcdAzdPjGKG5Y-tNv3AcUstkqCi52E5x-aO8EDUNBFi_OCbD86nkTUv1jOpAQwejowSiiOnCZ91zUEg5pRQyhBV0Ozib-j0Wf8S8VAz9M8bqQQaKedPCEowDn6csOKbFdBtqSeROf1XWKCkm1mSJGHWW9V44MiMiHebVrRUpbP0PqvxiIeJE0","created_at":1783710443,"id":"46425f8d4e8007af43abb67b3668b59604bd34c86dee1b1c3702559086927cb9","kind":1059,"pubkey":"960e391e314a7fb00bbdd85eccb0a93c17e981b6fed38487cf891f1ed6b66aeb","sig":"975c88c1c4d11f0b623603f8ecc7c181fea7385e8feacdb4a64a6b4f7536b1337ff556aee165ca337c9ec501cfab3e9703026c6df2b12bb8c702378875f00722","tags":[["p","1c108500bf53da288d30530718bac4bf80d661d1fe38854060c5b5c79eb77755"]]}
|
||||
@@ -0,0 +1 @@
|
||||
{"recipient_private_key":"8355a5c110cdfef2e644f4ad5d51c39f253b2c2c80ebb6856379fb16531dc1fa"}
|
||||
@@ -2,18 +2,17 @@
|
||||
// NostrProtocolTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// Tests for NIP-17 gift-wrapped private messages
|
||||
// Tests for BitChat's proprietary private-envelope transport over Nostr.
|
||||
//
|
||||
|
||||
import Testing
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
import BitFoundation
|
||||
@testable import bitchat
|
||||
|
||||
struct NostrProtocolTests {
|
||||
|
||||
@Test func nip17MessageRoundTrip() throws {
|
||||
@Test func privateEnvelopeRoundTrip() throws {
|
||||
// Create sender and recipient identities
|
||||
let sender = try NostrIdentity.generate()
|
||||
let recipient = try NostrIdentity.generate()
|
||||
@@ -22,21 +21,19 @@ struct NostrProtocolTests {
|
||||
print("Recipient pubkey: \(recipient.publicKeyHex)")
|
||||
|
||||
// Create a test message
|
||||
let originalContent = "Hello from NIP-17 test!"
|
||||
let originalContent = "Hello from BitChat private-envelope test!"
|
||||
|
||||
// Create encrypted gift wrap
|
||||
let giftWrap = try NostrProtocol.createPrivateMessage(
|
||||
let envelope = try NostrProtocol.createPrivateEnvelope(
|
||||
content: originalContent,
|
||||
recipientPubkey: recipient.publicKeyHex,
|
||||
senderIdentity: sender
|
||||
)
|
||||
|
||||
print("Gift wrap created with ID: \(giftWrap.id)")
|
||||
print("Gift wrap pubkey: \(giftWrap.pubkey)")
|
||||
print("Private envelope created with ID: \(envelope.id)")
|
||||
print("Private envelope pubkey: \(envelope.pubkey)")
|
||||
|
||||
// Decrypt the gift wrap
|
||||
let (decryptedContent, senderPubkey, timestamp) = try NostrProtocol.decryptPrivateMessage(
|
||||
giftWrap: giftWrap,
|
||||
let (decryptedContent, senderPubkey, timestamp) = try NostrProtocol.decryptPrivateEnvelope(
|
||||
envelope: envelope,
|
||||
recipientIdentity: recipient
|
||||
)
|
||||
|
||||
@@ -52,43 +49,265 @@ struct NostrProtocolTests {
|
||||
print("✅ Successfully decrypted message: '\(decryptedContent)' from \(senderPubkey) at \(messageDate)")
|
||||
}
|
||||
|
||||
@Test func giftWrapUsesUniqueEphemeralKeys() throws {
|
||||
@Test func privateEnvelopesUseUniqueEphemeralKeys() throws {
|
||||
// Create identities
|
||||
let sender = try NostrIdentity.generate()
|
||||
let recipient = try NostrIdentity.generate()
|
||||
|
||||
// Create two messages
|
||||
let message1 = try NostrProtocol.createPrivateMessage(
|
||||
let message1 = try NostrProtocol.createPrivateEnvelope(
|
||||
content: "Message 1",
|
||||
recipientPubkey: recipient.publicKeyHex,
|
||||
senderIdentity: sender
|
||||
)
|
||||
|
||||
let message2 = try NostrProtocol.createPrivateMessage(
|
||||
let message2 = try NostrProtocol.createPrivateEnvelope(
|
||||
content: "Message 2",
|
||||
recipientPubkey: recipient.publicKeyHex,
|
||||
senderIdentity: sender
|
||||
)
|
||||
|
||||
// Gift wrap pubkeys should be different (unique ephemeral keys)
|
||||
// Public envelope keys must be one-time use.
|
||||
#expect(message1.pubkey != message2.pubkey)
|
||||
|
||||
print("Message 1 gift wrap pubkey: \(message1.pubkey)")
|
||||
print("Message 2 gift wrap pubkey: \(message2.pubkey)")
|
||||
print("Message 1 envelope pubkey: \(message1.pubkey)")
|
||||
print("Message 2 envelope pubkey: \(message2.pubkey)")
|
||||
|
||||
// Both should decrypt successfully
|
||||
let (content1, _, _) = try NostrProtocol.decryptPrivateMessage(
|
||||
giftWrap: message1,
|
||||
let (content1, _, _) = try NostrProtocol.decryptPrivateEnvelope(
|
||||
envelope: message1,
|
||||
recipientIdentity: recipient
|
||||
)
|
||||
let (content2, _, _) = try NostrProtocol.decryptPrivateMessage(
|
||||
giftWrap: message2,
|
||||
let (content2, _, _) = try NostrProtocol.decryptPrivateEnvelope(
|
||||
envelope: message2,
|
||||
recipientIdentity: recipient
|
||||
)
|
||||
|
||||
#expect(content1 == "Message 1")
|
||||
#expect(content2 == "Message 2")
|
||||
}
|
||||
|
||||
@Test func privateEnvelopeUsesBitChatWireFormatAtEveryLayer() throws {
|
||||
let sender = try NostrIdentity.generate()
|
||||
let recipient = try NostrIdentity.generate()
|
||||
let envelope = try NostrProtocol.createPrivateEnvelope(
|
||||
content: "bitchat-specific",
|
||||
recipientPubkey: recipient.publicKeyHex,
|
||||
senderIdentity: sender
|
||||
)
|
||||
|
||||
#expect(envelope.kind == NostrProtocol.EventKind.privateEnvelope.rawValue)
|
||||
#expect(envelope.kind != NostrProtocol.EventKind.legacyNIP59GiftWrap.rawValue)
|
||||
#expect(envelope.content.hasPrefix(NostrProtocol.privateEnvelopeContentPrefix))
|
||||
#expect(!envelope.content.hasPrefix("v2:"))
|
||||
#expect(envelope.tags == [["p", recipient.publicKeyHex]])
|
||||
#expect(envelope.created_at <= Int(Date().timeIntervalSince1970))
|
||||
|
||||
let layers = try NostrProtocol.decodePrivateEnvelopeLayersForTesting(
|
||||
envelope: envelope,
|
||||
recipientIdentity: recipient
|
||||
)
|
||||
#expect(layers.seal.kind == NostrProtocol.EventKind.privateSeal.rawValue)
|
||||
#expect(layers.seal.content.hasPrefix(NostrProtocol.privateEnvelopeContentPrefix))
|
||||
#expect(layers.seal.tags.isEmpty)
|
||||
#expect(layers.message.kind == NostrProtocol.EventKind.privateMessage.rawValue)
|
||||
#expect(layers.message.tags.isEmpty)
|
||||
#expect(layers.message.sig == nil)
|
||||
}
|
||||
|
||||
@Test func decryptAcceptsReceiveOnlyLegacyBitChatEnvelope() throws {
|
||||
let sender = try NostrIdentity.generate()
|
||||
let recipient = try NostrIdentity.generate()
|
||||
let envelope = try NostrProtocol.createLegacyPrivateEnvelopeForTesting(
|
||||
content: "legacy in-flight message",
|
||||
recipientPubkey: recipient.publicKeyHex,
|
||||
senderIdentity: sender
|
||||
)
|
||||
|
||||
#expect(envelope.kind == NostrProtocol.EventKind.legacyNIP59GiftWrap.rawValue)
|
||||
#expect(envelope.content.hasPrefix("v2:"))
|
||||
|
||||
let result = try NostrProtocol.decryptPrivateEnvelope(
|
||||
envelope: envelope,
|
||||
recipientIdentity: recipient
|
||||
)
|
||||
#expect(result.content == "legacy in-flight message")
|
||||
#expect(result.senderPubkey == sender.publicKeyHex)
|
||||
|
||||
let layers = try NostrProtocol.decodePrivateEnvelopeLayersForTesting(
|
||||
envelope: envelope,
|
||||
recipientIdentity: recipient
|
||||
)
|
||||
#expect(layers.seal.kind == NostrProtocol.EventKind.legacyNIP59Seal.rawValue)
|
||||
#expect(layers.message.kind == NostrProtocol.EventKind.legacyNIP17DirectMessage.rawValue)
|
||||
}
|
||||
|
||||
@Test func decryptsFrozenLegacyEnvelopeProducedByRelease733098bb() throws {
|
||||
let eventData = try Data(contentsOf: fixtureURL(
|
||||
name: "LegacyPrivateEnvelope733098bb"
|
||||
))
|
||||
let keyData = try Data(contentsOf: fixtureURL(
|
||||
name: "LegacyPrivateEnvelope733098bbRecipientKey"
|
||||
))
|
||||
let envelope = try JSONDecoder().decode(NostrEvent.self, from: eventData)
|
||||
let keyFixture = try JSONDecoder().decode(LegacyRecipientKeyFixture.self, from: keyData)
|
||||
let recipientKey = try #require(Data(hexString: keyFixture.recipientPrivateKey))
|
||||
let recipient = try NostrIdentity(privateKeyData: recipientKey)
|
||||
|
||||
#expect(envelope.isValidSignature())
|
||||
let result = try NostrProtocol.decryptPrivateEnvelope(
|
||||
envelope: envelope,
|
||||
recipientIdentity: recipient
|
||||
)
|
||||
#expect(result.content == "legacy fixture from 733098bb")
|
||||
#expect(result.senderPubkey == "2e3d79df7047204f02b726c574e256f8de1dd80510f7dcb8b0d12df13acb87e6")
|
||||
}
|
||||
|
||||
@Test func publicationBatchDualPublishesOnlyBeforeExplicitDeadline() throws {
|
||||
let sender = try NostrIdentity.generate()
|
||||
let recipient = try NostrIdentity.generate()
|
||||
let beforeDeadline = NostrProtocol.legacyPrivateEnvelopePublicationDeadline
|
||||
.addingTimeInterval(-1)
|
||||
|
||||
let migrationBatch = try NostrProtocol.createPrivateEnvelopePublicationBatch(
|
||||
content: "mixed-version",
|
||||
recipientPubkey: recipient.publicKeyHex,
|
||||
senderIdentity: sender,
|
||||
now: beforeDeadline
|
||||
)
|
||||
#expect(migrationBatch.map(\.kind) == [
|
||||
NostrProtocol.EventKind.privateEnvelope.rawValue,
|
||||
NostrProtocol.EventKind.legacyNIP59GiftWrap.rawValue
|
||||
])
|
||||
for envelope in migrationBatch {
|
||||
let result = try NostrProtocol.decryptPrivateEnvelope(
|
||||
envelope: envelope,
|
||||
recipientIdentity: recipient
|
||||
)
|
||||
#expect(result.content == "mixed-version")
|
||||
}
|
||||
|
||||
let postMigrationBatch = try NostrProtocol.createPrivateEnvelopePublicationBatch(
|
||||
content: "new-only",
|
||||
recipientPubkey: recipient.publicKeyHex,
|
||||
senderIdentity: sender,
|
||||
now: NostrProtocol.legacyPrivateEnvelopePublicationDeadline
|
||||
)
|
||||
#expect(postMigrationBatch.map(\.kind) == [
|
||||
NostrProtocol.EventKind.privateEnvelope.rawValue
|
||||
])
|
||||
}
|
||||
|
||||
@Test func mailboxLookbackCoversFullRetentionWindowAndTimestampFuzz() {
|
||||
let sentAt = Date(timeIntervalSince1970: 1_800_000_000)
|
||||
let earliestPublicTimestamp = sentAt.addingTimeInterval(
|
||||
-TransportConfig.nostrPrivateEnvelopeTimestampFuzzSeconds
|
||||
)
|
||||
let reconnectAtRetentionBoundary = sentAt.addingTimeInterval(24 * 60 * 60)
|
||||
let filterSince = reconnectAtRetentionBoundary.addingTimeInterval(
|
||||
-TransportConfig.nostrDMSubscribeLookbackSeconds
|
||||
)
|
||||
|
||||
#expect(TransportConfig.nostrDMSubscribeLookbackSeconds == (24 * 60 * 60) + (15 * 60))
|
||||
#expect(filterSince <= earliestPublicTimestamp)
|
||||
}
|
||||
|
||||
@Test func largePrivateEnvelopeFitsLayerSpecificExpansionLimits() throws {
|
||||
let sender = try NostrIdentity.generate()
|
||||
let recipient = try NostrIdentity.generate()
|
||||
// Large enough that the nested Base64 seal exceeds the inner 32 KiB
|
||||
// cap, while the inner message JSON itself remains below that cap.
|
||||
let content = String(repeating: "A", count: 30 * 1024)
|
||||
|
||||
let envelope = try NostrProtocol.createPrivateEnvelope(
|
||||
content: content,
|
||||
recipientPubkey: recipient.publicKeyHex,
|
||||
senderIdentity: sender
|
||||
)
|
||||
let decrypted = try NostrProtocol.decryptPrivateEnvelope(
|
||||
envelope: envelope,
|
||||
recipientIdentity: recipient
|
||||
)
|
||||
|
||||
#expect(decrypted.content == content)
|
||||
#expect(envelope.content.utf8.count <= NostrProtocol.maximumPrivateEnvelopeCiphertextBytes)
|
||||
}
|
||||
|
||||
@Test func privateEnvelopeRejectsOversizedCiphertextBeforeDecoding() throws {
|
||||
let recipient = try NostrIdentity.generate()
|
||||
let wrapper = try NostrIdentity.generate()
|
||||
let oversizedContent = NostrProtocol.privateEnvelopeContentPrefix
|
||||
+ String(
|
||||
repeating: "A",
|
||||
count: NostrProtocol.maximumPrivateEnvelopeCiphertextBytes
|
||||
)
|
||||
let event = NostrEvent(
|
||||
pubkey: wrapper.publicKeyHex,
|
||||
createdAt: Date(),
|
||||
kind: .privateEnvelope,
|
||||
tags: [["p", recipient.publicKeyHex]],
|
||||
content: oversizedContent
|
||||
)
|
||||
let signed = try event.sign(with: wrapper.schnorrSigningKey())
|
||||
|
||||
expectInvalidCiphertext {
|
||||
_ = try NostrProtocol.decryptPrivateEnvelope(
|
||||
envelope: signed,
|
||||
recipientIdentity: recipient
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func privateEnvelopeRejectsOversizedPlaintextBeforeEncryption() throws {
|
||||
let sender = try NostrIdentity.generate()
|
||||
let recipient = try NostrIdentity.generate()
|
||||
let oversizedPlaintext = String(
|
||||
repeating: "x",
|
||||
count: NostrProtocol.maximumPrivateEnvelopePlaintextBytes + 1
|
||||
)
|
||||
|
||||
expectInvalidCiphertext {
|
||||
_ = try NostrProtocol.createPrivateEnvelope(
|
||||
content: oversizedPlaintext,
|
||||
recipientPubkey: recipient.publicKeyHex,
|
||||
senderIdentity: sender
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func privateEnvelopeRejectsOversizedNestedJSONBeforeParsing() {
|
||||
let oversizedJSON = String(
|
||||
repeating: "{",
|
||||
count: NostrProtocol.maximumPrivateEnvelopePlaintextBytes + 1
|
||||
)
|
||||
expectInvalidCiphertext {
|
||||
_ = try NostrProtocol.decodePrivateEnvelopeEventJSONForTesting(oversizedJSON)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func decryptDoesNotMisinterpretStandardNIP44PayloadAsLegacyBitChat() throws {
|
||||
let recipient = try NostrIdentity.generate()
|
||||
let wrapper = try NostrIdentity.generate()
|
||||
// A valid NIP-44 v2 payload from the official test vectors. Its wire
|
||||
// format starts with a version byte in standard Base64, not BitChat's
|
||||
// historical `v2:` prefix.
|
||||
let standardPayload = "AgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABee0G5VSK0/9YypIObAtDKfYEAjD35uVkHyB0F4DwrcNaCXlCWZKaArsGrY6M9wnuTMxWfp1RTN9Xga8no+kF5Vsb"
|
||||
let event = NostrEvent(
|
||||
pubkey: wrapper.publicKeyHex,
|
||||
createdAt: Date(),
|
||||
kind: .legacyNIP59GiftWrap,
|
||||
tags: [["p", recipient.publicKeyHex]],
|
||||
content: standardPayload
|
||||
)
|
||||
let signed = try event.sign(with: wrapper.schnorrSigningKey())
|
||||
|
||||
expectInvalidCiphertext {
|
||||
_ = try NostrProtocol.decryptPrivateEnvelope(
|
||||
envelope: signed,
|
||||
recipientIdentity: recipient
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func decryptionFailsWithWrongRecipient() throws {
|
||||
let sender = try NostrIdentity.generate()
|
||||
@@ -96,68 +315,58 @@ struct NostrProtocolTests {
|
||||
let wrongRecipient = try NostrIdentity.generate()
|
||||
|
||||
// Create message for recipient
|
||||
let giftWrap = try NostrProtocol.createPrivateMessage(
|
||||
let envelope = try NostrProtocol.createPrivateEnvelope(
|
||||
content: "Secret message",
|
||||
recipientPubkey: recipient.publicKeyHex,
|
||||
senderIdentity: sender
|
||||
)
|
||||
|
||||
// Try to decrypt with wrong recipient
|
||||
if #available(macOS 14.4, iOS 17.4, *) {
|
||||
#expect(throws: CryptoKitError.authenticationFailure) {
|
||||
try NostrProtocol.decryptPrivateMessage(
|
||||
giftWrap: giftWrap,
|
||||
recipientIdentity: wrongRecipient
|
||||
)
|
||||
}
|
||||
} else {
|
||||
#expect(throws: (any Error).self) {
|
||||
try NostrProtocol.decryptPrivateMessage(
|
||||
giftWrap: giftWrap,
|
||||
recipientIdentity: wrongRecipient
|
||||
)
|
||||
}
|
||||
expectInvalidEvent {
|
||||
_ = try NostrProtocol.decryptPrivateEnvelope(
|
||||
envelope: envelope,
|
||||
recipientIdentity: wrongRecipient
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func decryptRejectsInvalidSealSignature() throws {
|
||||
let sender = try NostrIdentity.generate()
|
||||
let recipient = try NostrIdentity.generate()
|
||||
let giftWrap = try NostrProtocol.createPrivateMessageWithInvalidSealSignatureForTesting(
|
||||
let envelope = try NostrProtocol.createPrivateEnvelopeWithInvalidSealSignatureForTesting(
|
||||
content: "forged signature",
|
||||
recipientPubkey: recipient.publicKeyHex,
|
||||
senderIdentity: sender
|
||||
)
|
||||
|
||||
expectInvalidEvent {
|
||||
_ = try NostrProtocol.decryptPrivateMessage(
|
||||
giftWrap: giftWrap,
|
||||
_ = try NostrProtocol.decryptPrivateEnvelope(
|
||||
envelope: envelope,
|
||||
recipientIdentity: recipient
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func decryptRejectsSealRumorPubkeyMismatch() throws {
|
||||
@Test func decryptRejectsSealMessagePubkeyMismatch() throws {
|
||||
let claimedSender = try NostrIdentity.generate()
|
||||
let sealSigner = try NostrIdentity.generate()
|
||||
let recipient = try NostrIdentity.generate()
|
||||
let giftWrap = try NostrProtocol.createPrivateMessageWithMismatchedSealRumorPubkeyForTesting(
|
||||
let envelope = try NostrProtocol.createPrivateEnvelopeWithMismatchedSealMessagePubkeyForTesting(
|
||||
content: "spoofed sender",
|
||||
recipientPubkey: recipient.publicKeyHex,
|
||||
rumorIdentity: claimedSender,
|
||||
messageIdentity: claimedSender,
|
||||
sealSignerIdentity: sealSigner
|
||||
)
|
||||
|
||||
expectInvalidEvent {
|
||||
_ = try NostrProtocol.decryptPrivateMessage(
|
||||
giftWrap: giftWrap,
|
||||
_ = try NostrProtocol.decryptPrivateEnvelope(
|
||||
envelope: envelope,
|
||||
recipientIdentity: recipient
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func testAckRoundTripNIP44V2_Delivered() throws {
|
||||
func deliveredAckRoundTripsInsidePrivateEnvelope() throws {
|
||||
// Identities
|
||||
let sender = try NostrIdentity.generate()
|
||||
let recipient = try NostrIdentity.generate()
|
||||
@@ -171,19 +380,17 @@ struct NostrProtocolTests {
|
||||
"Failed to embed delivered ack"
|
||||
)
|
||||
|
||||
// Create NIP-17 gift wrap to recipient (uses NIP-44 v2 internally)
|
||||
let giftWrap = try NostrProtocol.createPrivateMessage(
|
||||
let envelope = try NostrProtocol.createPrivateEnvelope(
|
||||
content: embedded,
|
||||
recipientPubkey: recipient.publicKeyHex,
|
||||
senderIdentity: sender
|
||||
)
|
||||
|
||||
// Ensure v2 format was used for ciphertext
|
||||
#expect(giftWrap.content.hasPrefix("v2:"))
|
||||
#expect(envelope.content.hasPrefix(NostrProtocol.privateEnvelopeContentPrefix))
|
||||
|
||||
// Decrypt as recipient
|
||||
let (content, senderPubkey, _) = try NostrProtocol.decryptPrivateMessage(
|
||||
giftWrap: giftWrap,
|
||||
let (content, senderPubkey, _) = try NostrProtocol.decryptPrivateEnvelope(
|
||||
envelope: envelope,
|
||||
recipientIdentity: recipient
|
||||
)
|
||||
|
||||
@@ -208,7 +415,7 @@ struct NostrProtocolTests {
|
||||
}
|
||||
}
|
||||
|
||||
@Test func ackRoundTripNIP44V2_ReadReceipt() throws {
|
||||
@Test func readReceiptRoundTripsInsidePrivateEnvelope() throws {
|
||||
// Identities
|
||||
let sender = try NostrIdentity.generate()
|
||||
let recipient = try NostrIdentity.generate()
|
||||
@@ -220,16 +427,16 @@ struct NostrProtocolTests {
|
||||
"Failed to embed read ack"
|
||||
)
|
||||
|
||||
let giftWrap = try NostrProtocol.createPrivateMessage(
|
||||
let envelope = try NostrProtocol.createPrivateEnvelope(
|
||||
content: embedded,
|
||||
recipientPubkey: recipient.publicKeyHex,
|
||||
senderIdentity: sender
|
||||
)
|
||||
|
||||
#expect(giftWrap.content.hasPrefix("v2:"))
|
||||
#expect(envelope.content.hasPrefix(NostrProtocol.privateEnvelopeContentPrefix))
|
||||
|
||||
let (content, senderPubkey, _) = try NostrProtocol.decryptPrivateMessage(
|
||||
giftWrap: giftWrap,
|
||||
let (content, senderPubkey, _) = try NostrProtocol.decryptPrivateEnvelope(
|
||||
envelope: envelope,
|
||||
recipientIdentity: recipient
|
||||
)
|
||||
#expect(senderPubkey == sender.publicKeyHex)
|
||||
@@ -290,7 +497,42 @@ struct NostrProtocolTests {
|
||||
#expect(object["limit"] as? Int == 42)
|
||||
}
|
||||
|
||||
@Test func privateEnvelopeFilterIncludesPrimaryAndCompatibilityKinds() throws {
|
||||
let since = Date(timeIntervalSince1970: 1_234_567)
|
||||
let filter = NostrFilter.privateEnvelopesFor(pubkey: "recipient", since: since)
|
||||
let data = try JSONEncoder().encode(filter)
|
||||
let object = try #require(try JSONSerialization.jsonObject(with: data) as? [String: Any])
|
||||
|
||||
#expect(object["kinds"] as? [Int] == [
|
||||
NostrProtocol.EventKind.privateEnvelope.rawValue,
|
||||
NostrProtocol.EventKind.legacyNIP59GiftWrap.rawValue
|
||||
])
|
||||
#expect(object["#p"] as? [String] == ["recipient"])
|
||||
#expect(object["since"] as? Int == 1_234_567)
|
||||
#expect(object["limit"] as? Int ==
|
||||
TransportConfig.nostrRelayDefaultFetchLimit
|
||||
* NostrProtocol.acceptedPrivateEnvelopeKinds.count
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
private struct LegacyRecipientKeyFixture: Decodable {
|
||||
let recipientPrivateKey: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case recipientPrivateKey = "recipient_private_key"
|
||||
}
|
||||
}
|
||||
|
||||
private func fixtureURL(name: String) throws -> URL {
|
||||
#if SWIFT_PACKAGE
|
||||
let bundle = Bundle.module
|
||||
#else
|
||||
let bundle = Bundle(for: MockKeychain.self)
|
||||
#endif
|
||||
return try #require(bundle.url(forResource: name, withExtension: "json"))
|
||||
}
|
||||
|
||||
private static func base64URLDecode(_ s: String) -> Data? {
|
||||
var str = s.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
|
||||
let rem = str.count % 4
|
||||
@@ -308,4 +550,15 @@ struct NostrProtocolTests {
|
||||
Issue.record("Expected NostrError.invalidEvent, got \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
private func expectInvalidCiphertext(_ operation: () throws -> Void) {
|
||||
do {
|
||||
try operation()
|
||||
Issue.record("Expected NostrError.invalidCiphertext")
|
||||
} catch NostrError.invalidCiphertext {
|
||||
return
|
||||
} catch {
|
||||
Issue.record("Expected NostrError.invalidCiphertext, got \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -893,11 +893,11 @@ final class NostrRelayManagerTests: XCTestCase {
|
||||
XCTAssertTrue(secondDelivered)
|
||||
}
|
||||
|
||||
func test_okMessages_clearPendingGiftWrapIDs() async throws {
|
||||
func test_okMessages_clearPendingPrivateEnvelopeIDs() async throws {
|
||||
let relayURL = "wss://ok.example"
|
||||
let context = makeContext(permission: .denied)
|
||||
let successID = "gift-wrap-success"
|
||||
let failureID = "gift-wrap-failure"
|
||||
let successID = "private-envelope-success"
|
||||
let failureID = "private-envelope-failure"
|
||||
|
||||
context.manager.ensureConnections(to: [relayURL])
|
||||
let connected = await waitUntil {
|
||||
@@ -906,17 +906,17 @@ final class NostrRelayManagerTests: XCTestCase {
|
||||
}
|
||||
XCTAssertTrue(connected)
|
||||
|
||||
NostrRelayManager.registerPendingGiftWrap(id: successID)
|
||||
NostrRelayManager.registerPendingPrivateEnvelope(id: successID)
|
||||
try context.sessionFactory.latestConnection(for: relayURL)?.emitOK(eventID: successID, success: true, reason: "ok")
|
||||
let successCleared = await waitUntil {
|
||||
!NostrRelayManager.pendingGiftWrapIDs.contains(successID)
|
||||
!NostrRelayManager.pendingPrivateEnvelopeIDs.contains(successID)
|
||||
}
|
||||
XCTAssertTrue(successCleared)
|
||||
|
||||
NostrRelayManager.registerPendingGiftWrap(id: failureID)
|
||||
NostrRelayManager.registerPendingPrivateEnvelope(id: failureID)
|
||||
try context.sessionFactory.latestConnection(for: relayURL)?.emitOK(eventID: failureID, success: false, reason: "rejected")
|
||||
let failureCleared = await waitUntil {
|
||||
!NostrRelayManager.pendingGiftWrapIDs.contains(failureID)
|
||||
!NostrRelayManager.pendingPrivateEnvelopeIDs.contains(failureID)
|
||||
}
|
||||
XCTAssertTrue(failureCleared)
|
||||
}
|
||||
|
||||
@@ -153,7 +153,7 @@ struct NostrTransportTests {
|
||||
favoriteStatusForNoiseKey: { _ in nil },
|
||||
favoriteStatusForPeerID: { $0 == shortPeerID ? relationship : nil },
|
||||
currentIdentity: { sender },
|
||||
registerPendingGiftWrap: probe.recordPendingGiftWrap(id:),
|
||||
registerPendingPrivateEnvelope: probe.recordPendingPrivateEnvelope(id:),
|
||||
sendEvent: probe.record(event:),
|
||||
scheduleAfter: { delay, action in
|
||||
probe.enqueueScheduledAction(delay: delay, action: action)
|
||||
@@ -173,7 +173,87 @@ struct NostrTransportTests {
|
||||
#expect(privateMessage.messageID == "pm-1")
|
||||
#expect(privateMessage.content == "hello over nostr")
|
||||
#expect(result.packet.recipientID == shortPeerID.routingData)
|
||||
#expect(probe.pendingGiftWrapIDs.isEmpty)
|
||||
#expect(probe.pendingPrivateEnvelopeIDs.isEmpty)
|
||||
}
|
||||
|
||||
@Test("Migration window publishes primary and compatibility envelopes, then stops")
|
||||
@MainActor
|
||||
func migrationWindowDualPublishesUntilDeadline() async throws {
|
||||
let keychain = MockKeychain()
|
||||
let idBridge = NostrIdentityBridge(keychain: keychain)
|
||||
let sender = try NostrIdentity.generate()
|
||||
let recipient = try NostrIdentity.generate()
|
||||
let noiseKey = Data((192..<224).map(UInt8.init))
|
||||
let peerID = PeerID(hexData: noiseKey)
|
||||
let relationship = makeRelationship(
|
||||
peerNoisePublicKey: noiseKey,
|
||||
peerNostrPublicKey: recipient.npub,
|
||||
peerNickname: "Migration peer"
|
||||
)
|
||||
|
||||
let migrationProbe = NostrTransportProbe()
|
||||
let migrationTransport = NostrTransport(
|
||||
keychain: keychain,
|
||||
idBridge: idBridge,
|
||||
dependencies: makeDependencies(
|
||||
favoriteStatusForNoiseKey: { $0 == noiseKey ? relationship : nil },
|
||||
currentIdentity: { sender },
|
||||
sendEvent: migrationProbe.record(event:),
|
||||
now: {
|
||||
NostrProtocol.legacyPrivateEnvelopePublicationDeadline
|
||||
.addingTimeInterval(-1)
|
||||
}
|
||||
)
|
||||
)
|
||||
migrationTransport.senderPeerID = PeerID(str: "0123456789abcdef")
|
||||
migrationTransport.sendPrivateMessage(
|
||||
"migration payload",
|
||||
to: peerID,
|
||||
recipientNickname: "Migration peer",
|
||||
messageID: "migration-pm"
|
||||
)
|
||||
|
||||
let sentPair = await TestHelpers.waitUntil(
|
||||
{ migrationProbe.sentEvents.count == 2 },
|
||||
timeout: 5.0
|
||||
)
|
||||
#expect(sentPair)
|
||||
#expect(migrationProbe.sentEvents.map(\.kind) == [
|
||||
NostrProtocol.EventKind.privateEnvelope.rawValue,
|
||||
NostrProtocol.EventKind.legacyNIP59GiftWrap.rawValue
|
||||
])
|
||||
for event in migrationProbe.sentEvents {
|
||||
let result = try decodeEmbeddedPayload(from: event, recipient: recipient)
|
||||
let message = try decodePrivateMessage(from: result.payload)
|
||||
#expect(message.messageID == "migration-pm")
|
||||
#expect(message.content == "migration payload")
|
||||
}
|
||||
|
||||
let postMigrationProbe = NostrTransportProbe()
|
||||
let postMigrationTransport = NostrTransport(
|
||||
keychain: keychain,
|
||||
idBridge: idBridge,
|
||||
dependencies: makeDependencies(
|
||||
favoriteStatusForNoiseKey: { $0 == noiseKey ? relationship : nil },
|
||||
currentIdentity: { sender },
|
||||
sendEvent: postMigrationProbe.record(event:),
|
||||
now: { NostrProtocol.legacyPrivateEnvelopePublicationDeadline }
|
||||
)
|
||||
)
|
||||
postMigrationTransport.senderPeerID = PeerID(str: "0123456789abcdef")
|
||||
postMigrationTransport.sendPrivateMessage(
|
||||
"post migration",
|
||||
to: peerID,
|
||||
recipientNickname: "Migration peer",
|
||||
messageID: "post-migration-pm"
|
||||
)
|
||||
|
||||
let sentPrimaryOnly = await TestHelpers.waitUntil(
|
||||
{ postMigrationProbe.sentEvents.count == 1 },
|
||||
timeout: 5.0
|
||||
)
|
||||
#expect(sentPrimaryOnly)
|
||||
#expect(postMigrationProbe.sentEvents.first?.kind == NostrProtocol.EventKind.privateEnvelope.rawValue)
|
||||
}
|
||||
|
||||
@Test("Favorite notification embeds current npub")
|
||||
@@ -198,7 +278,7 @@ struct NostrTransportTests {
|
||||
favoriteStatusForNoiseKey: { $0 == noiseKey ? relationship : nil },
|
||||
favoriteStatusForPeerID: { _ in nil },
|
||||
currentIdentity: { sender },
|
||||
registerPendingGiftWrap: probe.recordPendingGiftWrap(id:),
|
||||
registerPendingPrivateEnvelope: probe.recordPendingPrivateEnvelope(id:),
|
||||
sendEvent: probe.record(event:),
|
||||
scheduleAfter: { delay, action in
|
||||
probe.enqueueScheduledAction(delay: delay, action: action)
|
||||
@@ -239,7 +319,7 @@ struct NostrTransportTests {
|
||||
favoriteStatusForNoiseKey: { $0 == noiseKey ? relationship : nil },
|
||||
favoriteStatusForPeerID: { _ in nil },
|
||||
currentIdentity: { sender },
|
||||
registerPendingGiftWrap: probe.recordPendingGiftWrap(id:),
|
||||
registerPendingPrivateEnvelope: probe.recordPendingPrivateEnvelope(id:),
|
||||
sendEvent: probe.record(event:),
|
||||
scheduleAfter: { delay, action in
|
||||
probe.enqueueScheduledAction(delay: delay, action: action)
|
||||
@@ -259,9 +339,9 @@ struct NostrTransportTests {
|
||||
#expect(result.packet.recipientID == fullPeerID.toShort().routingData)
|
||||
}
|
||||
|
||||
@Test("Geohash private message registers pending gift wrap")
|
||||
@Test("Geohash private message registers pending private envelope")
|
||||
@MainActor
|
||||
func sendPrivateMessageGeohashRegistersPendingGiftWrap() async throws {
|
||||
func sendPrivateMessageGeohashRegistersPendingPrivateEnvelope() async throws {
|
||||
let keychain = MockKeychain()
|
||||
let idBridge = NostrIdentityBridge(keychain: keychain)
|
||||
let sender = try NostrIdentity.generate()
|
||||
@@ -272,7 +352,7 @@ struct NostrTransportTests {
|
||||
idBridge: idBridge,
|
||||
dependencies: makeDependencies(
|
||||
currentIdentity: { sender },
|
||||
registerPendingGiftWrap: probe.recordPendingGiftWrap(id:),
|
||||
registerPendingPrivateEnvelope: probe.recordPendingPrivateEnvelope(id:),
|
||||
sendEvent: probe.record(event:),
|
||||
scheduleAfter: { delay, action in
|
||||
probe.enqueueScheduledAction(delay: delay, action: action)
|
||||
@@ -297,7 +377,7 @@ struct NostrTransportTests {
|
||||
#expect(privateMessage.messageID == "geo-1")
|
||||
#expect(privateMessage.content == "geo hello")
|
||||
#expect(result.packet.recipientID == nil)
|
||||
#expect(probe.pendingGiftWrapIDs == [event.id])
|
||||
#expect(probe.pendingPrivateEnvelopeIDs == [event.id])
|
||||
}
|
||||
|
||||
@Test("Read receipt queue sends in order and waits for scheduler")
|
||||
@@ -322,7 +402,7 @@ struct NostrTransportTests {
|
||||
favoriteStatusForNoiseKey: { $0 == noiseKey ? relationship : nil },
|
||||
favoriteStatusForPeerID: { _ in nil },
|
||||
currentIdentity: { sender },
|
||||
registerPendingGiftWrap: probe.recordPendingGiftWrap(id:),
|
||||
registerPendingPrivateEnvelope: probe.recordPendingPrivateEnvelope(id:),
|
||||
sendEvent: probe.record(event:),
|
||||
scheduleAfter: { delay, action in
|
||||
probe.enqueueScheduledAction(delay: delay, action: action)
|
||||
@@ -419,10 +499,13 @@ struct NostrTransportTests {
|
||||
favoriteStatusForNoiseKey: @escaping @MainActor (Data) -> FavoriteRelationship? = { _ in nil },
|
||||
favoriteStatusForPeerID: @escaping @MainActor (PeerID) -> FavoriteRelationship? = { _ in nil },
|
||||
currentIdentity: @escaping @MainActor () throws -> NostrIdentity? = { nil },
|
||||
registerPendingGiftWrap: @escaping @MainActor (String) -> Void = { _ in },
|
||||
registerPendingPrivateEnvelope: @escaping @MainActor (String) -> Void = { _ in },
|
||||
sendEvent: @escaping @MainActor (NostrEvent) -> Void = { _ in },
|
||||
scheduleAfter: @escaping @Sendable (TimeInterval, @escaping @Sendable () -> Void) -> Void = { _, _ in },
|
||||
relayConnectivity: @escaping @MainActor () -> AnyPublisher<Bool, Never> = { Just(false).eraseToAnyPublisher() }
|
||||
relayConnectivity: @escaping @MainActor () -> AnyPublisher<Bool, Never> = { Just(false).eraseToAnyPublisher() },
|
||||
now: @escaping @MainActor () -> Date = {
|
||||
NostrProtocol.legacyPrivateEnvelopePublicationDeadline.addingTimeInterval(1)
|
||||
}
|
||||
) -> NostrTransport.Dependencies {
|
||||
NostrTransport.Dependencies(
|
||||
notificationCenter: notificationCenter,
|
||||
@@ -430,10 +513,11 @@ struct NostrTransportTests {
|
||||
favoriteStatusForNoiseKey: favoriteStatusForNoiseKey,
|
||||
favoriteStatusForPeerID: favoriteStatusForPeerID,
|
||||
currentIdentity: currentIdentity,
|
||||
registerPendingGiftWrap: registerPendingGiftWrap,
|
||||
registerPendingPrivateEnvelope: registerPendingPrivateEnvelope,
|
||||
sendEvent: sendEvent,
|
||||
scheduleAfter: scheduleAfter,
|
||||
relayConnectivity: relayConnectivity
|
||||
relayConnectivity: relayConnectivity,
|
||||
now: now
|
||||
)
|
||||
}
|
||||
|
||||
@@ -457,8 +541,8 @@ struct NostrTransportTests {
|
||||
from event: NostrEvent,
|
||||
recipient: NostrIdentity
|
||||
) throws -> (packet: BitchatPacket, payload: NoisePayload, senderPubkey: String) {
|
||||
let (content, senderPubkey, _) = try NostrProtocol.decryptPrivateMessage(
|
||||
giftWrap: event,
|
||||
let (content, senderPubkey, _) = try NostrProtocol.decryptPrivateEnvelope(
|
||||
envelope: event,
|
||||
recipientIdentity: recipient
|
||||
)
|
||||
guard content.hasPrefix("bitchat1:") else {
|
||||
@@ -503,7 +587,7 @@ private func base64URLDecode(_ string: String) -> Data? {
|
||||
private final class NostrTransportProbe: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var sentEventsStorage: [NostrEvent] = []
|
||||
private var pendingGiftWrapIDsStorage: [String] = []
|
||||
private var pendingPrivateEnvelopeIDsStorage: [String] = []
|
||||
private var scheduledActionsStorage: [(@Sendable () -> Void)] = []
|
||||
|
||||
var sentEvents: [NostrEvent] {
|
||||
@@ -512,10 +596,10 @@ private final class NostrTransportProbe: @unchecked Sendable {
|
||||
return sentEventsStorage
|
||||
}
|
||||
|
||||
var pendingGiftWrapIDs: [String] {
|
||||
var pendingPrivateEnvelopeIDs: [String] {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return pendingGiftWrapIDsStorage
|
||||
return pendingPrivateEnvelopeIDsStorage
|
||||
}
|
||||
|
||||
var scheduledActionCount: Int {
|
||||
@@ -530,9 +614,9 @@ private final class NostrTransportProbe: @unchecked Sendable {
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
func recordPendingGiftWrap(id: String) {
|
||||
func recordPendingPrivateEnvelope(id: String) {
|
||||
lock.lock()
|
||||
pendingGiftWrapIDsStorage.append(id)
|
||||
pendingPrivateEnvelopeIDsStorage.append(id)
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user