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:
jack
2026-07-26 12:50:17 +02:00
committed by GitHub
parent 55f824a11f
commit e9275cb3d8
21 changed files with 500 additions and 107 deletions
+244 -14
View File
@@ -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)")
}
}
}