From bf712a0610fbb6ce154efb939ab043599863bff6 Mon Sep 17 00:00:00 2001 From: Islam <2553451+qalandarov@users.noreply.github.com> Date: Tue, 30 Sep 2025 11:49:36 +0100 Subject: [PATCH] Refactor `peerID` - 1/n: Add `PeerID` + Tests (#688) * Unify SHA256 hash and hex usages * Refactor `peerID` - 1/n: Add `PeerID` + Tests --- bitchat/Models/PeerID.swift | 177 ++++++++++++ bitchatTests/Utils/PeerIDTests.swift | 386 +++++++++++++++++++++++++++ 2 files changed, 563 insertions(+) create mode 100644 bitchat/Models/PeerID.swift create mode 100644 bitchatTests/Utils/PeerIDTests.swift diff --git a/bitchat/Models/PeerID.swift b/bitchat/Models/PeerID.swift new file mode 100644 index 00000000..5738c585 --- /dev/null +++ b/bitchat/Models/PeerID.swift @@ -0,0 +1,177 @@ +// +// PeerID.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Foundation + +struct PeerID: Equatable, Hashable { + enum Prefix: String, CaseIterable { + /// When no prefix is provided + case empty = "" + /// `"mesh:"` + case mesh = "mesh:" + /// `"name:"` + case name = "name:" + /// `"noise:"` (+ 64 characters hex) + case noise = "noise:" + /// `"nostr_"` (+ 16 characters hex) + case geoDM = "nostr_" + /// `"nostr:"` (+ 8 characters hex) + case geoChat = "nostr:" + } + + let prefix: Prefix + + /// Returns the actual value without any prefix + let bare: String + + /// Returns the full `id` value by combining `(prefix + bare)` + var id: String { prefix.rawValue + bare } + + // Private so the callers have to go through a convenience init + private init(prefix: Prefix, bare: any StringProtocol) { + self.prefix = prefix + self.bare = String(bare) + } +} + +// MARK: - Convenience Inits + +extension PeerID { + /// Convenience init to create GeoDM PeerID by appending `"nostr_"` to the first 16 characters of `pubKey` + init(nostr_ pubKey: String) { + self.init(prefix: .geoDM, bare: pubKey.prefix(TransportConfig.nostrConvKeyPrefixLength)) + } + + /// Convenience init to create GeoChat PeerID by appending `"nostr:"` to the first 8 characters of `pubKey` + init(nostr pubKey: String) { + self.init(prefix: .geoChat, bare: pubKey.prefix(TransportConfig.nostrShortKeyDisplayLength)) + } + + /// Convenience init to create PeerID from String/Substring by splitting it into prefix and bare parts + init(str: any StringProtocol) { + if let prefix = Prefix.allCases.first(where: { $0 != .empty && str.hasPrefix($0.rawValue) }) { + self.init(prefix: prefix, bare: String(str).dropFirst(prefix.rawValue.count)) + } else { + self.init(prefix: .empty, bare: str) + } + } + + /// Convenience init to create PeerID by converting Data to String + init?(data: Data) { + guard let str = String(data: data, encoding: .utf8) else { return nil } + self.init(str: str) + } +} + +// MARK: - Noise Public Key Helpers + +extension PeerID { + /// Derive the stable 16-hex peer ID from a Noise static public key + init(publicKey: Data) { + self.init(str: publicKey.sha256Fingerprint().prefix(16)) + } + + /// Returns a 16-hex short peer ID derived from a 64-hex Noise public key if needed + func toShort() -> PeerID { + if let noiseKey { + return PeerID(publicKey: noiseKey) + } + return self + } +} + +// MARK: - Codable + +extension PeerID: Codable { + init(from decoder: any Decoder) throws { + self.init(str: try decoder.singleValueContainer().decode(String.self)) + } + + func encode(to encoder: any Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(id) + } +} + +// MARK: - Helpers + +extension PeerID { + var isEmpty: Bool { + id.isEmpty + } + + /// Returns true if `id` starts with "`nostr:`" + var isGeoChat: Bool { + prefix == .geoChat + } + + /// Returns true if `id` starts with "`nostr_`" + var isGeoDM: Bool { + prefix == .geoDM + } + + func toPercentEncoded() -> String { + id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? id + } +} + +// MARK: - Validation + +extension PeerID { + private enum Constants { + static let maxIDLength = 64 + static let hexIDLength = 16 // 8 bytes = 16 hex chars + } + + /// Validates a peer ID from any source (short 16-hex, full 64-hex, or internal alnum/-/_ up to 64) + var isValid: Bool { + if prefix != .empty { + return PeerID(str: bare).isValid + } + + // Accept short routing IDs (exact 16-hex) or Full Noise key hex (exact 64-hex) + if isShort || isNoiseKeyHex { + return true + } + + // If length equals short or full but isn't valid hex, reject + if id.count == Constants.hexIDLength || id.count == Constants.maxIDLength { + return false + } + + // Internal format: alphanumeric + dash/underscore up to 63 (not 16 or 64) + let validCharset = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-_")) + return !id.isEmpty && + id.count < Constants.maxIDLength && + id.rangeOfCharacter(from: validCharset.inverted) == nil + } + + /// Short routing IDs (exact 16-hex) + var isShort: Bool { + id.count == Constants.hexIDLength && Data(hexString: id) != nil + } + + /// Full Noise key hex (exact 64-hex) + var isNoiseKeyHex: Bool { + noiseKey != nil + } + + /// Full Noise key (exact 64-hex) as Data + var noiseKey: Data? { + guard id.count == Constants.maxIDLength else { return nil } + return Data(hexString: id) + } +} + +// MARK: - Comparable + +extension PeerID: Comparable { + static func < (lhs: PeerID, rhs: PeerID) -> Bool { + lhs.id < rhs.id + } +} diff --git a/bitchatTests/Utils/PeerIDTests.swift b/bitchatTests/Utils/PeerIDTests.swift new file mode 100644 index 00000000..7b76cef8 --- /dev/null +++ b/bitchatTests/Utils/PeerIDTests.swift @@ -0,0 +1,386 @@ +// +// PeerIDTests.swift +// bitchatTests +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import XCTest +@testable import bitchat + +final class PeerIDTests: XCTestCase { + + private let hex16 = "0011223344556677" + private let hex64 = "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff" + + private let encoder: JSONEncoder = { + var encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + return encoder + }() + + // MARK: - Empty prefix + + func test_init_empty_prefix_with16() { + let peerID = PeerID(str: hex16) + XCTAssertEqual(peerID.id, hex16) + XCTAssertEqual(peerID.bare, hex16) + XCTAssertEqual(peerID.prefix, .empty) + } + + func test_init_empty_prefix_with64() { + let peerID = PeerID(str: hex64) + XCTAssertEqual(peerID.id, hex64) + XCTAssertEqual(peerID.bare, hex64) + XCTAssertEqual(peerID.prefix, .empty) + } + + // MARK: - Mesh prefix + + func test_init_mesh_prefix_with16() { + let str = "mesh:" + hex16 + let peerID = PeerID(str: str) + XCTAssertEqual(peerID.id, str) + XCTAssertEqual(peerID.bare, hex16) + XCTAssertEqual(peerID.prefix, .mesh) + } + + func test_init_mesh_prefix_with64() { + let str = "mesh:" + hex64 + let peerID = PeerID(str: str) + XCTAssertEqual(peerID.id, str) + XCTAssertEqual(peerID.bare, hex64) + XCTAssertEqual(peerID.prefix, .mesh) + } + + // MARK: - Name prefix + + func test_init_name_prefix() { + let str = "name:some_name" + let peerID = PeerID(str: str) + XCTAssertEqual(peerID.id, str) + XCTAssertEqual(peerID.bare, "some_name") + XCTAssertEqual(peerID.prefix, .name) + } + + // MARK: - Noise prefix + + func test_init_noise_prefix_with16() { + let str = "noise:" + hex16 + let peerID = PeerID(str: str) + XCTAssertEqual(peerID.id, str) + XCTAssertEqual(peerID.bare, hex16) + XCTAssertEqual(peerID.prefix, .noise) + } + + func test_init_noise_prefix_with64() { + let str = "noise:" + hex64 + let peerID = PeerID(str: str) + XCTAssertEqual(peerID.id, str) + XCTAssertEqual(peerID.bare, hex64) + XCTAssertEqual(peerID.prefix, .noise) + } + + // MARK: - GeoDM prefix + + func test_init_geoDM_prefix_with16() { + let str = "nostr_" + hex16 + let peerID = PeerID(str: str) + XCTAssertEqual(peerID.id, str) + XCTAssertEqual(peerID.bare, hex16) + XCTAssertEqual(peerID.prefix, .geoDM) + } + + func test_init_geoDM_prefix_with64() { + let str = "nostr_" + hex64 + let peerID = PeerID(str: str) + XCTAssertEqual(peerID.id, str) + XCTAssertEqual(peerID.bare, hex64) + XCTAssertEqual(peerID.prefix, .geoDM) + } + + // MARK: - GeoChat prefix + + func test_init_geoChat_prefix_with16() { + let str = "nostr:" + hex16 + let peerID = PeerID(str: str) + XCTAssertEqual(peerID.id, str) + XCTAssertEqual(peerID.bare, hex16) + XCTAssertEqual(peerID.prefix, .geoChat) + } + + func test_init_geoChat_prefix_with64() { + let str = "nostr:" + hex64 + let peerID = PeerID(str: str) + XCTAssertEqual(peerID.id, str) + XCTAssertEqual(peerID.bare, hex64) + XCTAssertEqual(peerID.prefix, .geoChat) + } + + // MARK: - Edge cases + + func test_init_with_unknown_prefix() { + let str = "unknown:" + hex16 + let peerID = PeerID(str: str) + // Falls back to .empty + XCTAssertEqual(peerID.id, str) + XCTAssertEqual(peerID.bare, str) + XCTAssertEqual(peerID.prefix, .empty) + } + + func test_init_with_only_prefix_no_bare() { + let str = "mesh:" + let peerID = PeerID(str: str) + XCTAssertEqual(peerID.id, str) + XCTAssertEqual(peerID.bare, "") + XCTAssertEqual(peerID.prefix, .mesh) + } + + // MARK: - init?(data:) + + func test_init_data_valid_utf8() { + let peerID = PeerID(data: Data(hex16.utf8)) + XCTAssertNotNil(peerID) + XCTAssertEqual(peerID?.bare, hex16) + XCTAssertEqual(peerID?.prefix, .empty) + } + + func test_init_data_invalid_utf8() { + // Random invalid UTF8 + let bytes: [UInt8] = [0xFF, 0xFE, 0xFA] + let peerID = PeerID(data: Data(bytes)) + XCTAssertNil(peerID) + } + + // MARK: - init(str: Substring) + + func test_init_substring() { + let substring = hex64.prefix(16) + let peerID = PeerID(str: substring) + XCTAssertEqual(peerID.id, String(substring)) + XCTAssertEqual(peerID.bare, String(substring)) + XCTAssertEqual(peerID.prefix, .empty) + } + + // MARK: - init(nostr_ pubKey:) + + func test_init_nostrUnderscore_pubKey() { + let pubKey = hex64 + let peerID = PeerID(nostr_: pubKey) + XCTAssertEqual(peerID.id, "nostr_\(pubKey.prefix(TransportConfig.nostrConvKeyPrefixLength))") + XCTAssertEqual(peerID.bare, String(pubKey.prefix(TransportConfig.nostrConvKeyPrefixLength))) + XCTAssertEqual(peerID.prefix, .geoDM) + } + + // MARK: - init(nostr pubKey:) + + func test_init_nostr_pubKey() { + let pubKey = hex64 + let peerID = PeerID(nostr: pubKey) + XCTAssertEqual(peerID.id, "nostr:\(pubKey.prefix(TransportConfig.nostrShortKeyDisplayLength))") + XCTAssertEqual(peerID.bare, String(pubKey.prefix(TransportConfig.nostrShortKeyDisplayLength))) + XCTAssertEqual(peerID.prefix, .geoChat) + } + + // MARK: - init(publicKey:) + + func test_init_publicKey_derivesFingerprint() { + let publicKey = Data(hex64.utf8) + let expected = publicKey.sha256Fingerprint().prefix(16) + let peerID = PeerID(publicKey: publicKey) + XCTAssertEqual(peerID.bare, String(expected)) + XCTAssertEqual(peerID.prefix, .empty) + } + + // MARK: - toShort() + + func test_toShort_whenNoiseKeyExists() { + let peerID = PeerID(str: hex64) + let short = peerID.toShort() + + // `toShort()` should derive 16-hex peerID + let expected = Data(hexString: hex64)!.sha256Fingerprint().prefix(16) + + XCTAssertEqual(short.bare, String(expected)) + XCTAssertEqual(short.prefix, .empty) + } + + func test_toShort_whenNoNoiseKey() { + let peerID = PeerID(str: "some_random_key") + let short = peerID.toShort() + XCTAssertEqual(short, peerID) // unchanged + } + + + // MARK: - Codable + + func test_codable_emptyPrefix() throws { + struct Dummy: Codable, Equatable { + let name: String + let peerID: PeerID + } + + let str = "aabbccddeeff0011" + let jsonString = "{\"name\":\"some name\",\"peerID\":\"\(str)\"}" + + let decoded = try JSONDecoder().decode(Dummy.self, from: Data(jsonString.utf8)) + XCTAssertEqual(decoded.peerID, PeerID(str: str)) + + let encoded = try encoder.encode(decoded) + XCTAssertEqual(String(data: encoded, encoding: .utf8), jsonString) + } + + func test_codable_withPrefix() throws { + struct Dummy: Codable, Equatable { + let peerID: PeerID + } + + let str = "nostr_\(hex16)" + let jsonString = "{\"peerID\":\"\(str)\"}" + + let decoded = try JSONDecoder().decode(Dummy.self, from: Data(jsonString.utf8)) + XCTAssertEqual(decoded.peerID, PeerID(str: str)) + XCTAssertEqual(decoded.peerID.bare, hex16) + XCTAssertEqual(decoded.peerID.prefix, .geoDM) + + let encoded = try encoder.encode(decoded) + XCTAssertEqual(String(data: encoded, encoding: .utf8), jsonString) + } + + func test_codable_multiplePrefixes() throws { + // Loop across all Prefix cases (except .empty since already tested) + for prefix in PeerID.Prefix.allCases where prefix != .empty { + let bare = hex16 + let str = prefix.rawValue + bare + + let decoded = try JSONDecoder().decode(PeerID.self, from: Data("\"\(str)\"".utf8)) + XCTAssertEqual(decoded.prefix, prefix) + XCTAssertEqual(decoded.bare, bare) + + let encoded = try encoder.encode(decoded) + XCTAssertEqual(String(data: encoded, encoding: .utf8), "\"\(str)\"") + } + } + + // MARK: - Comparable + + func test_comparable_sorting_and_equality() { + let p1 = PeerID(str: "aaa") + let p2 = PeerID(str: "bbb") + let p3 = PeerID(str: "bbb") + + XCTAssertTrue(p1 < p2) + XCTAssertFalse(p2 < p1) + XCTAssertEqual(p2, p3) + + let sorted = [p2, p1].sorted() + XCTAssertEqual(sorted, [p1, p2]) + } + + // MARK: - Computed properties + + func test_isEmpty_true_and_false() { + XCTAssertTrue(PeerID(str: "").isEmpty) + XCTAssertFalse(PeerID(str: "abc").isEmpty) + } + + func test_isGeoChat() { + XCTAssertTrue(PeerID(str: "nostr:abcdef").isGeoChat) + XCTAssertFalse(PeerID(str: "nostr_abcdef").isGeoChat) // different prefix + } + + func test_isGeoDM() { + XCTAssertTrue(PeerID(str: "nostr_abcdef").isGeoDM) + XCTAssertFalse(PeerID(str: "nostr:abcdef").isGeoDM) + } + + func test_toPercentEncoded() { + let peerID = PeerID(str: "name:some value/with spaces?") + let encoded = peerID.toPercentEncoded() + // spaces and ? should be percent-encoded in urlPathAllowed + XCTAssertEqual(encoded, "name%3Asome%20value/with%20spaces%3F") + } + + // MARK: - Validation + + func test_accepts_short_hex_peer_id() { + XCTAssertTrue(PeerID(str: "0011223344556677").isValid) + XCTAssertTrue(PeerID(str: "aabbccddeeff0011").isValid) + } + + func test_accepts_full_noise_key_hex() { + let hex64 = String(repeating: "ab", count: 32) // 64 hex chars + XCTAssertTrue(PeerID(str: hex64).isValid) + } + + func test_accepts_internal_alnum_dash_underscore() { + XCTAssertTrue(PeerID(str: "peer_123-ABC").isValid) + XCTAssertTrue(PeerID(str: "nostr_user_01").isValid) + } + + func test_rejects_invalid_characters() { + XCTAssertFalse(PeerID(str: "peer!@#").isValid) + XCTAssertFalse(PeerID(str: "gggggggggggggggg").isValid) // not hex for short form + } + + func test_rejects_too_long() { + let tooLong = String(repeating: "a", count: 65) + XCTAssertFalse(PeerID(str: tooLong).isValid) + } + + func test_isShort() { + XCTAssertTrue(PeerID(str: hex16).isShort) + XCTAssertFalse(PeerID(str: "abcd").isShort) // wrong length + } + + func test_isNoiseKeyHex_and_noiseKey() { + let hex64 = String(repeating: "ab", count: 32) // 64 chars valid hex + let peerID = PeerID(str: hex64) + XCTAssertTrue(peerID.isNoiseKeyHex) + XCTAssertNotNil(peerID.noiseKey) + + let bad = String(repeating: "z", count: 64) // invalid hex + let badPeerID = PeerID(str: bad) + XCTAssertFalse(badPeerID.isNoiseKeyHex) + XCTAssertNil(badPeerID.noiseKey) + } + + func test_prefixes() { + let hex64 = String(repeating: "a", count: 64) + XCTAssertTrue(PeerID(str: "noise:\(hex64)").isValid) + XCTAssertTrue(PeerID(str: "nostr:\(hex64)").isValid) + XCTAssertTrue(PeerID(str: "nostr_\(hex64)").isValid) + + let hex63 = String(repeating: "a", count: 63) + XCTAssertTrue(PeerID(str: "noise:\(hex63)").isValid) + XCTAssertTrue(PeerID(str: "nostr:\(hex63)").isValid) + XCTAssertTrue(PeerID(str: "nostr_\(hex63)").isValid) + + let hex16 = String(repeating: "a", count: 16) + XCTAssertTrue(PeerID(str: "noise:\(hex16)").isValid) + XCTAssertTrue(PeerID(str: "nostr:\(hex16)").isValid) + XCTAssertTrue(PeerID(str: "nostr_\(hex16)").isValid) + + let hex8 = String(repeating: "a", count: 8) + XCTAssertTrue(PeerID(str: "noise:\(hex8)").isValid) + XCTAssertTrue(PeerID(str: "nostr:\(hex8)").isValid) + XCTAssertTrue(PeerID(str: "nostr_\(hex8)").isValid) + + let mesh = "mesh:abcdefg" + XCTAssertTrue(PeerID(str: "name:\(mesh)").isValid) + + let name = "name:some_name" + XCTAssertTrue(PeerID(str: "name:\(name)").isValid) + + let badName = "name:bad:name" + XCTAssertFalse(PeerID(str: "name:\(badName)").isValid) + + // Too long + let hex65 = String(repeating: "a", count: 65) + XCTAssertFalse(PeerID(str: "noise:\(hex65)").isValid) + XCTAssertFalse(PeerID(str: "nostr:\(hex65)").isValid) + XCTAssertFalse(PeerID(str: "nostr_\(hex65)").isValid) + } +} +