Relabel private Nostr envelopes honestly

This commit is contained in:
jack
2026-07-10 17:22:24 -04:00
parent 733098bb63
commit cc32f0b596
28 changed files with 1161 additions and 474 deletions
@@ -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
+33 -28
View File
@@ -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"}
+310 -57
View File
@@ -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)
}
+104 -20
View File
@@ -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()
}