mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 22:45:19 +00:00
720 lines
29 KiB
Swift
720 lines
29 KiB
Swift
//
|
|
// NostrProtocolTests.swift
|
|
// bitchatTests
|
|
//
|
|
// Tests for BitChat's proprietary private-envelope transport over Nostr.
|
|
//
|
|
|
|
import Testing
|
|
import Foundation
|
|
import BitFoundation
|
|
@testable import bitchat
|
|
|
|
struct NostrProtocolTests {
|
|
|
|
@Test func privateEnvelopeRoundTrip() throws {
|
|
// Create sender and recipient identities
|
|
let sender = try NostrIdentity.generate()
|
|
let recipient = try NostrIdentity.generate()
|
|
|
|
print("Sender pubkey: \(sender.publicKeyHex)")
|
|
print("Recipient pubkey: \(recipient.publicKeyHex)")
|
|
|
|
// Create a test message
|
|
let originalContent = "Hello from BitChat private-envelope test!"
|
|
|
|
let envelope = try NostrProtocol.createPrivateEnvelope(
|
|
content: originalContent,
|
|
recipientPubkey: recipient.publicKeyHex,
|
|
senderIdentity: sender
|
|
)
|
|
|
|
print("Private envelope created with ID: \(envelope.id)")
|
|
print("Private envelope pubkey: \(envelope.pubkey)")
|
|
|
|
let (decryptedContent, senderPubkey, timestamp) = try NostrProtocol.decryptPrivateEnvelope(
|
|
envelope: envelope,
|
|
recipientIdentity: recipient
|
|
)
|
|
|
|
// Verify
|
|
#expect(decryptedContent == originalContent)
|
|
#expect(senderPubkey == sender.publicKeyHex)
|
|
|
|
// Verify timestamp is reasonable (within last minute)
|
|
let messageDate = Date(timeIntervalSince1970: TimeInterval(timestamp))
|
|
let timeDiff = abs(messageDate.timeIntervalSinceNow)
|
|
#expect(timeDiff < 60, "Message timestamp should be recent")
|
|
|
|
print("✅ Successfully decrypted message: '\(decryptedContent)' from \(senderPubkey) at \(messageDate)")
|
|
}
|
|
|
|
@Test func privateEnvelopesUseUniqueEphemeralKeys() throws {
|
|
// Create identities
|
|
let sender = try NostrIdentity.generate()
|
|
let recipient = try NostrIdentity.generate()
|
|
|
|
// Create two messages
|
|
let message1 = try NostrProtocol.createPrivateEnvelope(
|
|
content: "Message 1",
|
|
recipientPubkey: recipient.publicKeyHex,
|
|
senderIdentity: sender
|
|
)
|
|
|
|
let message2 = try NostrProtocol.createPrivateEnvelope(
|
|
content: "Message 2",
|
|
recipientPubkey: recipient.publicKeyHex,
|
|
senderIdentity: sender
|
|
)
|
|
|
|
// Public envelope keys must be one-time use.
|
|
#expect(message1.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.decryptPrivateEnvelope(
|
|
envelope: message1,
|
|
recipientIdentity: recipient
|
|
)
|
|
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)
|
|
#expect(layers.message.tags.isEmpty)
|
|
}
|
|
|
|
@Test func decryptAcceptsCurrentAndroidLegacyInnerRecipientTag() throws {
|
|
let sender = try NostrIdentity.generate()
|
|
let recipient = try NostrIdentity.generate()
|
|
|
|
// Current Android's `createPrivateMessage` emits an unsigned kind-14
|
|
// inner event with exactly [["p", recipient]], while its outer/seal
|
|
// layers use the deployed BitChat legacy v2 crypto. This isolated
|
|
// generator reproduces that cross-platform wire shape without making
|
|
// the production encoder depend on Android's historical tag choice.
|
|
let envelope = try NostrProtocol.createLegacyPrivateEnvelopeForTesting(
|
|
content: "legacy message from Android",
|
|
recipientPubkey: recipient.publicKeyHex,
|
|
senderIdentity: sender,
|
|
innerMessageTags: [["p", recipient.publicKeyHex]]
|
|
)
|
|
|
|
let layers = try NostrProtocol.decodePrivateEnvelopeLayersForTesting(
|
|
envelope: envelope,
|
|
recipientIdentity: recipient
|
|
)
|
|
#expect(layers.message.tags == [["p", recipient.publicKeyHex]])
|
|
|
|
let result = try NostrProtocol.decryptPrivateEnvelope(
|
|
envelope: envelope,
|
|
recipientIdentity: recipient
|
|
)
|
|
#expect(result.content == "legacy message from Android")
|
|
#expect(result.senderPubkey == sender.publicKeyHex)
|
|
}
|
|
|
|
@Test func decryptsFrozenLegacyEnvelopeProducedByAndroidB7f0b33d() throws {
|
|
let eventData = try Data(contentsOf: fixtureURL(
|
|
name: "AndroidLegacyPrivateEnvelopeB7f0b33d"
|
|
))
|
|
let metadataData = try Data(contentsOf: fixtureURL(
|
|
name: "AndroidLegacyPrivateEnvelopeB7f0b33dMetadata"
|
|
))
|
|
let envelope = try JSONDecoder().decode(NostrEvent.self, from: eventData)
|
|
let metadata = try JSONDecoder().decode(AndroidLegacyEnvelopeFixture.self, from: metadataData)
|
|
let recipientKey = try #require(Data(hexString: metadata.recipientPrivateKey))
|
|
let recipient = try NostrIdentity(privateKeyData: recipientKey)
|
|
|
|
#expect(metadata.androidCommit == "b7f0b33d3a267c770d3d5a65ee2d8c7e755450db")
|
|
#expect(metadata.generator == "NostrProtocol.createPrivateMessage")
|
|
#expect(metadata.generatorPatch == "AndroidLegacyPrivateEnvelopeB7f0b33dGenerator.patch")
|
|
#expect(metadata.fixtureSHA256 == eventData.sha256Fingerprint())
|
|
#expect(metadata.gradleTest.contains("NostrProtocolTest.emitCrossPlatformFixtures"))
|
|
let generatorPatch = try String(contentsOf: fixtureURL(
|
|
name: "AndroidLegacyPrivateEnvelopeB7f0b33dGenerator",
|
|
extension: "patch"
|
|
))
|
|
#expect(metadata.generatorPatchSHA256 == Data(generatorPatch.utf8).sha256Fingerprint())
|
|
#expect(generatorPatch.contains("fun emitCrossPlatformFixtures()"))
|
|
#expect(generatorPatch.contains(metadata.recipientPrivateKey))
|
|
#expect(envelope.isValidSignature())
|
|
#expect(envelope.kind == NostrProtocol.EventKind.legacyNIP59GiftWrap.rawValue)
|
|
#expect(envelope.tags == [["p", recipient.publicKeyHex]])
|
|
|
|
let layers = try NostrProtocol.decodePrivateEnvelopeLayersForTesting(
|
|
envelope: envelope,
|
|
recipientIdentity: recipient
|
|
)
|
|
#expect(layers.message.tags == [["p", recipient.publicKeyHex]])
|
|
|
|
let result = try NostrProtocol.decryptPrivateEnvelope(
|
|
envelope: envelope,
|
|
recipientIdentity: recipient
|
|
)
|
|
#expect(result.content == "legacy fixture from Android b7f0b33d")
|
|
#expect(result.senderPubkey == metadata.senderPublicKey)
|
|
}
|
|
|
|
@Test func decryptRejectsAlternateLegacyInnerTags() throws {
|
|
let sender = try NostrIdentity.generate()
|
|
let recipient = try NostrIdentity.generate()
|
|
let otherRecipient = try NostrIdentity.generate()
|
|
let invalidTagShapes = [
|
|
[["p", otherRecipient.publicKeyHex]],
|
|
[["p", recipient.publicKeyHex], ["p", recipient.publicKeyHex]],
|
|
[["p", recipient.publicKeyHex, "unexpected"]],
|
|
[["x", recipient.publicKeyHex]]
|
|
]
|
|
|
|
for tags in invalidTagShapes {
|
|
let envelope = try NostrProtocol.createLegacyPrivateEnvelopeForTesting(
|
|
content: "invalid Android-shaped tags",
|
|
recipientPubkey: recipient.publicKeyHex,
|
|
senderIdentity: sender,
|
|
innerMessageTags: tags
|
|
)
|
|
expectInvalidEvent {
|
|
_ = try NostrProtocol.decryptPrivateEnvelope(
|
|
envelope: envelope,
|
|
recipientIdentity: recipient
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
@Test func newPrivateEnvelopeRejectsInnerRecipientTag() throws {
|
|
let sender = try NostrIdentity.generate()
|
|
let recipient = try NostrIdentity.generate()
|
|
let envelope = try NostrProtocol.createPrivateEnvelopeWithInnerTagsForTesting(
|
|
content: "new format stays strict",
|
|
recipientPubkey: recipient.publicKeyHex,
|
|
senderIdentity: sender,
|
|
innerMessageTags: [["p", recipient.publicKeyHex]]
|
|
)
|
|
|
|
expectInvalidEvent {
|
|
_ = try NostrProtocol.decryptPrivateEnvelope(
|
|
envelope: envelope,
|
|
recipientIdentity: recipient
|
|
)
|
|
}
|
|
}
|
|
|
|
@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 publicationBatchAlwaysDualPublishesForCoordinatedMigration() throws {
|
|
let sender = try NostrIdentity.generate()
|
|
let recipient = try NostrIdentity.generate()
|
|
|
|
let migrationBatch = try NostrProtocol.createPrivateEnvelopePublicationBatch(
|
|
content: "mixed-version",
|
|
recipientPubkey: recipient.publicKeyHex,
|
|
senderIdentity: sender
|
|
)
|
|
#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")
|
|
}
|
|
|
|
// The compatibility copy retains the exact released-iOS legacy shape,
|
|
// which current Android also accepts: kinds 1059/13/14, v2 prefix, and
|
|
// no inner tags. There is no wall-clock branch that can silently stop
|
|
// old-client delivery.
|
|
let compatibilityLayers = try NostrProtocol.decodePrivateEnvelopeLayersForTesting(
|
|
envelope: migrationBatch[1],
|
|
recipientIdentity: recipient
|
|
)
|
|
#expect(migrationBatch[1].content.hasPrefix("v2:"))
|
|
#expect(compatibilityLayers.seal.kind == NostrProtocol.EventKind.legacyNIP59Seal.rawValue)
|
|
#expect(compatibilityLayers.message.kind == NostrProtocol.EventKind.legacyNIP17DirectMessage.rawValue)
|
|
#expect(compatibilityLayers.message.tags.isEmpty)
|
|
}
|
|
|
|
@Test func mailboxLookbackCoversDeliveryWindowAndroidFuzzAndClockSkew() {
|
|
let sentAt = Date(timeIntervalSince1970: 1_800_000_000)
|
|
let earliestAndroidTimestamp = sentAt.addingTimeInterval(
|
|
-TransportConfig.nostrLegacyAndroidTimestampFuzzSeconds
|
|
)
|
|
let subscribeAtDeliveryBoundary = sentAt.addingTimeInterval(
|
|
TransportConfig.nostrPrivateEnvelopeDeliveryWindowSeconds
|
|
)
|
|
let filterSince = subscribeAtDeliveryBoundary.addingTimeInterval(
|
|
-TransportConfig.nostrDMSubscribeLookbackSeconds
|
|
)
|
|
|
|
#expect(TransportConfig.nostrPrivateEnvelopeDeliveryWindowSeconds == 24 * 60 * 60)
|
|
#expect(TransportConfig.nostrLegacyAndroidTimestampFuzzSeconds == 48 * 60 * 60)
|
|
#expect(TransportConfig.nostrDMSubscribeClockSkewSeconds == 15 * 60)
|
|
let expectedLookback: TimeInterval = 72 * 60 * 60 + 15 * 60
|
|
#expect(TransportConfig.nostrDMSubscribeLookbackSeconds == expectedLookback)
|
|
#expect(filterSince == earliestAndroidTimestamp.addingTimeInterval(-(15 * 60)))
|
|
}
|
|
|
|
@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()
|
|
let recipient = try NostrIdentity.generate()
|
|
let wrongRecipient = try NostrIdentity.generate()
|
|
|
|
// Create message for recipient
|
|
let envelope = try NostrProtocol.createPrivateEnvelope(
|
|
content: "Secret message",
|
|
recipientPubkey: recipient.publicKeyHex,
|
|
senderIdentity: sender
|
|
)
|
|
|
|
expectInvalidEvent {
|
|
_ = try NostrProtocol.decryptPrivateEnvelope(
|
|
envelope: envelope,
|
|
recipientIdentity: wrongRecipient
|
|
)
|
|
}
|
|
}
|
|
|
|
@Test func decryptRejectsInvalidSealSignature() throws {
|
|
let sender = try NostrIdentity.generate()
|
|
let recipient = try NostrIdentity.generate()
|
|
let envelope = try NostrProtocol.createPrivateEnvelopeWithInvalidSealSignatureForTesting(
|
|
content: "forged signature",
|
|
recipientPubkey: recipient.publicKeyHex,
|
|
senderIdentity: sender
|
|
)
|
|
|
|
expectInvalidEvent {
|
|
_ = try NostrProtocol.decryptPrivateEnvelope(
|
|
envelope: envelope,
|
|
recipientIdentity: recipient
|
|
)
|
|
}
|
|
}
|
|
|
|
@Test func decryptRejectsSealMessagePubkeyMismatch() throws {
|
|
let claimedSender = try NostrIdentity.generate()
|
|
let sealSigner = try NostrIdentity.generate()
|
|
let recipient = try NostrIdentity.generate()
|
|
let envelope = try NostrProtocol.createPrivateEnvelopeWithMismatchedSealMessagePubkeyForTesting(
|
|
content: "spoofed sender",
|
|
recipientPubkey: recipient.publicKeyHex,
|
|
messageIdentity: claimedSender,
|
|
sealSignerIdentity: sealSigner
|
|
)
|
|
|
|
expectInvalidEvent {
|
|
_ = try NostrProtocol.decryptPrivateEnvelope(
|
|
envelope: envelope,
|
|
recipientIdentity: recipient
|
|
)
|
|
}
|
|
}
|
|
|
|
@Test
|
|
func deliveredAckRoundTripsInsidePrivateEnvelope() throws {
|
|
// Identities
|
|
let sender = try NostrIdentity.generate()
|
|
let recipient = try NostrIdentity.generate()
|
|
|
|
// Build a DELIVERED ack embedded payload (geohash-style, no recipient peer ID)
|
|
let messageID = "TEST-MSG-DELIVERED-1"
|
|
let senderPeerID = PeerID(str: "0123456789abcdef") // 8-byte hex peer ID
|
|
|
|
let embedded = try #require(
|
|
NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .delivered, messageID: messageID, senderPeerID: senderPeerID),
|
|
"Failed to embed delivered ack"
|
|
)
|
|
|
|
let envelope = try NostrProtocol.createPrivateEnvelope(
|
|
content: embedded,
|
|
recipientPubkey: recipient.publicKeyHex,
|
|
senderIdentity: sender
|
|
)
|
|
|
|
#expect(envelope.content.hasPrefix(NostrProtocol.privateEnvelopeContentPrefix))
|
|
|
|
// Decrypt as recipient
|
|
let (content, senderPubkey, _) = try NostrProtocol.decryptPrivateEnvelope(
|
|
envelope: envelope,
|
|
recipientIdentity: recipient
|
|
)
|
|
|
|
// Verify sender is correct
|
|
#expect(senderPubkey == sender.publicKeyHex)
|
|
|
|
// Parse BitChat payload
|
|
#expect(content.hasPrefix("bitchat1:"))
|
|
let base64url = String(content.dropFirst("bitchat1:".count))
|
|
let packetData = try #require(Self.base64URLDecode(base64url))
|
|
let packet = try #require(BitchatPacket.from(packetData), "Failed to decode bitchat packet")
|
|
|
|
#expect(packet.type == MessageType.noiseEncrypted.rawValue)
|
|
let payload = try #require(NoisePayload.decode(packet.payload), "Failed to decode NoisePayload")
|
|
|
|
switch payload.type {
|
|
case .delivered:
|
|
let mid = String(data: payload.data, encoding: .utf8)
|
|
#expect(mid == messageID)
|
|
default:
|
|
Issue.record("Unexpected payload type: \(payload.type)")
|
|
}
|
|
}
|
|
|
|
@Test func readReceiptRoundTripsInsidePrivateEnvelope() throws {
|
|
// Identities
|
|
let sender = try NostrIdentity.generate()
|
|
let recipient = try NostrIdentity.generate()
|
|
|
|
let messageID = "TEST-MSG-READ-1"
|
|
let senderPeerID = PeerID(str: "fedcba9876543210") // 8-byte hex peer ID
|
|
let embedded = try #require(
|
|
NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .readReceipt, messageID: messageID, senderPeerID: senderPeerID),
|
|
"Failed to embed read ack"
|
|
)
|
|
|
|
let envelope = try NostrProtocol.createPrivateEnvelope(
|
|
content: embedded,
|
|
recipientPubkey: recipient.publicKeyHex,
|
|
senderIdentity: sender
|
|
)
|
|
|
|
#expect(envelope.content.hasPrefix(NostrProtocol.privateEnvelopeContentPrefix))
|
|
|
|
let (content, senderPubkey, _) = try NostrProtocol.decryptPrivateEnvelope(
|
|
envelope: envelope,
|
|
recipientIdentity: recipient
|
|
)
|
|
#expect(senderPubkey == sender.publicKeyHex)
|
|
|
|
#expect(content.hasPrefix("bitchat1:"))
|
|
let base64url = String(content.dropFirst("bitchat1:".count))
|
|
let packetData = try #require(Self.base64URLDecode(base64url))
|
|
let packet = try #require(BitchatPacket.from(packetData), "Failed to decode bitchat packet")
|
|
|
|
#expect(packet.type == MessageType.noiseEncrypted.rawValue)
|
|
let payload = try #require(NoisePayload.decode(packet.payload), "Failed to decode NoisePayload")
|
|
|
|
switch payload.type {
|
|
case .readReceipt:
|
|
let mid = String(data: payload.data, encoding: .utf8)
|
|
#expect(mid == messageID)
|
|
default:
|
|
Issue.record("Unexpected payload type: \(payload.type)")
|
|
}
|
|
}
|
|
|
|
@Test func nostrEventSignatureVerification_roundTrip() throws {
|
|
let identity = try NostrIdentity.generate()
|
|
let event = NostrEvent(
|
|
pubkey: identity.publicKeyHex,
|
|
createdAt: Date(),
|
|
kind: .ephemeralEvent,
|
|
tags: [],
|
|
content: "Signed event"
|
|
)
|
|
let signed = try event.sign(with: identity.schnorrSigningKey())
|
|
#expect(signed.isValidSignature())
|
|
}
|
|
|
|
@Test func nostrEventSignatureVerification_detectsTamper() throws {
|
|
let identity = try NostrIdentity.generate()
|
|
let event = NostrEvent(
|
|
pubkey: identity.publicKeyHex,
|
|
createdAt: Date(),
|
|
kind: .ephemeralEvent,
|
|
tags: [],
|
|
content: "Original"
|
|
)
|
|
var signed = try event.sign(with: identity.schnorrSigningKey())
|
|
signed.id = "deadbeef"
|
|
#expect(!signed.isValidSignature())
|
|
}
|
|
|
|
@Test func geohashNotesSingleFilter_encodesExpectedTagShape() throws {
|
|
let since = Date(timeIntervalSince1970: 1_234_567)
|
|
let filter = NostrFilter.geohashNotes("u4pruyd", since: since, limit: 42)
|
|
let data = try JSONEncoder().encode(filter)
|
|
let object = try #require(try JSONSerialization.jsonObject(with: data) as? [String: Any])
|
|
|
|
#expect(object["kinds"] as? [Int] == [1])
|
|
#expect(object["#g"] as? [String] == ["u4pruyd"])
|
|
#expect(object["since"] as? Int == 1_234_567)
|
|
#expect(object["limit"] as? Int == 42)
|
|
}
|
|
|
|
@Test func privateEnvelopeFiltersGiveEachMigrationKindAnIndependentRecoveryBudget() throws {
|
|
let since = Date(timeIntervalSince1970: 1_234_567)
|
|
let filters = NostrFilter.privateEnvelopeFiltersFor(
|
|
pubkey: "recipient",
|
|
since: since
|
|
)
|
|
|
|
#expect(filters.count == 2)
|
|
let objects = try filters.map { filter in
|
|
let data = try JSONEncoder().encode(filter)
|
|
return try #require(
|
|
try JSONSerialization.jsonObject(with: data) as? [String: Any]
|
|
)
|
|
}
|
|
#expect(objects.compactMap { $0["kinds"] as? [Int] } == [
|
|
[NostrProtocol.EventKind.privateEnvelope.rawValue],
|
|
[NostrProtocol.EventKind.legacyNIP59GiftWrap.rawValue]
|
|
])
|
|
for object in objects {
|
|
#expect(object["#p"] as? [String] == ["recipient"])
|
|
#expect(object["since"] as? Int == 1_234_567)
|
|
#expect(object["limit"] as? Int ==
|
|
TransportConfig.nostrPrivateEnvelopeFetchLimitPerKind
|
|
)
|
|
}
|
|
}
|
|
|
|
// MARK: - Helpers
|
|
private struct LegacyRecipientKeyFixture: Decodable {
|
|
let recipientPrivateKey: String
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case recipientPrivateKey = "recipient_private_key"
|
|
}
|
|
}
|
|
|
|
private struct AndroidLegacyEnvelopeFixture: Decodable {
|
|
let androidCommit: String
|
|
let generator: String
|
|
let generatorPatch: String
|
|
let generatorPatchSHA256: String
|
|
let gradleTest: String
|
|
let fixtureSHA256: String
|
|
let recipientPrivateKey: String
|
|
let senderPublicKey: String
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case androidCommit = "android_commit"
|
|
case generator
|
|
case generatorPatch = "generator_patch"
|
|
case generatorPatchSHA256 = "generator_patch_sha256"
|
|
case gradleTest = "gradle_test"
|
|
case fixtureSHA256 = "fixture_sha256"
|
|
case recipientPrivateKey = "recipient_private_key"
|
|
case senderPublicKey = "sender_public_key"
|
|
}
|
|
}
|
|
|
|
private func fixtureURL(name: String, extension fileExtension: String = "json") 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: fileExtension))
|
|
}
|
|
|
|
private static func base64URLDecode(_ s: String) -> Data? {
|
|
var str = s.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
|
|
let rem = str.count % 4
|
|
if rem > 0 { str.append(String(repeating: "=", count: 4 - rem)) }
|
|
return Data(base64Encoded: str)
|
|
}
|
|
|
|
private func expectInvalidEvent(_ operation: () throws -> Void) {
|
|
do {
|
|
try operation()
|
|
Issue.record("Expected NostrError.invalidEvent")
|
|
} catch NostrError.invalidEvent {
|
|
return
|
|
} catch {
|
|
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)")
|
|
}
|
|
}
|
|
}
|