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
+404 -230
View File
@@ -1,4 +1,3 @@
import BitLogger
import Foundation
import CryptoKit
import P256K
@@ -7,16 +6,30 @@ import Security
// Note: This file depends on Data extension from BinaryEncodingUtils.swift
// Make sure BinaryEncodingUtils.swift is included in the target
/// NIP-17 Protocol Implementation for Private Direct Messages
/// BitChat's private-envelope protocol transported over Nostr relays.
///
/// This is deliberately BitChat-specific and is not NIP-17, NIP-44, or NIP-59.
/// It uses Nostr events and secp256k1 identities, but its XChaCha20-Poly1305
/// payload layout is proprietary and interoperates only with BitChat clients.
struct NostrProtocol {
/// Nostr event kinds
enum EventKind: Int {
case metadata = 0
case textNote = 1
case dm = 14 // NIP-17 DM rumor kind
case seal = 13 // NIP-17 sealed event
case giftWrap = 1059 // NIP-59 gift wrap
// Bounded compatibility for BitChat releases that incorrectly emitted
// the proprietary payload under standard NIP kinds. Only kind 1059 is
// temporarily published during migration; all three remain readable.
case legacyNIP59Seal = 13
case legacyNIP17DirectMessage = 14
case legacyNIP59GiftWrap = 1059
// Provisional BitChat-specific regular event kinds. These are not
// formally reserved by the Nostr kind registry. Only
// `privateEnvelope` is published; message and seal exist solely
// inside ciphertext.
case privateEnvelope = 1402
case privateSeal = 1403
case privateMessage = 1404
case ephemeralEvent = 20000
case geohashPresence = 20001
case deletion = 5 // NIP-09 event deletion request
@@ -25,145 +38,272 @@ struct NostrProtocol {
/// its NIP-40 expiration the whole point is store-and-forward.
case courierDrop = 1401
}
/// Prefix for BitChat private-envelope ciphertext. The suffix is
/// base64url(nonce24 || ciphertext || poly1305Tag).
static let privateEnvelopeContentPrefix = "bitchat-pm-v1:"
/// Bound work before Base64 decoding either encrypted layer. Current
/// private messages are normally only a few KiB; 64 KiB leaves ample
/// migration headroom without allowing an addressed relay event to drive
/// unbounded allocation.
static let maximumPrivateEnvelopeCiphertextBytes = 64 * 1024
/// Bound the inner authenticated message JSON before allocation/parsing.
static let maximumPrivateEnvelopePlaintextBytes = 32 * 1024
/// The outer authenticated seal JSON contains a Base64-encoded encrypted
/// copy of the inner JSON, so it needs expansion headroom of its own. Keep
/// the layer-specific cap below the public ciphertext ceiling.
private static let maximumPrivateEnvelopeSealPlaintextBytes = 48 * 1024
/// Compatibility-only publication stops at this instant. New-format kind
/// 1402 remains first/primary throughout the window; the legacy kind-1059
/// copy exists solely so pre-migration BitChat clients can receive it.
static let legacyPrivateEnvelopePublicationDeadline = Date(
timeIntervalSince1970: 1_792_022_400 // 2026-10-15T00:00:00Z
)
/// New clients subscribe to the provisional BitChat-specific kind and the
/// compatibility-only legacy kind so both sides of a rolling rollout can
/// recover stored messages.
static let acceptedPrivateEnvelopeKinds = [
EventKind.privateEnvelope.rawValue,
EventKind.legacyNIP59GiftWrap.rawValue
]
private enum PrivateEnvelopeWireFormat {
case bitchatV1
case legacyMislabelledV2
init?(outerKind: Int) {
switch outerKind {
case EventKind.privateEnvelope.rawValue:
self = .bitchatV1
case EventKind.legacyNIP59GiftWrap.rawValue:
self = .legacyMislabelledV2
default:
return nil
}
}
var messageKind: EventKind {
switch self {
case .bitchatV1: .privateMessage
case .legacyMislabelledV2: .legacyNIP17DirectMessage
}
}
var sealKind: EventKind {
switch self {
case .bitchatV1: .privateSeal
case .legacyMislabelledV2: .legacyNIP59Seal
}
}
var envelopeKind: EventKind {
switch self {
case .bitchatV1: .privateEnvelope
case .legacyMislabelledV2: .legacyNIP59GiftWrap
}
}
var contentPrefix: String {
switch self {
case .bitchatV1: NostrProtocol.privateEnvelopeContentPrefix
case .legacyMislabelledV2: "v2:"
}
}
var hkdfSalt: Data {
switch self {
case .bitchatV1: Data("bitchat-private-envelope-v1".utf8)
case .legacyMislabelledV2: Data()
}
}
var hkdfInfo: Data {
switch self {
case .bitchatV1: Data()
case .legacyMislabelledV2: Data("nip44-v2".utf8)
}
}
}
/// Create a NIP-17 private message
static func createPrivateMessage(
/// Create a BitChat private envelope for relay transport.
static func createPrivateEnvelope(
content: String,
recipientPubkey: String,
senderIdentity: NostrIdentity
) throws -> NostrEvent {
// Creating private message
// 1. Create the rumor (unsigned event)
let rumor = NostrEvent(
try createPrivateEnvelope(
content: content,
recipientPubkey: recipientPubkey,
senderIdentity: senderIdentity,
format: .bitchatV1
)
}
/// Events to publish for one logical private payload. The primary
/// BitChat-specific format is always first. Until the explicit migration
/// deadline, a legacy copy follows for clients that still subscribe only
/// to kind 1059. Both encrypt the exact same embedded BitChat payload, so
/// receive-side logical-payload dedup collapses the pair.
static func createPrivateEnvelopePublicationBatch(
content: String,
recipientPubkey: String,
senderIdentity: NostrIdentity,
now: Date = Date()
) throws -> [NostrEvent] {
let primary = try createPrivateEnvelope(
content: content,
recipientPubkey: recipientPubkey,
senderIdentity: senderIdentity
)
guard now < legacyPrivateEnvelopePublicationDeadline else {
return [primary]
}
let compatibilityCopy = try createPrivateEnvelope(
content: content,
recipientPubkey: recipientPubkey,
senderIdentity: senderIdentity,
format: .legacyMislabelledV2
)
return [primary, compatibilityCopy]
}
private static func createPrivateEnvelope(
content: String,
recipientPubkey: String,
senderIdentity: NostrIdentity,
format: PrivateEnvelopeWireFormat
) throws -> NostrEvent {
// 1. Create the unsigned inner BitChat message.
let message = NostrEvent(
pubkey: senderIdentity.publicKeyHex,
createdAt: Date(),
kind: .dm, // NIP-17: DM rumor kind 14
kind: format.messageKind,
tags: [],
content: content
)
// 2. Seal the rumor (encrypt to recipient) and sign it with the SENDER'S
// real identity key. NIP-17 requires the seal be signed by the sender
// so the recipient can authenticate who sent the message; signing with
// a throwaway key leaves DMs forgeable/impersonatable.
// 2. Encrypt the message to the recipient and sign the private seal
// with the sender's stable Nostr identity for sender authentication.
let senderKey = try senderIdentity.schnorrSigningKey()
let sealedEvent = try createSeal(
rumor: rumor,
let sealedEvent = try createPrivateSeal(
message: message,
recipientPubkey: recipientPubkey,
senderKey: senderKey
senderKey: senderKey,
format: format
)
// 3. Gift wrap the sealed event with a throwaway ephemeral key (the wrap
// layer hides the sender's identity from relays; createGiftWrap mints
// its own ephemeral key internally).
let giftWrap = try createGiftWrap(
// 3. Encrypt the seal under a one-time key so the public envelope does
// not reveal the stable sender identity.
return try createPrivateEnvelopeEvent(
seal: sealedEvent,
recipientPubkey: recipientPubkey
recipientPubkey: recipientPubkey,
format: format
)
// Created gift wrap
return giftWrap
}
/// Decrypt a received NIP-17 message
/// Returns the content, sender pubkey, and the actual message timestamp (not the randomized gift wrap timestamp)
static func decryptPrivateMessage(
giftWrap: NostrEvent,
/// Decrypt a BitChat private envelope. Legacy proprietary envelopes that
/// older BitChat releases placed under kinds 1059/13/14 are accepted only
/// through the format-isolated receive path.
static func decryptPrivateEnvelope(
envelope: NostrEvent,
recipientIdentity: NostrIdentity
) throws -> (content: String, senderPubkey: String, timestamp: Int) {
// Starting decryption
// 1. Unwrap the gift wrap
let seal: NostrEvent
do {
seal = try unwrapGiftWrap(
giftWrap: giftWrap,
recipientKey: recipientIdentity.schnorrSigningKey()
)
// Successfully unwrapped gift wrap
} catch {
SecureLogger.error("❌ Failed to unwrap gift wrap: \(error)", category: .session)
throw error
}
// 2. Authenticate the seal. The seal MUST be signed by the sender's real
// identity key (NIP-17); without this check a DM is forgeable by anyone
// who knows the recipient's npub. Verify the seal's own signature.
guard seal.isValidSignature() else {
SecureLogger.error("❌ Rejecting DM: seal signature is missing or invalid", category: .session)
throw NostrError.invalidEvent
}
// 3. Open the seal
let rumor: NostrEvent
do {
rumor = try openSeal(
seal: seal,
recipientKey: recipientIdentity.schnorrSigningKey()
)
// Successfully opened seal
} catch {
SecureLogger.error("❌ Failed to open seal: \(error)", category: .session)
throw error
}
// 4. The sender claimed inside the rumor must match the key that actually
// signed the seal, otherwise the sender field is unauthenticated and
// spoofable.
guard seal.pubkey == rumor.pubkey else {
SecureLogger.error("❌ Rejecting DM: rumor pubkey does not match seal signer", category: .session)
throw NostrError.invalidEvent
}
// Return the seal signer's pubkey as the authenticated sender.
return (content: rumor.content, senderPubkey: seal.pubkey, timestamp: rumor.created_at)
let layers = try decodePrivateEnvelopeLayers(
envelope: envelope,
recipientIdentity: recipientIdentity
)
return (
content: layers.message.content,
senderPubkey: layers.seal.pubkey,
timestamp: layers.message.created_at
)
}
#if DEBUG
static func createPrivateMessageWithInvalidSealSignatureForTesting(
static func createPrivateEnvelopeWithInvalidSealSignatureForTesting(
content: String,
recipientPubkey: String,
senderIdentity: NostrIdentity
) throws -> NostrEvent {
let rumor = NostrEvent(
let format = PrivateEnvelopeWireFormat.bitchatV1
let message = NostrEvent(
pubkey: senderIdentity.publicKeyHex,
createdAt: Date(),
kind: .dm,
kind: format.messageKind,
tags: [],
content: content
)
var seal = try createSeal(
rumor: rumor,
var seal = try createPrivateSeal(
message: message,
recipientPubkey: recipientPubkey,
senderKey: senderIdentity.schnorrSigningKey()
senderKey: senderIdentity.schnorrSigningKey(),
format: format
)
seal.sig = String(repeating: "0", count: 128)
return try createGiftWrap(seal: seal, recipientPubkey: recipientPubkey)
return try createPrivateEnvelopeEvent(
seal: seal,
recipientPubkey: recipientPubkey,
format: format
)
}
static func createPrivateMessageWithMismatchedSealRumorPubkeyForTesting(
static func createPrivateEnvelopeWithMismatchedSealMessagePubkeyForTesting(
content: String,
recipientPubkey: String,
rumorIdentity: NostrIdentity,
messageIdentity: NostrIdentity,
sealSignerIdentity: NostrIdentity
) throws -> NostrEvent {
let rumor = NostrEvent(
pubkey: rumorIdentity.publicKeyHex,
let format = PrivateEnvelopeWireFormat.bitchatV1
let message = NostrEvent(
pubkey: messageIdentity.publicKeyHex,
createdAt: Date(),
kind: .dm,
kind: format.messageKind,
tags: [],
content: content
)
let seal = try createSeal(
rumor: rumor,
let seal = try createPrivateSeal(
message: message,
recipientPubkey: recipientPubkey,
senderKey: sealSignerIdentity.schnorrSigningKey()
senderKey: sealSignerIdentity.schnorrSigningKey(),
format: format
)
return try createGiftWrap(seal: seal, recipientPubkey: recipientPubkey)
return try createPrivateEnvelopeEvent(
seal: seal,
recipientPubkey: recipientPubkey,
format: format
)
}
static func createLegacyPrivateEnvelopeForTesting(
content: String,
recipientPubkey: String,
senderIdentity: NostrIdentity
) throws -> NostrEvent {
try createPrivateEnvelope(
content: content,
recipientPubkey: recipientPubkey,
senderIdentity: senderIdentity,
format: .legacyMislabelledV2
)
}
static func decodePrivateEnvelopeLayersForTesting(
envelope: NostrEvent,
recipientIdentity: NostrIdentity
) throws -> (seal: NostrEvent, message: NostrEvent) {
try decodePrivateEnvelopeLayers(
envelope: envelope,
recipientIdentity: recipientIdentity
)
}
static func decodePrivateEnvelopeEventJSONForTesting(_ json: String) throws -> NostrEvent {
try decodePrivateEnvelopeEventJSON(json)
}
#endif
@@ -400,151 +540,186 @@ struct NostrProtocol {
// MARK: - Private Methods
private static func createSeal(
rumor: NostrEvent,
private static func createPrivateSeal(
message: NostrEvent,
recipientPubkey: String,
senderKey: P256K.Schnorr.PrivateKey
senderKey: P256K.Schnorr.PrivateKey,
format: PrivateEnvelopeWireFormat
) throws -> NostrEvent {
let rumorJSON = try rumor.jsonString()
let encrypted = try encrypt(
plaintext: rumorJSON,
plaintext: message.jsonString(),
recipientPubkey: recipientPubkey,
senderKey: senderKey
senderKey: senderKey,
format: format,
maximumPlaintextBytes: maximumPrivateEnvelopePlaintextBytes
)
let seal = NostrEvent(
pubkey: Data(senderKey.xonly.bytes).hexEncodedString(),
createdAt: randomizedTimestamp(),
kind: .seal,
createdAt: randomizedPastTimestamp(),
kind: format.sealKind,
tags: [],
content: encrypted
)
// Sign the seal with the sender's Schnorr private key
return try seal.sign(with: senderKey)
}
private static func createGiftWrap(
seal: NostrEvent,
recipientPubkey: String
) throws -> NostrEvent {
let sealJSON = try seal.jsonString()
// Create new ephemeral key for gift wrap
let wrapKey = try P256K.Schnorr.PrivateKey()
// Creating gift wrap with ephemeral key
// Encrypt the seal with the new ephemeral key (not the seal's key)
private static func createPrivateEnvelopeEvent(
seal: NostrEvent,
recipientPubkey: String,
format: PrivateEnvelopeWireFormat
) throws -> NostrEvent {
// A fresh signing/encryption key for every public envelope keeps the
// stable sender identity inside ciphertext.
let envelopeKey = try P256K.Schnorr.PrivateKey()
let encrypted = try encrypt(
plaintext: sealJSON,
plaintext: seal.jsonString(),
recipientPubkey: recipientPubkey,
senderKey: wrapKey // Use the gift wrap ephemeral key
senderKey: envelopeKey,
format: format,
maximumPlaintextBytes: maximumPrivateEnvelopeSealPlaintextBytes
)
let giftWrap = NostrEvent(
pubkey: Data(wrapKey.xonly.bytes).hexEncodedString(),
createdAt: randomizedTimestamp(),
kind: .giftWrap,
tags: [["p", recipientPubkey]], // Tag recipient
let envelope = NostrEvent(
pubkey: Data(envelopeKey.xonly.bytes).hexEncodedString(),
createdAt: randomizedPastTimestamp(),
kind: format.envelopeKind,
tags: [["p", recipientPubkey]],
content: encrypted
)
// Sign the gift wrap with the wrap Schnorr private key
return try giftWrap.sign(with: wrapKey)
return try envelope.sign(with: envelopeKey)
}
private static func unwrapGiftWrap(
giftWrap: NostrEvent,
recipientKey: P256K.Schnorr.PrivateKey
) throws -> NostrEvent {
// Unwrapping gift wrap
let decrypted = try decrypt(
ciphertext: giftWrap.content,
senderPubkey: giftWrap.pubkey,
recipientKey: recipientKey
)
guard let data = decrypted.data(using: .utf8),
let sealDict = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
private static func decodePrivateEnvelopeLayers(
envelope: NostrEvent,
recipientIdentity: NostrIdentity
) throws -> (seal: NostrEvent, message: NostrEvent) {
guard envelope.content.utf8.count <= maximumPrivateEnvelopeCiphertextBytes else {
throw NostrError.invalidCiphertext
}
guard let format = PrivateEnvelopeWireFormat(outerKind: envelope.kind),
envelope.tags == [["p", recipientIdentity.publicKeyHex]],
envelope.isValidSignature() else {
throw NostrError.invalidEvent
}
let seal = try NostrEvent(from: sealDict)
// Unwrapped seal
return seal
}
private static func openSeal(
seal: NostrEvent,
recipientKey: P256K.Schnorr.PrivateKey
) throws -> NostrEvent {
let decrypted = try decrypt(
let recipientKey = try recipientIdentity.schnorrSigningKey()
let sealJSON = try decrypt(
ciphertext: envelope.content,
senderPubkey: envelope.pubkey,
recipientKey: recipientKey,
format: format,
maximumPlaintextBytes: maximumPrivateEnvelopeSealPlaintextBytes
)
let seal = try decodePrivateEnvelopeEventJSON(
sealJSON,
maximumBytes: maximumPrivateEnvelopeSealPlaintextBytes
)
guard seal.kind == format.sealKind.rawValue,
seal.tags.isEmpty,
seal.isValidSignature() else {
throw NostrError.invalidEvent
}
let messageJSON = try decrypt(
ciphertext: seal.content,
senderPubkey: seal.pubkey,
recipientKey: recipientKey
recipientKey: recipientKey,
format: format,
maximumPlaintextBytes: maximumPrivateEnvelopePlaintextBytes
)
guard let data = decrypted.data(using: .utf8),
let rumorDict = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
let message = try decodePrivateEnvelopeEventJSON(
messageJSON,
maximumBytes: maximumPrivateEnvelopePlaintextBytes
)
// The inner message is intentionally unsigned; sender authentication
// comes from the seal. Bind its claimed sender and custom kind to that
// authenticated layer before exposing content.
guard message.kind == format.messageKind.rawValue,
message.tags.isEmpty,
message.sig == nil,
seal.pubkey == message.pubkey else {
throw NostrError.invalidEvent
}
return try NostrEvent(from: rumorDict)
return (seal, message)
}
// MARK: - Encryption (NIP-44 v2)
private static func decodePrivateEnvelopeEventJSON(
_ json: String,
maximumBytes: Int = maximumPrivateEnvelopePlaintextBytes
) throws -> NostrEvent {
// Check UTF-8 size before allocating Data or invoking the general JSON
// parser. `decrypt` enforces the same cap on authenticated bytes; this
// local guard keeps the parser boundary explicit and independently
// testable.
guard json.utf8.count <= maximumBytes else {
throw NostrError.invalidCiphertext
}
guard let data = json.data(using: .utf8),
let dictionary = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
throw NostrError.invalidEvent
}
return try NostrEvent(from: dictionary)
}
// MARK: - BitChat private-envelope encryption
private static func encrypt(
plaintext: String,
recipientPubkey: String,
senderKey: P256K.Schnorr.PrivateKey
senderKey: P256K.Schnorr.PrivateKey,
format: PrivateEnvelopeWireFormat,
maximumPlaintextBytes: Int
) throws -> String {
guard let recipientPubkeyData = Data(hexString: recipientPubkey) else {
throw NostrError.invalidPublicKey
}
// Encrypting message (NIP-44 v2: XChaCha20-Poly1305, versioned)
// Derive shared secret
let sharedSecret = try deriveSharedSecret(
privateKey: senderKey,
publicKey: recipientPubkeyData
)
// Derive NIP-44 v2 symmetric key (HKDF-SHA256 with label in info)
let key = try deriveNIP44V2Key(from: sharedSecret)
// 24-byte random nonce for XChaCha20-Poly1305
let key = derivePrivateEnvelopeKey(from: sharedSecret, format: format)
var nonce24 = Data(count: 24)
_ = nonce24.withUnsafeMutableBytes { ptr in
let randomStatus = nonce24.withUnsafeMutableBytes { ptr in
SecRandomCopyBytes(kSecRandomDefault, 24, ptr.baseAddress!)
}
let pt = Data(plaintext.utf8)
let sealed = try XChaCha20Poly1305Compat.seal(plaintext: pt, key: key, nonce24: nonce24)
// v2: base64url(nonce24 || ciphertext || tag)
guard randomStatus == errSecSuccess else {
throw NostrError.cryptographicFailure
}
let plaintextData = Data(plaintext.utf8)
guard plaintextData.count <= maximumPlaintextBytes else {
throw NostrError.invalidCiphertext
}
let sealed = try XChaCha20Poly1305Compat.seal(
plaintext: plaintextData,
key: key,
nonce24: nonce24
)
var combined = Data()
combined.append(nonce24)
combined.append(sealed.ciphertext)
combined.append(sealed.tag)
return "v2:" + Base64URLCoding.encode(combined)
return format.contentPrefix + Base64URLCoding.encode(combined)
}
private static func decrypt(
ciphertext: String,
senderPubkey: String,
recipientKey: P256K.Schnorr.PrivateKey
recipientKey: P256K.Schnorr.PrivateKey,
format: PrivateEnvelopeWireFormat,
maximumPlaintextBytes: Int
) throws -> String {
// Expect NIP-44 v2 format
guard ciphertext.hasPrefix("v2:") else { throw NostrError.invalidCiphertext }
let encoded = String(ciphertext.dropFirst(3))
guard ciphertext.utf8.count <= maximumPrivateEnvelopeCiphertextBytes,
ciphertext.hasPrefix(format.contentPrefix) else {
throw NostrError.invalidCiphertext
}
let encoded = String(ciphertext.dropFirst(format.contentPrefix.count))
guard let data = Base64URLCoding.decode(encoded),
data.count > (24 + 16),
let senderPubkeyData = Data(hexString: senderPubkey) else {
@@ -554,33 +729,40 @@ struct NostrProtocol {
let nonce24 = data.prefix(24)
let rest = data.dropFirst(24)
let tag = rest.suffix(16)
let ct = rest.dropLast(16)
let ciphertextBytes = rest.dropLast(16)
// Try decryption with even-Y then odd-Y when sender pubkey is x-only
func attemptDecrypt(using pubKeyData: Data) throws -> Data {
let ss = try deriveSharedSecret(privateKey: recipientKey, publicKey: pubKeyData)
let key = try deriveNIP44V2Key(from: ss)
func attemptDecrypt(using publicKeyData: Data) throws -> Data {
let sharedSecret = try deriveSharedSecret(
privateKey: recipientKey,
publicKey: publicKeyData
)
let key = derivePrivateEnvelopeKey(from: sharedSecret, format: format)
return try XChaCha20Poly1305Compat.open(
ciphertext: Data(ct),
ciphertext: Data(ciphertextBytes),
tag: Data(tag),
key: key,
nonce24: Data(nonce24)
)
}
// If 32 bytes (x-only) try both parities, otherwise single try
let plaintext: Data
if senderPubkeyData.count == 32 {
let even = Data([0x02]) + senderPubkeyData
if let pt = try? attemptDecrypt(using: even) {
return String(data: pt, encoding: .utf8) ?? ""
let evenKey = Data([0x02]) + senderPubkeyData
if let opened = try? attemptDecrypt(using: evenKey) {
plaintext = opened
} else {
let oddKey = Data([0x03]) + senderPubkeyData
plaintext = try attemptDecrypt(using: oddKey)
}
let odd = Data([0x03]) + senderPubkeyData
let pt = try attemptDecrypt(using: odd)
return String(data: pt, encoding: .utf8) ?? ""
} else {
let pt = try attemptDecrypt(using: senderPubkeyData)
return String(data: pt, encoding: .utf8) ?? ""
plaintext = try attemptDecrypt(using: senderPubkeyData)
}
guard plaintext.count <= maximumPlaintextBytes,
let decoded = String(data: plaintext, encoding: .utf8) else {
throw NostrError.invalidCiphertext
}
return decoded
}
private static func deriveSharedSecret(
@@ -640,29 +822,17 @@ struct NostrProtocol {
let sharedSecretData = sharedSecret.withUnsafeBytes { Data($0) }
// ECDH shared secret derived
// Return raw ECDH shared secret; HKDF is applied by deriveNIP44V2Key
// Return raw ECDH shared secret; the wire-format-specific HKDF is
// applied by derivePrivateEnvelopeKey.
return sharedSecretData
}
private static func randomizedTimestamp() -> Date {
// Add random offset to current time for privacy
// This prevents timing correlation attacks while the actual message timestamp
// is preserved in the encrypted rumor
let offset = TimeInterval.random(in: -900...900) // +/- 15 minutes
let now = Date()
let randomized = now.addingTimeInterval(offset)
// Log with explicit UTC and local time for debugging
let formatter = DateFormatter()
//
formatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
formatter.timeZone = TimeZone(abbreviation: "UTC")
formatter.timeZone = TimeZone.current
// Timestamp randomized for privacy
return randomized
private static func randomizedPastTimestamp() -> Date {
// Keep public timestamps in the past: future-dated events are rejected
// by some relays. The actual message timestamp remains encrypted.
Date().addingTimeInterval(
-TimeInterval.random(in: 0...TransportConfig.nostrPrivateEnvelopeTimestampFuzzSeconds)
)
}
}
@@ -775,16 +945,20 @@ enum NostrError: Error {
case invalidPublicKey
case invalidEvent
case invalidCiphertext
case cryptographicFailure
}
// MARK: - NIP-44 v2 helpers (XChaCha20-Poly1305)
// MARK: - BitChat private-envelope key derivation
private extension NostrProtocol {
static func deriveNIP44V2Key(from sharedSecretData: Data) throws -> Data {
private static func derivePrivateEnvelopeKey(
from sharedSecretData: Data,
format: PrivateEnvelopeWireFormat
) -> Data {
let derivedKey = HKDF<CryptoKit.SHA256>.deriveKey(
inputKeyMaterial: SymmetricKey(data: sharedSecretData),
salt: Data(),
info: Data("nip44-v2".utf8),
salt: format.hkdfSalt,
info: format.hkdfInfo,
outputByteCount: 32
)
return derivedKey.withUnsafeBytes { Data($0) }