diff --git a/bitchat/Nostr/XChaCha20Poly1305Compat.swift b/bitchat/Nostr/XChaCha20Poly1305Compat.swift index 44f1ca97..13794508 100644 --- a/bitchat/Nostr/XChaCha20Poly1305Compat.swift +++ b/bitchat/Nostr/XChaCha20Poly1305Compat.swift @@ -5,16 +5,27 @@ import CryptoKit /// Implements HChaCha20 to derive a subkey and reduces the 24-byte nonce to a 12-byte nonce /// as per XChaCha20 construction. enum XChaCha20Poly1305Compat { + + /// Errors that can occur during XChaCha20-Poly1305 operations + enum Error: Swift.Error { + case invalidKeyLength(expected: Int, got: Int) + case invalidNonceLength(expected: Int, got: Int) + } + struct SealBox { let ciphertext: Data let tag: Data } static func seal(plaintext: Data, key: Data, nonce24: Data, aad: Data? = nil) throws -> SealBox { - precondition(key.count == 32, "XChaCha20 key must be 32 bytes") - precondition(nonce24.count == 24, "XChaCha20 nonce must be 24 bytes") + guard key.count == 32 else { + throw Error.invalidKeyLength(expected: 32, got: key.count) + } + guard nonce24.count == 24 else { + throw Error.invalidNonceLength(expected: 24, got: nonce24.count) + } - let subkey = hchacha20(key: key, nonce16: nonce24.prefix(16)) + let subkey = try hchacha20(key: key, nonce16: Data(nonce24.prefix(16))) let nonce12 = derive12ByteNonce(from24: nonce24) let chachaKey = SymmetricKey(data: subkey) let nonce = try ChaChaPoly.Nonce(data: nonce12) @@ -23,10 +34,14 @@ enum XChaCha20Poly1305Compat { } static func open(ciphertext: Data, tag: Data, key: Data, nonce24: Data, aad: Data? = nil) throws -> Data { - precondition(key.count == 32, "XChaCha20 key must be 32 bytes") - precondition(nonce24.count == 24, "XChaCha20 nonce must be 24 bytes") + guard key.count == 32 else { + throw Error.invalidKeyLength(expected: 32, got: key.count) + } + guard nonce24.count == 24 else { + throw Error.invalidNonceLength(expected: 24, got: nonce24.count) + } - let subkey = hchacha20(key: key, nonce16: nonce24.prefix(16)) + let subkey = try hchacha20(key: key, nonce16: Data(nonce24.prefix(16))) let nonce12 = derive12ByteNonce(from24: nonce24) let chachaKey = SymmetricKey(data: subkey) let box = try ChaChaPoly.SealedBox(nonce: ChaChaPoly.Nonce(data: nonce12), ciphertext: ciphertext, tag: tag) @@ -43,10 +58,14 @@ enum XChaCha20Poly1305Compat { return out } - private static func hchacha20(key: Data, nonce16: Data) -> Data { + private static func hchacha20(key: Data, nonce16: Data) throws -> Data { // HChaCha20 based on the original ChaCha20 core with a 16-byte nonce. - precondition(key.count == 32) - precondition(nonce16.count == 16) + guard key.count == 32 else { + throw Error.invalidKeyLength(expected: 32, got: key.count) + } + guard nonce16.count == 16 else { + throw Error.invalidNonceLength(expected: 16, got: nonce16.count) + } // Constants "expand 32-byte k" var state: [UInt32] = [ diff --git a/bitchatTests/XChaCha20Poly1305CompatTests.swift b/bitchatTests/XChaCha20Poly1305CompatTests.swift new file mode 100644 index 00000000..9b607a9b --- /dev/null +++ b/bitchatTests/XChaCha20Poly1305CompatTests.swift @@ -0,0 +1,222 @@ +// +// XChaCha20Poly1305CompatTests.swift +// bitchatTests +// +// Tests for XChaCha20-Poly1305 encryption with proper error handling. +// This is free and unencumbered software released into the public domain. +// + +import Testing +import struct Foundation.Data +@testable import bitchat + +struct XChaCha20Poly1305CompatTests { + + @Test func sealAndOpenRoundtrip() throws { + let plaintext = "Hello, XChaCha20-Poly1305!".data(using: .utf8)! + let key = Data(repeating: 0x42, count: 32) + let nonce = Data(repeating: 0x24, count: 24) + + let sealed = try XChaCha20Poly1305Compat.seal(plaintext: plaintext, key: key, nonce24: nonce) + let decrypted = try XChaCha20Poly1305Compat.open( + ciphertext: sealed.ciphertext, + tag: sealed.tag, + key: key, + nonce24: nonce + ) + + #expect(decrypted == plaintext) + } + + @Test func sealAndOpenWithAAD() throws { + let plaintext = "Secret message".data(using: .utf8)! + let key = Data(repeating: 0xAB, count: 32) + let nonce = Data(repeating: 0xCD, count: 24) + let aad = "additional authenticated data".data(using: .utf8)! + + let sealed = try XChaCha20Poly1305Compat.seal(plaintext: plaintext, key: key, nonce24: nonce, aad: aad) + let decrypted = try XChaCha20Poly1305Compat.open( + ciphertext: sealed.ciphertext, + tag: sealed.tag, + key: key, + nonce24: nonce, + aad: aad + ) + + #expect(decrypted == plaintext) + } + + @Test func sealProducesDifferentCiphertextWithDifferentNonces() throws { + let plaintext = "Same plaintext".data(using: .utf8)! + let key = Data(repeating: 0x42, count: 32) + let nonce1 = Data(repeating: 0x01, count: 24) + let nonce2 = Data(repeating: 0x02, count: 24) + + let sealed1 = try XChaCha20Poly1305Compat.seal(plaintext: plaintext, key: key, nonce24: nonce1) + let sealed2 = try XChaCha20Poly1305Compat.seal(plaintext: plaintext, key: key, nonce24: nonce2) + + #expect(sealed1.ciphertext != sealed2.ciphertext) + } + + @Test func sealThrowsOnShortKey() { + let plaintext = "Test".data(using: .utf8)! + let shortKey = Data(repeating: 0x42, count: 16) + let nonce = Data(repeating: 0x24, count: 24) + + var didThrow = false + do { + _ = try XChaCha20Poly1305Compat.seal(plaintext: plaintext, key: shortKey, nonce24: nonce) + } catch { + didThrow = true + } + #expect(didThrow) + } + + @Test func sealThrowsOnLongKey() { + let plaintext = "Test".data(using: .utf8)! + let longKey = Data(repeating: 0x42, count: 64) + let nonce = Data(repeating: 0x24, count: 24) + + var didThrow = false + do { + _ = try XChaCha20Poly1305Compat.seal(plaintext: plaintext, key: longKey, nonce24: nonce) + } catch { + didThrow = true + } + #expect(didThrow) + } + + @Test func sealThrowsOnEmptyKey() { + let plaintext = "Test".data(using: .utf8)! + let emptyKey = Data() + let nonce = Data(repeating: 0x24, count: 24) + + var didThrow = false + do { + _ = try XChaCha20Poly1305Compat.seal(plaintext: plaintext, key: emptyKey, nonce24: nonce) + } catch { + didThrow = true + } + #expect(didThrow) + } + + @Test func openThrowsOnInvalidKeyLength() { + let ciphertext = Data(repeating: 0x00, count: 16) + let tag = Data(repeating: 0x00, count: 16) + let shortKey = Data(repeating: 0x42, count: 31) + let nonce = Data(repeating: 0x24, count: 24) + + var didThrow = false + do { + _ = try XChaCha20Poly1305Compat.open(ciphertext: ciphertext, tag: tag, key: shortKey, nonce24: nonce) + } catch { + didThrow = true + } + #expect(didThrow) + } + + @Test func sealThrowsOnShortNonce() { + let plaintext = "Test".data(using: .utf8)! + let key = Data(repeating: 0x42, count: 32) + let shortNonce = Data(repeating: 0x24, count: 12) + + var didThrow = false + do { + _ = try XChaCha20Poly1305Compat.seal(plaintext: plaintext, key: key, nonce24: shortNonce) + } catch { + didThrow = true + } + #expect(didThrow) + } + + @Test func sealThrowsOnLongNonce() { + let plaintext = "Test".data(using: .utf8)! + let key = Data(repeating: 0x42, count: 32) + let longNonce = Data(repeating: 0x24, count: 32) + + var didThrow = false + do { + _ = try XChaCha20Poly1305Compat.seal(plaintext: plaintext, key: key, nonce24: longNonce) + } catch { + didThrow = true + } + #expect(didThrow) + } + + @Test func sealThrowsOnEmptyNonce() { + let plaintext = "Test".data(using: .utf8)! + let key = Data(repeating: 0x42, count: 32) + let emptyNonce = Data() + + var didThrow = false + do { + _ = try XChaCha20Poly1305Compat.seal(plaintext: plaintext, key: key, nonce24: emptyNonce) + } catch { + didThrow = true + } + #expect(didThrow) + } + + @Test func openThrowsOnInvalidNonceLength() { + let ciphertext = Data(repeating: 0x00, count: 16) + let tag = Data(repeating: 0x00, count: 16) + let key = Data(repeating: 0x42, count: 32) + let shortNonce = Data(repeating: 0x24, count: 23) + + var didThrow = false + do { + _ = try XChaCha20Poly1305Compat.open(ciphertext: ciphertext, tag: tag, key: key, nonce24: shortNonce) + } catch { + didThrow = true + } + #expect(didThrow) + } + + @Test func openFailsWithWrongKey() throws { + let plaintext = "Secret".data(using: .utf8)! + let correctKey = Data(repeating: 0x42, count: 32) + let wrongKey = Data(repeating: 0x43, count: 32) + let nonce = Data(repeating: 0x24, count: 24) + + let sealed = try XChaCha20Poly1305Compat.seal(plaintext: plaintext, key: correctKey, nonce24: nonce) + + var didThrow = false + do { + _ = try XChaCha20Poly1305Compat.open( + ciphertext: sealed.ciphertext, + tag: sealed.tag, + key: wrongKey, + nonce24: nonce + ) + } catch { + didThrow = true + } + #expect(didThrow) + } + + @Test func openFailsWithTamperedCiphertext() throws { + let plaintext = "Secret".data(using: .utf8)! + let key = Data(repeating: 0x42, count: 32) + let nonce = Data(repeating: 0x24, count: 24) + + let sealed = try XChaCha20Poly1305Compat.seal(plaintext: plaintext, key: key, nonce24: nonce) + + // Create tampered ciphertext by changing first byte + var tamperedBytes = [UInt8](sealed.ciphertext) + tamperedBytes[0] = tamperedBytes[0] ^ 0xFF + let tampered = Data(tamperedBytes) + + var didThrow = false + do { + _ = try XChaCha20Poly1305Compat.open( + ciphertext: tampered, + tag: sealed.tag, + key: key, + nonce24: nonce + ) + } catch { + didThrow = true + } + #expect(didThrow) + } +}