mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-26 22:45:20 +00:00
Relabel private Nostr envelopes honestly in docs; harden legacy envelope validation (#1480)
Split-out safe half of #1437 (the kind-1402 wire migration stays held for Android coordination). Docs (README/WHITEPAPER/PRIVACY_POLICY/privacy-assessment) now describe the actual proprietary DM construction — kind 1059 gift wrap carrying XChaCha20-Poly1305 with a 24-byte nonce, base64url v2: framing, and an HKDF that borrows the nip44-v2 info label but is not the NIP-44 key schedule — instead of claiming NIP-17/44/59. Hardens the existing legacy inbound path: 64 KiB ciphertext cap before decode (~46x the largest producible legacy envelope), outer kind/recipient-tag/signature binding, tagless kind-13 seal binding, unsigned kind-14 inner binding, inner tags restricted to the two shapes deployed clients emit (verified against every historical iOS release and a fixture frozen from Android production), SecRandomCopyBytes failure now throws, non-UTF-8 plaintext now throws instead of returning empty. Adds frozen cross-platform fixtures with hash-pinned generators; interop-reviewed with no rejection surface for deployed clients.
This commit is contained in:
@@ -2,11 +2,10 @@
|
||||
// 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
|
||||
@@ -102,24 +101,202 @@ struct NostrProtocolTests {
|
||||
senderIdentity: sender
|
||||
)
|
||||
|
||||
// Try to decrypt with wrong recipient
|
||||
if #available(macOS 14.4, iOS 17.4, *) {
|
||||
#expect(throws: CryptoKitError.authenticationFailure) {
|
||||
try NostrProtocol.decryptPrivateMessage(
|
||||
// Try to decrypt with wrong recipient. The outer envelope is bound to
|
||||
// the addressed recipient's `p` tag, so this now fails validation
|
||||
// before any decryption is attempted.
|
||||
expectInvalidEvent {
|
||||
_ = try NostrProtocol.decryptPrivateMessage(
|
||||
giftWrap: giftWrap,
|
||||
recipientIdentity: wrongRecipient
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func decryptAcceptsCurrentAndroidInnerRecipientTag() 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 released iOS
|
||||
// uses no inner tags. This isolated generator reproduces the Android
|
||||
// wire shape without making the production encoder depend on it.
|
||||
let giftWrap = try NostrProtocol.createPrivateMessageWithInnerTagsForTesting(
|
||||
content: "legacy message from Android",
|
||||
recipientPubkey: recipient.publicKeyHex,
|
||||
senderIdentity: sender,
|
||||
innerMessageTags: [["p", recipient.publicKeyHex]]
|
||||
)
|
||||
|
||||
let result = try NostrProtocol.decryptPrivateMessage(
|
||||
giftWrap: giftWrap,
|
||||
recipientIdentity: recipient
|
||||
)
|
||||
#expect(result.content == "legacy message from Android")
|
||||
#expect(result.senderPubkey == sender.publicKeyHex)
|
||||
}
|
||||
|
||||
@Test func decryptRejectsAlternateInnerTagShapes() 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 giftWrap = try NostrProtocol.createPrivateMessageWithInnerTagsForTesting(
|
||||
content: "invalid inner tag shape",
|
||||
recipientPubkey: recipient.publicKeyHex,
|
||||
senderIdentity: sender,
|
||||
innerMessageTags: tags
|
||||
)
|
||||
expectInvalidEvent {
|
||||
_ = try NostrProtocol.decryptPrivateMessage(
|
||||
giftWrap: giftWrap,
|
||||
recipientIdentity: wrongRecipient
|
||||
)
|
||||
}
|
||||
} else {
|
||||
#expect(throws: (any Error).self) {
|
||||
try NostrProtocol.decryptPrivateMessage(
|
||||
giftWrap: giftWrap,
|
||||
recipientIdentity: wrongRecipient
|
||||
recipientIdentity: recipient
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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")
|
||||
// The generator patch ships as .patch.txt so the Xcode synchronized
|
||||
// test group reliably copies it as a text resource; its pinned SHA-256
|
||||
// covers the unchanged patch content.
|
||||
#expect(metadata.generatorPatch == "AndroidLegacyPrivateEnvelopeB7f0b33dGenerator.patch.txt")
|
||||
#expect(metadata.fixtureSHA256 == eventData.sha256Fingerprint())
|
||||
#expect(metadata.gradleTest.contains("NostrProtocolTest.emitCrossPlatformFixtures"))
|
||||
let generatorPatch = try String(contentsOf: fixtureURL(
|
||||
name: "AndroidLegacyPrivateEnvelopeB7f0b33dGenerator.patch",
|
||||
extension: "txt"
|
||||
))
|
||||
#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.giftWrap.rawValue)
|
||||
#expect(envelope.tags == [["p", recipient.publicKeyHex]])
|
||||
|
||||
// The Android inner event carries exactly the recipient `p` tag, so a
|
||||
// successful decrypt also proves the Android inner-tag acceptance.
|
||||
let result = try NostrProtocol.decryptPrivateMessage(
|
||||
giftWrap: envelope,
|
||||
recipientIdentity: recipient
|
||||
)
|
||||
#expect(result.content == "legacy fixture from Android b7f0b33d")
|
||||
#expect(result.senderPubkey == metadata.senderPublicKey)
|
||||
}
|
||||
|
||||
@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.decryptPrivateMessage(
|
||||
giftWrap: envelope,
|
||||
recipientIdentity: recipient
|
||||
)
|
||||
#expect(result.content == "legacy fixture from 733098bb")
|
||||
#expect(result.senderPubkey == "2e3d79df7047204f02b726c574e256f8de1dd80510f7dcb8b0d12df13acb87e6")
|
||||
}
|
||||
|
||||
@Test func decryptRejectsOversizedCiphertextBeforeDecoding() throws {
|
||||
let recipient = try NostrIdentity.generate()
|
||||
let wrapper = try NostrIdentity.generate()
|
||||
let oversizedContent = "v2:"
|
||||
+ String(
|
||||
repeating: "A",
|
||||
count: NostrProtocol.maximumPrivateEnvelopeCiphertextBytes
|
||||
)
|
||||
let event = NostrEvent(
|
||||
pubkey: wrapper.publicKeyHex,
|
||||
createdAt: Date(),
|
||||
kind: .giftWrap,
|
||||
tags: [["p", recipient.publicKeyHex]],
|
||||
content: oversizedContent
|
||||
)
|
||||
let signed = try event.sign(with: wrapper.schnorrSigningKey())
|
||||
|
||||
expectInvalidCiphertext {
|
||||
_ = try NostrProtocol.decryptPrivateMessage(
|
||||
giftWrap: signed,
|
||||
recipientIdentity: recipient
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func decryptDoesNotMisinterpretStandardNIP44Payload() 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: .giftWrap,
|
||||
tags: [["p", recipient.publicKeyHex]],
|
||||
content: standardPayload
|
||||
)
|
||||
let signed = try event.sign(with: wrapper.schnorrSigningKey())
|
||||
|
||||
expectInvalidCiphertext {
|
||||
_ = try NostrProtocol.decryptPrivateMessage(
|
||||
giftWrap: signed,
|
||||
recipientIdentity: recipient
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func decryptRejectsWrongOuterKind() throws {
|
||||
let sender = try NostrIdentity.generate()
|
||||
let recipient = try NostrIdentity.generate()
|
||||
var giftWrap = try NostrProtocol.createPrivateMessage(
|
||||
content: "wrong outer kind",
|
||||
recipientPubkey: recipient.publicKeyHex,
|
||||
senderIdentity: sender
|
||||
)
|
||||
giftWrap = NostrEvent(
|
||||
pubkey: giftWrap.pubkey,
|
||||
createdAt: Date(timeIntervalSince1970: TimeInterval(giftWrap.created_at)),
|
||||
kind: .textNote,
|
||||
tags: giftWrap.tags,
|
||||
content: giftWrap.content
|
||||
)
|
||||
|
||||
expectInvalidEvent {
|
||||
_ = try NostrProtocol.decryptPrivateMessage(
|
||||
giftWrap: giftWrap,
|
||||
recipientIdentity: recipient
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func decryptRejectsInvalidSealSignature() throws {
|
||||
let sender = try NostrIdentity.generate()
|
||||
let recipient = try NostrIdentity.generate()
|
||||
@@ -349,6 +526,48 @@ struct NostrProtocolTests {
|
||||
]
|
||||
}
|
||||
|
||||
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 {
|
||||
// Bundle.module only exists under SwiftPM; the Xcode test targets
|
||||
// resolve resources through the test bundle (same pattern as
|
||||
// NoiseProtocolTests' NoiseTestVectors.json loader).
|
||||
#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
|
||||
@@ -366,4 +585,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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user