Add NIP-44 style padding support to Nostr DM encryption

The bespoke "v2" DM envelope (XChaCha20-Poly1305 over the raw UTF-8
rumor JSON) leaks exact plaintext length to relays. Add NIP-44 v2
style payload framing: a 2-byte big-endian length prefix followed by
zero padding to NIP-44's calc_padded_len buckets (32-byte minimum,
power-of-two-derived chunks), carried in a new "v3:" envelope that
reuses the existing key derivation and AEAD.

Compatibility: deployed clients hard-reject any envelope that does not
start with "v2:", and the v2 payload is raw JSON, so padded payloads
cannot be introduced inside v2 either. This lands phase 1 of a
two-phase rollout: decrypt accepts both v2 (unpadded) and v3 (padded),
while encrypt keeps emitting v2, gated by
NostrProtocol.sendPaddedEnvelope (defaults to false with the rollout
plan documented inline). Flip the flag once v3-capable clients are
widely deployed.

Unpad validates that the claimed length's bucket exactly matches the
authenticated payload size, so a tampered or malformed length prefix
fails closed. Tests cover pad/unpad round-trips, the NIP-44 bucket
vectors, v3 round-trip, legacy v2 decrypt, default-envelope pinning,
and tampered length prefixes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jack
2026-07-01 23:30:00 +02:00
co-authored by Claude Fable 5
parent 3f6dc7dd36
commit 7200b3de2a
2 changed files with 270 additions and 25 deletions
+109 -17
View File
@@ -328,26 +328,46 @@ struct NostrProtocol {
return try NostrEvent(from: rumorDict)
}
// MARK: - Encryption (NIP-44 v2)
// MARK: - Encryption (NIP-44 v2/v3)
private static func encrypt(
/// Whether outgoing DMs use the padded "v3:" envelope.
///
/// TWO-PHASE ROLLOUT DO NOT FLIP YET. Deployed clients hard-reject any
/// ciphertext that does not start with "v2:" (`decrypt` below, as shipped,
/// throws `invalidCiphertext` on unknown version prefixes), and the "v2:"
/// payload is the raw UTF-8 rumor JSON, so a padded payload cannot be
/// smuggled inside "v2:" without breaking old receivers either. Enabling
/// this today would strand every client in the field.
///
/// Phase 1 (this change): ship decrypt-side support for "v3:" everywhere.
/// Phase 2 (future release, once phase-1 clients are widely deployed):
/// set this to true so outgoing DMs stop leaking plaintext length to
/// relays.
static let sendPaddedEnvelope = false
/// Internal (rather than private) so tests can exercise both envelope
/// versions directly.
static func encrypt(
plaintext: String,
recipientPubkey: String,
senderKey: P256K.Schnorr.PrivateKey
senderKey: P256K.Schnorr.PrivateKey,
padded: Bool = NostrProtocol.sendPaddedEnvelope
) throws -> String {
guard let recipientPubkeyData = Data(hexString: recipientPubkey) else {
throw NostrError.invalidPublicKey
}
// Encrypting message (NIP-44 v2: XChaCha20-Poly1305, versioned)
// Encrypting message (XChaCha20-Poly1305, versioned envelope)
// Derive shared secret
let sharedSecret = try deriveSharedSecret(
privateKey: senderKey,
publicKey: recipientPubkeyData
)
// Derive NIP-44 v2 symmetric key (HKDF-SHA256 with label in info)
// Derive NIP-44 v2 symmetric key (HKDF-SHA256 with label in info).
// The v3 envelope deliberately reuses the same key derivation; it only
// changes the payload framing (length prefix + padding).
let key = try deriveNIP44V2Key(from: sharedSecret)
// 24-byte random nonce for XChaCha20-Poly1305
@@ -356,24 +376,37 @@ struct NostrProtocol {
SecRandomCopyBytes(kSecRandomDefault, 24, ptr.baseAddress!)
}
// v2 payload: raw UTF-8 plaintext (length leaks to relays)
// v3 payload: NIP-44 style [2-byte BE length][plaintext][zero padding]
let pt = Data(plaintext.utf8)
let sealed = try XChaCha20Poly1305Compat.seal(plaintext: pt, key: key, nonce24: nonce24)
let payload = padded ? try NIP44Padding.pad(pt) : pt
let sealed = try XChaCha20Poly1305Compat.seal(plaintext: payload, key: key, nonce24: nonce24)
// v2: base64url(nonce24 || ciphertext || tag)
// version prefix + base64url(nonce24 || ciphertext || tag)
var combined = Data()
combined.append(nonce24)
combined.append(sealed.ciphertext)
combined.append(sealed.tag)
return "v2:" + Base64URLCoding.encode(combined)
return (padded ? "v3:" : "v2:") + Base64URLCoding.encode(combined)
}
private static func decrypt(
/// Internal (rather than private) so tests can exercise both envelope
/// versions directly.
static func decrypt(
ciphertext: String,
senderPubkey: String,
recipientKey: P256K.Schnorr.PrivateKey
) throws -> String {
// Expect NIP-44 v2 format
guard ciphertext.hasPrefix("v2:") else { throw NostrError.invalidCiphertext }
// Accept both the legacy unpadded "v2:" envelope and the padded "v3:"
// envelope (see `sendPaddedEnvelope` for the rollout plan).
let isPadded: Bool
if ciphertext.hasPrefix("v2:") {
isPadded = false
} else if ciphertext.hasPrefix("v3:") {
isPadded = true
} else {
throw NostrError.invalidCiphertext
}
let encoded = String(ciphertext.dropFirst(3))
guard let data = Base64URLCoding.decode(encoded),
data.count > (24 + 16),
@@ -399,18 +432,23 @@ struct NostrProtocol {
}
// If 32 bytes (x-only) try both parities, otherwise single try
let payload: Data
if senderPubkeyData.count == 32 {
let even = Data([0x02]) + senderPubkeyData
if let pt = try? attemptDecrypt(using: even) {
return String(data: pt, encoding: .utf8) ?? ""
payload = pt
} else {
let odd = Data([0x03]) + senderPubkeyData
payload = try attemptDecrypt(using: odd)
}
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) ?? ""
payload = try attemptDecrypt(using: senderPubkeyData)
}
// The AEAD tag has already authenticated the payload; unpadding
// failures here mean a malformed sender, not a wrong key.
let plaintextData = isPadded ? try NIP44Padding.unpad(payload) : payload
return String(data: plaintextData, encoding: .utf8) ?? ""
}
private static func deriveSharedSecret(
@@ -641,6 +679,60 @@ enum NostrError: Error {
case encryptionFailed
}
// MARK: - NIP-44 style padding (v3 envelope payload framing)
/// Payload framing for the padded "v3:" envelope, modeled on NIP-44 v2:
/// `[2-byte big-endian plaintext length][plaintext][zero padding]`, where the
/// total is padded to `paddedLength(for:)` power-of-two-derived buckets with
/// a 32-byte minimum so ciphertext length no longer reveals exact plaintext
/// length to relays.
enum NIP44Padding {
static let minPaddedLength = 32
static let maxPlaintextLength = 65535
/// NIP-44's calc_padded_len: pad to 32 bytes minimum, then to a chunk
/// granularity of max(32, nextPowerOfTwo/8).
static func paddedLength(for unpaddedLength: Int) -> Int {
guard unpaddedLength > minPaddedLength else { return minPaddedLength }
// Smallest power of two strictly greater than (unpaddedLength - 1).
let nextPower = 1 << (Int.bitWidth - (unpaddedLength - 1).leadingZeroBitCount)
let chunk = nextPower <= 256 ? 32 : nextPower / 8
return chunk * ((unpaddedLength - 1) / chunk + 1)
}
/// Prefix plaintext with its 2-byte big-endian length and zero-pad to the
/// bucketed length. Rejects empty plaintexts and plaintexts that do not
/// fit the 16-bit length prefix.
static func pad(_ plaintext: Data) throws -> Data {
let length = plaintext.count
guard length >= 1, length <= maxPlaintextLength else {
throw NostrError.encryptionFailed
}
let padded = paddedLength(for: length)
var result = Data(capacity: 2 + padded)
result.append(UInt8(length >> 8))
result.append(UInt8(length & 0xFF))
result.append(plaintext)
result.append(Data(count: padded - length))
return result
}
/// Read the 2-byte length prefix, validate the total padded size matches
/// it exactly, and return the plaintext. Throws on any inconsistency so a
/// malformed (already-authenticated) payload can never over- or
/// under-read.
static func unpad(_ padded: Data) throws -> Data {
guard padded.count >= 2 else { throw NostrError.invalidCiphertext }
let start = padded.startIndex
let length = Int(padded[start]) << 8 | Int(padded[start + 1])
guard length >= 1,
padded.count == 2 + paddedLength(for: length) else {
throw NostrError.invalidCiphertext
}
return padded.subdata(in: (start + 2)..<(start + 2 + length))
}
}
// MARK: - NIP-44 v2 helpers (XChaCha20-Poly1305)
private extension NostrProtocol {
+153
View File
@@ -289,6 +289,159 @@ struct NostrProtocolTests {
#expect(object["limit"] as? Int == 42)
}
// MARK: - Padding (v3 envelope)
@Test func paddedLengthMatchesNIP44Buckets() {
// Vectors from the NIP-44 reference test suite (calc_padded_len).
let vectors: [(Int, Int)] = [
(1, 32), (16, 32), (32, 32), (33, 64), (37, 64), (45, 64), (49, 64),
(64, 64), (65, 96), (100, 128), (111, 128), (200, 224), (250, 256),
(320, 320), (383, 384), (384, 384), (400, 448), (500, 512),
(512, 512), (515, 640), (700, 768), (800, 896), (900, 1024),
(1020, 1024), (65535, 65536)
]
for (unpadded, expected) in vectors {
#expect(
NIP44Padding.paddedLength(for: unpadded) == expected,
"paddedLength(for: \(unpadded)) should be \(expected)"
)
}
}
@Test func padUnpadRoundTrip() throws {
for length in [1, 2, 31, 32, 33, 100, 320, 1020, 4096, 65535] {
let plaintext = Data((0..<length).map { _ in UInt8.random(in: .min ... .max) })
let padded = try NIP44Padding.pad(plaintext)
#expect(padded.count == 2 + NIP44Padding.paddedLength(for: length))
let unpadded = try NIP44Padding.unpad(padded)
#expect(unpadded == plaintext)
}
}
@Test func padHidesExactLengthWithinBucket() throws {
// Two plaintexts of different length in the same bucket must produce
// identically sized padded payloads (and thus ciphertexts).
let short = try NIP44Padding.pad(Data(repeating: 0x41, count: 65))
let long = try NIP44Padding.pad(Data(repeating: 0x42, count: 96))
#expect(short.count == long.count)
}
@Test func padRejectsOutOfRangePlaintexts() {
#expect(throws: (any Error).self) { try NIP44Padding.pad(Data()) }
#expect(throws: (any Error).self) { try NIP44Padding.pad(Data(count: 65536)) }
}
@Test func unpadRejectsTamperedLengthPrefix() throws {
var padded = try NIP44Padding.pad(Data(repeating: 0x41, count: 40))
// Claimed length larger than the actual payload
var tooLong = padded
tooLong[tooLong.startIndex] = 0xFF
tooLong[tooLong.startIndex + 1] = 0xFF
#expect(throws: NostrError.invalidCiphertext) { try NIP44Padding.unpad(tooLong) }
// Claimed length of zero
var zero = padded
zero[zero.startIndex] = 0x00
zero[zero.startIndex + 1] = 0x00
#expect(throws: NostrError.invalidCiphertext) { try NIP44Padding.unpad(zero) }
// Claimed length whose bucket does not match the payload size
// (payload is bucket 64; a claimed length of 20 expects bucket 32)
var wrongBucket = padded
wrongBucket[wrongBucket.startIndex] = 0x00
wrongBucket[wrongBucket.startIndex + 1] = 0x14
#expect(throws: NostrError.invalidCiphertext) { try NIP44Padding.unpad(wrongBucket) }
// Truncated payloads
#expect(throws: NostrError.invalidCiphertext) { try NIP44Padding.unpad(Data()) }
#expect(throws: NostrError.invalidCiphertext) { try NIP44Padding.unpad(Data([0x00])) }
padded.removeLast()
#expect(throws: NostrError.invalidCiphertext) { try NIP44Padding.unpad(padded) }
// Works on Data slices with non-zero startIndex
let sliced = try (Data([0xAB]) + NIP44Padding.pad(Data(repeating: 0x41, count: 40))).dropFirst()
#expect(try NIP44Padding.unpad(sliced) == Data(repeating: 0x41, count: 40))
}
@Test func paddedEnvelopeRoundTrip_v3() throws {
let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate()
let plaintext = "padded envelope test"
let ciphertext = try NostrProtocol.encrypt(
plaintext: plaintext,
recipientPubkey: recipient.publicKeyHex,
senderKey: sender.schnorrSigningKey(),
padded: true
)
#expect(ciphertext.hasPrefix("v3:"))
let decrypted = try NostrProtocol.decrypt(
ciphertext: ciphertext,
senderPubkey: sender.publicKeyHex,
recipientKey: recipient.schnorrSigningKey()
)
#expect(decrypted == plaintext)
}
@Test func legacyUnpaddedEnvelopeStillDecrypts_v2() throws {
let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate()
let plaintext = "legacy v2 envelope"
// What deployed clients send today.
let ciphertext = try NostrProtocol.encrypt(
plaintext: plaintext,
recipientPubkey: recipient.publicKeyHex,
senderKey: sender.schnorrSigningKey(),
padded: false
)
#expect(ciphertext.hasPrefix("v2:"))
let decrypted = try NostrProtocol.decrypt(
ciphertext: ciphertext,
senderPubkey: sender.publicKeyHex,
recipientKey: recipient.schnorrSigningKey()
)
#expect(decrypted == plaintext)
}
@Test func outgoingMessagesStillUseV2UntilRolloutFlagFlips() throws {
// Deployed clients reject anything that is not "v2:", so the padded
// envelope must stay off by default until decrypt-side support is
// widely shipped (see NostrProtocol.sendPaddedEnvelope).
#expect(NostrProtocol.sendPaddedEnvelope == false)
let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate()
let giftWrap = try NostrProtocol.createPrivateMessage(
content: "default envelope",
recipientPubkey: recipient.publicKeyHex,
senderIdentity: sender
)
#expect(giftWrap.content.hasPrefix("v2:"))
}
@Test func decryptRejectsUnknownEnvelopeVersion() throws {
let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate()
let ciphertext = try NostrProtocol.encrypt(
plaintext: "test",
recipientPubkey: recipient.publicKeyHex,
senderKey: sender.schnorrSigningKey(),
padded: false
)
let mutated = "v9:" + ciphertext.dropFirst(3)
#expect(throws: NostrError.invalidCiphertext) {
_ = try NostrProtocol.decrypt(
ciphertext: mutated,
senderPubkey: sender.publicKeyHex,
recipientKey: recipient.schnorrSigningKey()
)
}
}
// MARK: - Helpers
private static func base64URLDecode(_ s: String) -> Data? {
var str = s.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")