feat: enhance password-protected rooms with key commitments and management

- Add key commitment scheme for immediate password verification
- Implement ownership transfer with /transfer command
- Add password change functionality with /pass command
- Include room metadata in initialization messages
- Remove /help command and rename /changepass to /pass
- Make room names tappable to show sidebar
- Remove spaces in person/room counter display
- Update UI to show lock icons and orange colors for protected rooms
- Fix password verification to work with empty rooms
- Add comprehensive tests for new functionality

This update significantly improves the security and usability of
password-protected rooms by allowing immediate verification without
waiting for encrypted messages.
This commit is contained in:
jack
2025-07-05 19:35:37 +02:00
parent cfded8c4df
commit 7151896102
13 changed files with 1824 additions and 114 deletions
+246 -4
View File
@@ -7,9 +7,13 @@
objects = {
/* Begin PBXBuildFile section */
0DAFF1DDE9BA83FF648D5AB3 /* BitchatMessageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FA8FF26ABDC1C642A8C7AE5 /* BitchatMessageTests.swift */; };
10E68BB889356219189E38EC /* BitchatApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF625BB3AD919322C01A46B2 /* BitchatApp.swift */; };
1D9674FA5F998503831DC281 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A08E03AA0C63E97C91749AEC /* ContentView.swift */; };
230B11C5BF035D35638B21C8 /* PasswordProtectedRoomTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB12482E4AD78B861C538449 /* PasswordProtectedRoomTests.swift */; };
2E71E320EA921498C57E023B /* BitchatMessageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FA8FF26ABDC1C642A8C7AE5 /* BitchatMessageTests.swift */; };
4B747085D07A1BCE0F5BA612 /* BinaryProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2136C3E22D02D4A8DBE7EAB /* BinaryProtocol.swift */; };
56F0892E13CC0D1490A3691E /* BloomFilterTests.swift.disabled in Resources */ = {isa = PBXBuildFile; fileRef = 822AA698BDB7BB939B62A010 /* BloomFilterTests.swift.disabled */; };
61C81ED5F679D5E973EE0C07 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3448F84BF86A42A3CC4A9379 /* NotificationService.swift */; };
6A624D9359BCCAAE58CE0D64 /* LoggingService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96CDF6D0AF0F2052A6C7E634 /* LoggingService.swift */; };
6DE056E1EE9850E9FBF50157 /* BitchatProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 229F17B68CFF7AB1BC91C847 /* BitchatProtocol.swift */; };
@@ -20,32 +24,63 @@
7576A357B278E5733E9D9F33 /* ChatViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6B8F7B7D55092C2540A7996 /* ChatViewModel.swift */; };
7A50E2F04A3515A7E90EEAE4 /* BluetoothMeshService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5C3D880FF8AE1673B20E1E3 /* BluetoothMeshService.swift */; };
7DD72D928FF9DD3CA81B46B0 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 3A69677D382F1C3D5ED03F7D /* Assets.xcassets */; };
8F0BFC2D2B2A5E7B70C3B485 /* BinaryProtocolTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53D535D9CE0B875F47402290 /* BinaryProtocolTests.swift */; };
923027D6F2F417AFA2488127 /* BitchatProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 229F17B68CFF7AB1BC91C847 /* BitchatProtocol.swift */; };
92D34E7A07C990C8A815B0CE /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A08E03AA0C63E97C91749AEC /* ContentView.swift */; };
A0691E21A3A358AB9715AC20 /* BloomFilterTests.swift.disabled in Resources */ = {isa = PBXBuildFile; fileRef = 822AA698BDB7BB939B62A010 /* BloomFilterTests.swift.disabled */; };
ABAF130D88561F4A646F0430 /* AppInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 763E0DBA9492A654FC0CDCB9 /* AppInfoView.swift */; };
AD11E46940D742AEAF547EB2 /* AppInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 763E0DBA9492A654FC0CDCB9 /* AppInfoView.swift */; };
ADC66F95FBD513B10411ADB3 /* MessagePaddingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8DE9CDF66D4E52D268851048 /* MessagePaddingTests.swift */; };
BCCFEDC1EBE59323C3C470BF /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 3A69677D382F1C3D5ED03F7D /* Assets.xcassets */; };
CDAD6629EB69916B95C80DAF /* BinaryProtocolTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53D535D9CE0B875F47402290 /* BinaryProtocolTests.swift */; };
D450CF41F207BDE1A1AAA56E /* ChatViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6B8F7B7D55092C2540A7996 /* ChatViewModel.swift */; };
D948085736ED8E736C1DE3B0 /* BluetoothMeshService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5C3D880FF8AE1673B20E1E3 /* BluetoothMeshService.swift */; };
DDA1DFAB1FF7AADE52DC0F53 /* EncryptionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DC1563390A15C042D059CF9 /* EncryptionService.swift */; };
F00B713D5053617FB5F3F1BE /* MessagePaddingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8DE9CDF66D4E52D268851048 /* MessagePaddingTests.swift */; };
F455F011B3B648ADA233F998 /* BinaryProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2136C3E22D02D4A8DBE7EAB /* BinaryProtocol.swift */; };
F4A689F5F34125AE1BFD5599 /* PasswordProtectedRoomTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB12482E4AD78B861C538449 /* PasswordProtectedRoomTests.swift */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
96415D4F989854F908EAD303 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 475D96681D0EA0AE57A4E06E /* Project object */;
proxyType = 1;
remoteGlobalIDString = AF077EA0474EDEDE2C72716C;
remoteInfo = bitchat_iOS;
};
FF470234EF8C6BB8865B80B5 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 475D96681D0EA0AE57A4E06E /* Project object */;
proxyType = 1;
remoteGlobalIDString = 0576A29205865664C0937536;
remoteInfo = bitchat_macOS;
};
/* End PBXContainerItemProxy section */
/* Begin PBXFileReference section */
03C57F452B55FD0FD8F51421 /* bitchatTests_macOS.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = bitchatTests_macOS.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
229F17B68CFF7AB1BC91C847 /* BitchatProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BitchatProtocol.swift; sourceTree = "<group>"; };
3448F84BF86A42A3CC4A9379 /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = "<group>"; };
3A69677D382F1C3D5ED03F7D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
3FA8FF26ABDC1C642A8C7AE5 /* BitchatMessageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BitchatMessageTests.swift; sourceTree = "<group>"; };
53D535D9CE0B875F47402290 /* BinaryProtocolTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BinaryProtocolTests.swift; sourceTree = "<group>"; };
6DC1563390A15C042D059CF9 /* EncryptionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EncryptionService.swift; sourceTree = "<group>"; };
763E0DBA9492A654FC0CDCB9 /* AppInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppInfoView.swift; sourceTree = "<group>"; };
7EEBDA723E1CFD88758DA4AC /* bitchat.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = bitchat.app; sourceTree = BUILT_PRODUCTS_DIR; };
822AA698BDB7BB939B62A010 /* BloomFilterTests.swift.disabled */ = {isa = PBXFileReference; lastKnownFileType = text; path = BloomFilterTests.swift.disabled; sourceTree = "<group>"; };
8DE9CDF66D4E52D268851048 /* MessagePaddingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessagePaddingTests.swift; sourceTree = "<group>"; };
96CDF6D0AF0F2052A6C7E634 /* LoggingService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoggingService.swift; sourceTree = "<group>"; };
997D512074C64904D75DDD40 /* bitchat.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = bitchat.app; sourceTree = BUILT_PRODUCTS_DIR; };
A08E03AA0C63E97C91749AEC /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
A2136C3E22D02D4A8DBE7EAB /* BinaryProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BinaryProtocol.swift; sourceTree = "<group>"; };
C0DB1DE27F0AAB5092663E8E /* bitchatTests_iOS.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = bitchatTests_iOS.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
D5C3D880FF8AE1673B20E1E3 /* BluetoothMeshService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BluetoothMeshService.swift; sourceTree = "<group>"; };
D69A18D27F9A565FD6041E12 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
E6B8F7B7D55092C2540A7996 /* ChatViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatViewModel.swift; sourceTree = "<group>"; };
EA706D8E5097785414646A8E /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
EF625BB3AD919322C01A46B2 /* BitchatApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BitchatApp.swift; sourceTree = "<group>"; };
FB12482E4AD78B861C538449 /* PasswordProtectedRoomTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasswordProtectedRoomTests.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXGroup section */
@@ -53,6 +88,7 @@
isa = PBXGroup;
children = (
2F82C5FC8433F4064F079D1F /* bitchat */,
C3D98EB3E1B455E321F519F4 /* bitchatTests */,
9F37F9F2C353B58AC809E93B /* Products */,
);
sourceTree = "<group>";
@@ -84,6 +120,8 @@
children = (
997D512074C64904D75DDD40 /* bitchat.app */,
7EEBDA723E1CFD88758DA4AC /* bitchat.app */,
C0DB1DE27F0AAB5092663E8E /* bitchatTests_iOS.xctest */,
03C57F452B55FD0FD8F51421 /* bitchatTests_macOS.xctest */,
);
name = Products;
sourceTree = "<group>";
@@ -106,6 +144,19 @@
path = Protocols;
sourceTree = "<group>";
};
C3D98EB3E1B455E321F519F4 /* bitchatTests */ = {
isa = PBXGroup;
children = (
53D535D9CE0B875F47402290 /* BinaryProtocolTests.swift */,
3FA8FF26ABDC1C642A8C7AE5 /* BitchatMessageTests.swift */,
822AA698BDB7BB939B62A010 /* BloomFilterTests.swift.disabled */,
D69A18D27F9A565FD6041E12 /* Info.plist */,
8DE9CDF66D4E52D268851048 /* MessagePaddingTests.swift */,
FB12482E4AD78B861C538449 /* PasswordProtectedRoomTests.swift */,
);
path = bitchatTests;
sourceTree = "<group>";
};
D98A3186D7E4C72E35BDF7FE /* Services */ = {
isa = PBXGroup;
children = (
@@ -138,6 +189,44 @@
productReference = 7EEBDA723E1CFD88758DA4AC /* bitchat.app */;
productType = "com.apple.product-type.application";
};
47FF23248747DD7CB666CB91 /* bitchatTests_macOS */ = {
isa = PBXNativeTarget;
buildConfigurationList = 1C27B5BA3DB46DDF0DBFEF62 /* Build configuration list for PBXNativeTarget "bitchatTests_macOS" */;
buildPhases = (
5C22AA7B9ACC5A861445C769 /* Sources */,
7F7221A3304F43E9CF496036 /* Resources */,
);
buildRules = (
);
dependencies = (
4AA8605DCAA64A45657EF0CA /* PBXTargetDependency */,
);
name = bitchatTests_macOS;
packageProductDependencies = (
);
productName = bitchatTests_macOS;
productReference = 03C57F452B55FD0FD8F51421 /* bitchatTests_macOS.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
};
6CB97DF2EA57234CB3E563B8 /* bitchatTests_iOS */ = {
isa = PBXNativeTarget;
buildConfigurationList = 38C4AF6313E5037F25CEF30B /* Build configuration list for PBXNativeTarget "bitchatTests_iOS" */;
buildPhases = (
865C8403EF02C089369A9FCB /* Sources */,
B42D1108ADC08C2AD4FD6DB5 /* Resources */,
);
buildRules = (
);
dependencies = (
D8C09F21DB7DC06E8E672C21 /* PBXTargetDependency */,
);
name = bitchatTests_iOS;
packageProductDependencies = (
);
productName = bitchatTests_iOS;
productReference = C0DB1DE27F0AAB5092663E8E /* bitchatTests_iOS.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
};
AF077EA0474EDEDE2C72716C /* bitchat_iOS */ = {
isa = PBXNativeTarget;
buildConfigurationList = 53EADEF7546F94DDF82271B9 /* Build configuration list for PBXNativeTarget "bitchat_iOS" */;
@@ -186,6 +275,8 @@
projectDirPath = "";
projectRoot = "";
targets = (
6CB97DF2EA57234CB3E563B8 /* bitchatTests_iOS */,
47FF23248747DD7CB666CB91 /* bitchatTests_macOS */,
AF077EA0474EDEDE2C72716C /* bitchat_iOS */,
0576A29205865664C0937536 /* bitchat_macOS */,
);
@@ -201,6 +292,22 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
7F7221A3304F43E9CF496036 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
56F0892E13CC0D1490A3691E /* BloomFilterTests.swift.disabled in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
B42D1108ADC08C2AD4FD6DB5 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
A0691E21A3A358AB9715AC20 /* BloomFilterTests.swift.disabled in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
CD6E8F32BC38357473954F97 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
@@ -246,9 +353,101 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
5C22AA7B9ACC5A861445C769 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
8F0BFC2D2B2A5E7B70C3B485 /* BinaryProtocolTests.swift in Sources */,
2E71E320EA921498C57E023B /* BitchatMessageTests.swift in Sources */,
ADC66F95FBD513B10411ADB3 /* MessagePaddingTests.swift in Sources */,
F4A689F5F34125AE1BFD5599 /* PasswordProtectedRoomTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
865C8403EF02C089369A9FCB /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
CDAD6629EB69916B95C80DAF /* BinaryProtocolTests.swift in Sources */,
0DAFF1DDE9BA83FF648D5AB3 /* BitchatMessageTests.swift in Sources */,
F00B713D5053617FB5F3F1BE /* MessagePaddingTests.swift in Sources */,
230B11C5BF035D35638B21C8 /* PasswordProtectedRoomTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
4AA8605DCAA64A45657EF0CA /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 0576A29205865664C0937536 /* bitchat_macOS */;
targetProxy = FF470234EF8C6BB8865B80B5 /* PBXContainerItemProxy */;
};
D8C09F21DB7DC06E8E672C21 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = AF077EA0474EDEDE2C72716C /* bitchat_iOS */;
targetProxy = 96415D4F989854F908EAD303 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */
077A5203074247CF8F766E2F /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
INFOPLIST_FILE = bitchatTests/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@loader_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat.tests;
SDKROOT = iphoneos;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/bitchat.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/bitchat";
};
name = Debug;
};
0DACAA261446D178EDD30ECA /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
INFOPLIST_FILE = bitchatTests/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@loader_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat.tests;
SDKROOT = iphoneos;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/bitchat.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/bitchat";
};
name = Release;
};
147FDAE548082D5B921C6F0B /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
COMBINE_HIDPI_IMAGES = YES;
INFOPLIST_FILE = bitchatTests/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
"@loader_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 13.0;
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat.tests;
SDKROOT = macosx;
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/bitchat.app/Contents/MacOS/bitchat";
};
name = Release;
};
702E7395723CADA4B830F4A9 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
@@ -270,9 +469,31 @@
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat;
PRODUCT_NAME = bitchat;
SDKROOT = iphoneos;
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = NO;
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
TARGETED_DEVICE_FAMILY = 1;
};
name = Debug;
};
7FA2BADBF3B325125030CAB1 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
COMBINE_HIDPI_IMAGES = YES;
INFOPLIST_FILE = bitchatTests/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
"@loader_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 13.0;
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat.tests;
SDKROOT = macosx;
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/bitchat.app/Contents/MacOS/bitchat";
};
name = Debug;
};
@@ -297,9 +518,12 @@
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat;
PRODUCT_NAME = bitchat;
SDKROOT = iphoneos;
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = NO;
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
TARGETED_DEVICE_FAMILY = 1;
};
name = Release;
};
@@ -479,6 +703,24 @@
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
1C27B5BA3DB46DDF0DBFEF62 /* Build configuration list for PBXNativeTarget "bitchatTests_macOS" */ = {
isa = XCConfigurationList;
buildConfigurations = (
7FA2BADBF3B325125030CAB1 /* Debug */,
147FDAE548082D5B921C6F0B /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Debug;
};
38C4AF6313E5037F25CEF30B /* Build configuration list for PBXNativeTarget "bitchatTests_iOS" */ = {
isa = XCConfigurationList;
buildConfigurations = (
077A5203074247CF8F766E2F /* Debug */,
0DACAA261446D178EDD30ECA /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Debug;
};
3EA424CBD51200895D361189 /* Build configuration list for PBXProject "bitchat" */ = {
isa = XCConfigurationList;
buildConfigurations = (
+3 -1
View File
@@ -107,7 +107,9 @@ struct BinaryProtocol {
var offset = 0
// Header
_ = data[offset]; offset += 1 // version
let version = data[offset]; offset += 1
// Only support version 1
guard version == 1 else { return nil }
let type = data[offset]; offset += 1
let ttl = data[offset]; offset += 1
+9 -6
View File
@@ -18,15 +18,18 @@ struct MessagePadding {
static func pad(_ data: Data, toSize targetSize: Int) -> Data {
guard data.count < targetSize else { return data }
var padded = data
let paddingNeeded = targetSize - data.count
// Add random padding bytes (more secure than zeros)
// PKCS#7 only supports padding up to 255 bytes
// If we need more padding than that, don't pad - return original data
guard paddingNeeded <= 255 else { return data }
var padded = data
// Standard PKCS#7 padding
var randomBytes = [UInt8](repeating: 0, count: paddingNeeded - 1)
_ = SecRandomCopyBytes(kSecRandomDefault, paddingNeeded - 1, &randomBytes)
padded.append(contentsOf: randomBytes)
// Last byte indicates padding length (PKCS#7 style)
padded.append(UInt8(paddingNeeded))
return padded
@@ -161,7 +164,7 @@ protocol BitchatDelegate: AnyObject {
func didDisconnectFromPeer(_ peerID: String)
func didUpdatePeerList(_ peers: [String])
func didReceiveRoomLeave(_ room: String, from peerID: String)
func didReceivePasswordProtectedRoomAnnouncement(_ room: String, isProtected: Bool, creatorID: String?)
func didReceivePasswordProtectedRoomAnnouncement(_ room: String, isProtected: Bool, creatorID: String?, keyCommitment: String?)
func decryptRoomMessage(_ encryptedContent: Data, room: String) -> String?
// Optional method to check if a fingerprint belongs to a favorite peer
@@ -178,7 +181,7 @@ extension BitchatDelegate {
// Default empty implementation
}
func didReceivePasswordProtectedRoomAnnouncement(_ room: String, isProtected: Bool, creatorID: String?) {
func didReceivePasswordProtectedRoomAnnouncement(_ room: String, isProtected: Bool, creatorID: String?, keyCommitment: String?) {
// Default empty implementation
}
+16 -6
View File
@@ -685,14 +685,15 @@ class BluetoothMeshService: NSObject {
}
}
func announcePasswordProtectedRoom(_ room: String, isProtected: Bool = true, creatorID: String? = nil) {
func announcePasswordProtectedRoom(_ room: String, isProtected: Bool = true, creatorID: String? = nil, keyCommitment: String? = nil) {
messageQueue.async { [weak self] in
guard let self = self else { return }
// Payload format: room|isProtected|creatorID
// Payload format: room|isProtected|creatorID|keyCommitment
let protectedFlag = isProtected ? "1" : "0"
let creator = creatorID ?? self.myPeerID
let payload = "\(room)|\(protectedFlag)|\(creator)"
let commitment = keyCommitment ?? ""
let payload = "\(room)|\(protectedFlag)|\(creator)|\(commitment)"
let packet = BitchatPacket(
type: MessageType.roomAnnounce.rawValue,
@@ -719,9 +720,14 @@ class BluetoothMeshService: NSObject {
// Encrypt the content
guard let contentData = content.data(using: .utf8) else { return }
// Debug logging
let keyData = roomKey.withUnsafeBytes { Data($0) }
bitchatLog("Encrypting message for room \(room) with key hash: \(keyData.prefix(8).hexEncodedString())", category: "crypto")
do {
let sealedBox = try AES.GCM.seal(contentData, using: roomKey)
let encryptedData = sealedBox.combined!
bitchatLog("Successfully encrypted message, size: \(encryptedData.count) bytes", category: "crypto")
// Create message with encrypted content
let message = BitchatMessage(
@@ -1309,12 +1315,15 @@ class BluetoothMeshService: NSObject {
// Handle encrypted room messages
var finalContent = message.content
if message.isEncrypted, let room = message.room, let encryptedData = message.encryptedContent {
bitchatLog("Processing encrypted message in room \(room), encrypted data size: \(encryptedData.count)", category: "crypto")
// Try to decrypt the content
if let decryptedContent = self.delegate?.decryptRoomMessage(encryptedData, room: room) {
finalContent = decryptedContent
bitchatLog("Successfully decrypted message in room \(room): \(decryptedContent.prefix(20))...", category: "crypto")
} else {
// Unable to decrypt - show placeholder
finalContent = "[Encrypted message - password required]"
bitchatLog("Failed to decrypt message in room \(room) - will pass to ChatViewModel for re-attempt", category: "crypto")
}
}
@@ -1762,17 +1771,18 @@ class BluetoothMeshService: NSObject {
case .roomAnnounce:
if let payloadStr = String(data: packet.payload, encoding: .utf8) {
// Parse payload: room|isProtected|creatorID
// Parse payload: room|isProtected|creatorID|keyCommitment
let components = payloadStr.split(separator: "|").map(String.init)
if components.count >= 3 {
let room = components[0]
let isProtected = components[1] == "1"
let creatorID = components[2]
let keyCommitment = components.count >= 4 ? components[3] : nil
bitchatLog("Received room announcement: \(room) is \(isProtected ? "protected" : "public") by \(creatorID)", category: "room")
bitchatLog("Received room announcement: \(room) is \(isProtected ? "protected" : "public") by \(creatorID)" + (keyCommitment != nil ? " with commitment" : ""), category: "room")
DispatchQueue.main.async {
self.delegate?.didReceivePasswordProtectedRoomAnnouncement(room, isProtected: isProtected, creatorID: creatorID)
self.delegate?.didReceivePasswordProtectedRoomAnnouncement(room, isProtected: isProtected, creatorID: creatorID, keyCommitment: keyCommitment)
}
// Relay announcement
File diff suppressed because it is too large Load Diff
+155 -46
View File
@@ -23,6 +23,9 @@ struct ContentView: View {
@State private var passwordInput = ""
@State private var showPasswordPrompt = false
@State private var passwordPromptInput = ""
@State private var showPasswordError = false
@State private var showCommandSuggestions = false
@State private var commandSuggestions: [String] = []
private var backgroundColor: Color {
colorScheme == .dark ? Color.black : Color.white
@@ -192,12 +195,23 @@ struct ContentView: View {
}
Button("Join") {
if let room = viewModel.passwordPromptRoom, !passwordPromptInput.isEmpty {
viewModel.joinRoom(room, password: passwordPromptInput)
let success = viewModel.joinRoom(room, password: passwordPromptInput)
if success {
passwordPromptInput = ""
} else {
// Wrong password - show error
passwordPromptInput = ""
showPasswordError = true
}
}
}
} message: {
Text("This room is password protected. Enter the password to join.")
Text("Room \(viewModel.passwordPromptRoom ?? "") is password protected. Enter the password to join.")
}
.alert("Wrong Password", isPresented: $showPasswordError) {
Button("OK", role: .cancel) { }
} message: {
Text("The password you entered is incorrect. Please try again.")
}
}
@@ -244,23 +258,46 @@ struct ContentView: View {
.buttonStyle(.plain)
} else if let currentRoom = viewModel.currentRoom {
// Room header
Button(action: {
viewModel.switchToRoom(nil)
}) {
HStack(spacing: 4) {
Image(systemName: "chevron.left")
.font(.system(size: 12))
Text("back")
.font(.system(size: 14, design: .monospaced))
}
.foregroundColor(textColor)
}
.buttonStyle(.plain)
Spacer()
Button(action: {
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
showSidebar.toggle()
sidebarDragOffset = 0
}
}) {
Text(currentRoom)
.font(.system(size: 18, weight: .medium, design: .monospaced))
.foregroundColor(Color.blue)
HStack(spacing: 6) {
if viewModel.passwordProtectedRooms.contains(currentRoom) {
Image(systemName: "lock.fill")
.font(.system(size: 14))
.foregroundColor(Color.orange)
}
Text("room: \(currentRoom)")
.font(.system(size: 16, weight: .medium, design: .monospaced))
.foregroundColor(viewModel.passwordProtectedRooms.contains(currentRoom) ? Color.orange : Color.blue)
}
}
.buttonStyle(.plain)
.frame(maxWidth: .infinity)
Spacer()
// Password button for room creator
if viewModel.roomCreators[currentRoom] == viewModel.meshService.myPeerID || viewModel.roomCreators[currentRoom] == nil {
HStack(spacing: 8) {
// Password button for room creator only
if viewModel.roomCreators[currentRoom] == viewModel.meshService.myPeerID {
Button(action: {
// Toggle password protection
if viewModel.passwordProtectedRooms.contains(currentRoom) {
@@ -271,14 +308,9 @@ struct ContentView: View {
passwordInputRoom = currentRoom
}
}) {
Image(systemName: viewModel.passwordProtectedRooms.contains(currentRoom) ? "lock.open" : "lock")
.font(.system(size: 14))
.foregroundColor(secondaryTextColor)
.padding(6)
.overlay(
RoundedRectangle(cornerRadius: 4)
.stroke(secondaryTextColor.opacity(0.5), lineWidth: 1)
)
Image(systemName: viewModel.passwordProtectedRooms.contains(currentRoom) ? "lock.fill" : "lock")
.font(.system(size: 16))
.foregroundColor(viewModel.passwordProtectedRooms.contains(currentRoom) ? Color.yellow : textColor)
}
.buttonStyle(.plain)
}
@@ -287,31 +319,9 @@ struct ContentView: View {
Button(action: {
viewModel.leaveRoom(currentRoom)
}) {
Text("leave room")
Text("leave")
.font(.system(size: 12, design: .monospaced))
.foregroundColor(Color.red)
.padding(.horizontal, 8)
.padding(.vertical, 2)
.overlay(
RoundedRectangle(cornerRadius: 4)
.stroke(Color.red.opacity(0.5), lineWidth: 1)
)
}
.buttonStyle(.plain)
// Back to main button
Button(action: {
viewModel.switchToRoom(nil)
}) {
Text("main")
.font(.system(size: 12, design: .monospaced))
.foregroundColor(textColor)
.padding(.horizontal, 8)
.padding(.vertical, 2)
.overlay(
RoundedRectangle(cornerRadius: 4)
.stroke(textColor.opacity(0.5), lineWidth: 1)
)
}
.buttonStyle(.plain)
}
@@ -373,7 +383,7 @@ struct ContentView: View {
let statusText = if !viewModel.isConnected {
"alone :/"
} else if roomCount > 0 {
"\(otherPeersCount) \(otherPeersCount == 1 ? "person" : "people") / \(roomCount) \(roomCount == 1 ? "room" : "rooms")"
"\(otherPeersCount) \(otherPeersCount == 1 ? "person" : "people")/\(roomCount) \(roomCount == 1 ? "room" : "rooms")"
} else {
"\(otherPeersCount) \(otherPeersCount == 1 ? "person" : "people")"
}
@@ -419,6 +429,7 @@ struct ContentView: View {
.font(.system(size: 14, design: .monospaced))
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity, alignment: .leading)
.textSelection(.enabled)
} else {
// Regular messages with tappable sender name
HStack(alignment: .top, spacing: 0) {
@@ -426,6 +437,7 @@ struct ContentView: View {
Text("[\(viewModel.formatTimestamp(message.timestamp))] ")
.font(.system(size: 12, design: .monospaced))
.foregroundColor(secondaryTextColor)
.textSelection(.enabled)
// Tappable sender name
if message.sender != viewModel.nickname {
@@ -445,6 +457,7 @@ struct ContentView: View {
Text("<\(message.sender)>")
.font(.system(size: 12, weight: .medium, design: .monospaced))
.foregroundColor(textColor)
.textSelection(.enabled)
}
Text(" ")
@@ -489,6 +502,56 @@ struct ContentView: View {
}
private var inputView: some View {
VStack(spacing: 0) {
// Command suggestions
if showCommandSuggestions && !commandSuggestions.isEmpty {
VStack(alignment: .leading, spacing: 0) {
let commandDescriptions = [
"/j": "join or create a room",
"/list": "show your joined rooms",
"/w": "see who's online",
"/m": "send private message",
"/clear": "clear chat messages",
"/transfer": "transfer room ownership",
"/pass": "change room password"
]
ForEach(commandSuggestions, id: \.self) { command in
Button(action: {
// Replace current text with selected command
messageText = command + " "
showCommandSuggestions = false
commandSuggestions = []
}) {
HStack {
Text(command)
.font(.system(size: 11, design: .monospaced))
.foregroundColor(textColor)
.fontWeight(.medium)
Spacer()
if let description = commandDescriptions[command] {
Text(description)
.font(.system(size: 10, design: .monospaced))
.foregroundColor(secondaryTextColor)
}
}
.padding(.horizontal, 12)
.padding(.vertical, 3)
.frame(maxWidth: .infinity, alignment: .leading)
}
.buttonStyle(.plain)
.background(Color.gray.opacity(0.1))
}
}
.background(backgroundColor)
.overlay(
RoundedRectangle(cornerRadius: 4)
.stroke(secondaryTextColor.opacity(0.3), lineWidth: 1)
)
.padding(.horizontal, 12)
.padding(.bottom, 4)
}
HStack(alignment: .center, spacing: 4) {
if viewModel.selectedPrivateChatPeer != nil {
Text("<\(viewModel.nickname)> →")
@@ -497,6 +560,13 @@ struct ContentView: View {
.lineLimit(1)
.fixedSize()
.padding(.leading, 12)
} else if let currentRoom = viewModel.currentRoom, viewModel.passwordProtectedRooms.contains(currentRoom) {
Text("<\(viewModel.nickname)> →")
.font(.system(size: 12, weight: .medium, design: .monospaced))
.foregroundColor(Color.orange)
.lineLimit(1)
.fixedSize()
.padding(.leading, 12)
} else {
Text("<\(viewModel.nickname)>")
.font(.system(size: 12, weight: .medium, design: .monospaced))
@@ -515,6 +585,27 @@ struct ContentView: View {
// Get cursor position (approximate - end of text for now)
let cursorPosition = newValue.count
viewModel.updateAutocomplete(for: newValue, cursorPosition: cursorPosition)
// Check for command autocomplete
if newValue.hasPrefix("/") && newValue.count >= 1 {
let commandDescriptions = [
("/j", "join or create a room"),
("/list", "show your joined rooms"),
("/w", "see who's online"),
("/m", "send private message"),
("/clear", "clear chat messages"),
("/transfer", "transfer room ownership"),
("/pass", "change room password")
]
let input = newValue.lowercased()
commandSuggestions = commandDescriptions
.filter { $0.0.starts(with: input) }
.map { $0.0 }
showCommandSuggestions = !commandSuggestions.isEmpty
} else {
showCommandSuggestions = false
commandSuggestions = []
}
}
.onSubmit {
sendMessage()
@@ -523,13 +614,16 @@ struct ContentView: View {
Button(action: sendMessage) {
Image(systemName: "arrow.up.circle.fill")
.font(.system(size: 20))
.foregroundColor(viewModel.selectedPrivateChatPeer != nil ? Color.orange : textColor)
.foregroundColor((viewModel.selectedPrivateChatPeer != nil ||
(viewModel.currentRoom != nil && viewModel.passwordProtectedRooms.contains(viewModel.currentRoom ?? "")))
? Color.orange : textColor)
}
.buttonStyle(.plain)
.padding(.trailing, 12)
}
.padding(.vertical, 8)
.background(backgroundColor.opacity(0.95))
}
.onAppear {
isTextFieldFocused = true
}
@@ -574,10 +668,18 @@ struct ContentView: View {
ForEach(Array(viewModel.joinedRooms).sorted(), id: \.self) { room in
Button(action: {
// Check if room needs password and we don't have it
if viewModel.passwordProtectedRooms.contains(room) && viewModel.roomKeys[room] == nil {
// Need password
viewModel.passwordPromptRoom = room
viewModel.showPasswordPrompt = true
} else {
// Can enter room
viewModel.switchToRoom(room)
withAnimation(.spring()) {
showSidebar = false
}
}
}) {
HStack {
// Lock icon for password protected rooms
@@ -607,7 +709,7 @@ struct ContentView: View {
// Room controls
if viewModel.currentRoom == room {
HStack(spacing: 4) {
// Password button for room creator
// Password button for room creator only
if viewModel.roomCreators[room] == viewModel.meshService.myPeerID {
Button(action: {
// Toggle password protection
@@ -619,13 +721,17 @@ struct ContentView: View {
passwordInputRoom = room
}
}) {
Image(systemName: viewModel.passwordProtectedRooms.contains(room) ? "lock.open" : "lock")
HStack(spacing: 2) {
Image(systemName: viewModel.passwordProtectedRooms.contains(room) ? "lock.fill" : "lock")
.font(.system(size: 10))
.foregroundColor(secondaryTextColor)
.padding(4)
}
.foregroundColor(viewModel.passwordProtectedRooms.contains(room) ? backgroundColor : secondaryTextColor)
.padding(.horizontal, 8)
.padding(.vertical, 2)
.background(viewModel.passwordProtectedRooms.contains(room) ? Color.orange : Color.clear)
.overlay(
RoundedRectangle(cornerRadius: 4)
.stroke(secondaryTextColor.opacity(0.5), lineWidth: 1)
.stroke(viewModel.passwordProtectedRooms.contains(room) ? Color.orange : secondaryTextColor.opacity(0.5), lineWidth: 1)
)
}
.buttonStyle(.plain)
@@ -846,22 +952,25 @@ struct MessageContentView: View {
ForEach(Array(buildTextSegments().enumerated()), id: \.offset) { _, segment in
if segment.type == "hashtag" {
Button(action: {
viewModel.joinRoom(segment.text)
_ = viewModel.joinRoom(segment.text)
}) {
Text(segment.text)
.font(.system(size: 14, weight: .semibold, design: .monospaced))
.foregroundColor(Color.blue)
.underline()
.textSelection(.enabled)
}
.buttonStyle(.plain)
} else if segment.type == "mention" {
Text(segment.text)
.font(.system(size: 14, weight: .semibold, design: .monospaced))
.foregroundColor(Color.orange)
.textSelection(.enabled)
} else {
Text(segment.text)
.font(.system(size: 14, design: .monospaced))
.fontWeight(isMentioned ? .bold : .regular)
.textSelection(.enabled)
}
}
}
-1
View File
@@ -14,7 +14,6 @@ class BinaryProtocolTests: XCTestCase {
func testPacketEncodingDecoding() {
// Test basic packet
let packet = BitchatPacket(
version: 1,
type: MessageType.message.rawValue,
senderID: Data("testuser".utf8),
recipientID: Data("recipient".utf8),
+62
View File
@@ -42,6 +42,68 @@ class BitchatMessageTests: XCTestCase {
XCTAssertTrue(decoded.mentions?.contains("bob") ?? false)
}
func testRoomMessage() {
let roomMessage = BitchatMessage(
sender: "alice",
content: "Hello #general",
timestamp: Date(),
isRelay: false,
originalSender: nil,
isPrivate: false,
recipientNickname: nil,
senderPeerID: "alice123",
mentions: nil,
room: "#general"
)
guard let encoded = roomMessage.toBinaryPayload() else {
XCTFail("Failed to encode room message")
return
}
guard let decoded = BitchatMessage.fromBinaryPayload(encoded) else {
XCTFail("Failed to decode room message")
return
}
XCTAssertEqual(decoded.room, "#general")
XCTAssertEqual(decoded.content, roomMessage.content)
}
func testEncryptedRoomMessage() {
let encryptedData = Data([1, 2, 3, 4, 5, 6, 7, 8]) // Mock encrypted content
let encryptedMessage = BitchatMessage(
sender: "bob",
content: "", // Empty for encrypted messages
timestamp: Date(),
isRelay: false,
originalSender: nil,
isPrivate: false,
recipientNickname: nil,
senderPeerID: "bob456",
mentions: nil,
room: "#secret",
encryptedContent: encryptedData,
isEncrypted: true
)
guard let encoded = encryptedMessage.toBinaryPayload() else {
XCTFail("Failed to encode encrypted message")
return
}
guard let decoded = BitchatMessage.fromBinaryPayload(encoded) else {
XCTFail("Failed to decode encrypted message")
return
}
XCTAssertTrue(decoded.isEncrypted)
XCTAssertEqual(decoded.encryptedContent, encryptedData)
XCTAssertEqual(decoded.room, "#secret")
XCTAssertEqual(decoded.content, "") // Content should be empty for encrypted messages
}
func testPrivateMessage() {
let privateMessage = BitchatMessage(
sender: "alice",
+18
View File
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
</dict>
</plist>
+20 -4
View File
@@ -39,9 +39,17 @@ class MessagePaddingTests: XCTestCase {
XCTAssertTrue(MessagePadding.blockSizes.contains(blockSize) || blockSize == data.count)
let padded = MessagePadding.pad(data, toSize: blockSize)
let unpadded = MessagePadding.unpad(padded)
// Check if padding was applied (only if needed padding <= 255)
let paddingNeeded = blockSize - data.count
if paddingNeeded <= 255 {
XCTAssertEqual(padded.count, blockSize)
let unpadded = MessagePadding.unpad(padded)
XCTAssertEqual(unpadded, data)
} else {
// No padding applied if more than 255 bytes needed
XCTAssertEqual(padded, data)
}
}
}
@@ -53,10 +61,18 @@ class MessagePaddingTests: XCTestCase {
XCTAssertEqual(blockSize, 2048)
let padded = MessagePadding.pad(largeData, toSize: blockSize)
XCTAssertEqual(padded.count, blockSize)
// Since padding needed (548 bytes) > 255, no padding is applied
XCTAssertEqual(padded.count, largeData.count)
XCTAssertEqual(padded, largeData)
let unpadded = MessagePadding.unpad(padded)
XCTAssertEqual(unpadded, largeData)
// Test with data that fits within PKCS#7 limits
let smallerData = Data(repeating: 0xAA, count: 1800)
let paddedSmaller = MessagePadding.pad(smallerData, toSize: 2048)
// Padding needed is 248 bytes, which is < 255, so padding should work
XCTAssertEqual(paddedSmaller.count, 2048)
let unpaddedSmaller = MessagePadding.unpad(paddedSmaller)
XCTAssertEqual(unpaddedSmaller, smallerData)
}
func testInvalidPadding() {
@@ -0,0 +1,437 @@
//
// PasswordProtectedRoomTests.swift
// bitchatTests
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import XCTest
import CryptoKit
import CommonCrypto
@testable import bitchat
class PasswordProtectedRoomTests: XCTestCase {
var viewModel: ChatViewModel!
override func setUp() {
super.setUp()
// Clear UserDefaults to ensure test isolation
clearAllUserDefaults()
// Create a fresh view model for each test
viewModel = ChatViewModel()
// Ensure clean state
viewModel.passwordProtectedRooms.removeAll()
viewModel.roomCreators.removeAll()
viewModel.roomPasswords.removeAll()
viewModel.roomKeys.removeAll()
viewModel.joinedRooms.removeAll()
viewModel.roomMembers.removeAll()
viewModel.roomMessages.removeAll()
}
private func clearAllUserDefaults() {
let defaults = UserDefaults.standard
defaults.removeObject(forKey: "bitchat_nickname")
defaults.removeObject(forKey: "bitchat_joined_rooms")
defaults.removeObject(forKey: "bitchat_password_protected_rooms")
defaults.removeObject(forKey: "bitchat_room_creators")
defaults.removeObject(forKey: "bitchat_room_passwords")
defaults.removeObject(forKey: "bitchat_favorite_peers")
defaults.synchronize()
}
override func tearDown() {
// Clean up after tests
clearAllUserDefaults()
viewModel = nil
super.tearDown()
}
// MARK: - Password Key Derivation Tests
func testPasswordKeyDerivation() {
// Same password and room should always produce same key
let password = "secretPassword123"
let roomName = "#testroom"
let key1 = deriveRoomKey(from: password, roomName: roomName)
let key2 = deriveRoomKey(from: password, roomName: roomName)
// Keys should be identical
XCTAssertEqual(key1, key2, "Same password and room should produce same key")
}
func testDifferentPasswordsProduceDifferentKeys() {
let roomName = "#testroom"
let password1 = "password123"
let password2 = "different456"
let key1 = deriveRoomKey(from: password1, roomName: roomName)
let key2 = deriveRoomKey(from: password2, roomName: roomName)
XCTAssertNotEqual(key1, key2, "Different passwords should produce different keys")
}
func testDifferentRoomsProduceDifferentKeys() {
let password = "samePassword"
let room1 = "#room1"
let room2 = "#room2"
let key1 = deriveRoomKey(from: password, roomName: room1)
let key2 = deriveRoomKey(from: password, roomName: room2)
XCTAssertNotEqual(key1, key2, "Same password in different rooms should produce different keys")
}
// MARK: - Room Creation and Joining Tests
func testJoinUnprotectedRoom() {
let roomName = "#public"
let success = viewModel.joinRoom(roomName)
XCTAssertTrue(success, "Should be able to join unprotected room")
XCTAssertTrue(viewModel.joinedRooms.contains(roomName))
XCTAssertEqual(viewModel.currentRoom, roomName)
XCTAssertTrue(viewModel.roomMembers[roomName]?.contains(viewModel.meshService.myPeerID) ?? false)
}
func testCreatePasswordProtectedRoom() {
let roomName = "#private"
let password = "secret123"
// Join room first
let joinSuccess = viewModel.joinRoom(roomName)
XCTAssertTrue(joinSuccess)
// Set password
viewModel.setRoomPassword(password, for: roomName)
XCTAssertTrue(viewModel.passwordProtectedRooms.contains(roomName))
XCTAssertNotNil(viewModel.roomKeys[roomName])
XCTAssertEqual(viewModel.roomPasswords[roomName], password)
XCTAssertEqual(viewModel.roomCreators[roomName], viewModel.meshService.myPeerID)
}
func testJoinPasswordProtectedEmptyRoom() {
let roomName = "#protected"
let password = "test123"
// Simulate room being marked as password protected
viewModel.passwordProtectedRooms.insert(roomName)
// Try to join with password - should be accepted tentatively for empty room
let success = viewModel.joinRoom(roomName, password: password)
XCTAssertTrue(success, "Should accept tentative access to empty password-protected room")
XCTAssertNotNil(viewModel.roomKeys[roomName], "Should store key tentatively")
XCTAssertEqual(viewModel.roomPasswords[roomName], password, "Should store password tentatively")
// Should have a system message explaining tentative access
let hasSystemMessage = viewModel.messages.contains { $0.sender == "system" && $0.content.contains("waiting for encrypted messages to verify password") }
XCTAssertTrue(hasSystemMessage, "Should add system message explaining tentative access")
}
func testJoinPasswordProtectedRoomWithMessages() {
let roomName = "#secure"
let correctPassword = "correct123"
let wrongPassword = "wrong456"
let testMessage = "Test encrypted message"
// First, create the room and set password as creator
let _ = viewModel.joinRoom(roomName)
viewModel.setRoomPassword(correctPassword, for: roomName)
// Simulate an encrypted message in the room
let key = viewModel.roomKeys[roomName]!
guard let messageData = testMessage.data(using: .utf8) else {
XCTFail("Failed to convert message to data")
return
}
do {
let sealedBox = try AES.GCM.seal(messageData, using: key)
let encryptedData = sealedBox.combined!
let encryptedMsg = BitchatMessage(
sender: "alice",
content: "[Encrypted message - password required]",
timestamp: Date(),
isRelay: false,
originalSender: nil,
isPrivate: false,
recipientNickname: nil,
senderPeerID: "alice123",
mentions: nil,
room: roomName,
encryptedContent: encryptedData,
isEncrypted: true
)
// Add to room messages
viewModel.roomMessages[roomName] = [encryptedMsg]
// Clear keys to simulate another user
viewModel.roomKeys.removeValue(forKey: roomName)
viewModel.roomPasswords.removeValue(forKey: roomName)
// Try to join with wrong password
let wrongSuccess = viewModel.joinRoom(roomName, password: wrongPassword)
XCTAssertFalse(wrongSuccess, "Should reject wrong password")
// Try to join with correct password
let correctSuccess = viewModel.joinRoom(roomName, password: correctPassword)
XCTAssertTrue(correctSuccess, "Should accept correct password")
XCTAssertNotNil(viewModel.roomKeys[roomName], "Should store key for correct password")
} catch {
XCTFail("Encryption failed: \(error)")
}
}
// MARK: - Password Verification Tests
func testEncryptDecryptRoomMessage() {
let roomName = "#crypto"
let password = "cryptoKey"
let testMessage = "This is a secret message"
// Derive key
let key = deriveRoomKey(from: password, roomName: roomName)
// Encrypt
guard let messageData = testMessage.data(using: .utf8) else {
XCTFail("Failed to convert message to data")
return
}
do {
let sealedBox = try AES.GCM.seal(messageData, using: key)
let encryptedData = sealedBox.combined!
// Store key and decrypt
viewModel.roomKeys[roomName] = key
let decrypted = viewModel.decryptRoomMessage(encryptedData, room: roomName)
XCTAssertEqual(decrypted, testMessage, "Decrypted message should match original")
} catch {
XCTFail("Encryption failed: \(error)")
}
}
func testWrongPasswordFailsDecryption() {
let roomName = "#secure"
let correctPassword = "correct"
let wrongPassword = "wrong"
let testMessage = "Secret content"
// Encrypt with correct password
let correctKey = deriveRoomKey(from: correctPassword, roomName: roomName)
guard let messageData = testMessage.data(using: .utf8) else {
XCTFail("Failed to convert message to data")
return
}
do {
let sealedBox = try AES.GCM.seal(messageData, using: correctKey)
let encryptedData = sealedBox.combined!
// Try to decrypt with wrong password
let wrongKey = deriveRoomKey(from: wrongPassword, roomName: roomName)
let decrypted = viewModel.decryptRoomMessage(encryptedData, room: roomName, testKey: wrongKey)
XCTAssertNil(decrypted, "Wrong password should fail to decrypt")
} catch {
XCTFail("Encryption failed: \(error)")
}
}
// MARK: - Room Creator Tests
func testOnlyCreatorCanSetPassword() {
let roomName = "#owned"
let password = "ownerOnly"
// Join room (becomes creator)
let _ = viewModel.joinRoom(roomName)
// Set password as creator
viewModel.setRoomPassword(password, for: roomName)
XCTAssertTrue(viewModel.passwordProtectedRooms.contains(roomName))
// Simulate another user trying to set password
viewModel.roomCreators[roomName] = "otherUser123"
viewModel.setRoomPassword("hackerPassword", for: roomName)
// Password should not change
XCTAssertEqual(viewModel.roomPasswords[roomName], password, "Non-creator should not be able to change password")
}
func testCreatorCanRemovePassword() {
let roomName = "#changeable"
let password = "temporary"
// Create protected room
let _ = viewModel.joinRoom(roomName)
viewModel.setRoomPassword(password, for: roomName)
XCTAssertTrue(viewModel.passwordProtectedRooms.contains(roomName))
// Remove password
viewModel.removeRoomPassword(for: roomName)
XCTAssertFalse(viewModel.passwordProtectedRooms.contains(roomName))
XCTAssertNil(viewModel.roomKeys[roomName])
XCTAssertNil(viewModel.roomPasswords[roomName])
}
// MARK: - Message Handling Tests
func testReceiveEncryptedMessageWithoutKey() {
let roomName = "#encrypted"
// Join room without password
let _ = viewModel.joinRoom(roomName)
// Simulate receiving encrypted message
let encryptedMessage = BitchatMessage(
sender: "alice",
content: "[Encrypted message - password required]",
timestamp: Date(),
isRelay: false,
originalSender: nil,
isPrivate: false,
recipientNickname: nil,
senderPeerID: "alice123",
mentions: nil,
room: roomName,
encryptedContent: Data([1, 2, 3, 4]), // dummy encrypted data
isEncrypted: true
)
viewModel.didReceiveMessage(encryptedMessage)
// Should mark room as password protected
XCTAssertTrue(viewModel.passwordProtectedRooms.contains(roomName))
// Should add system message
let roomMessages = viewModel.roomMessages[roomName] ?? []
let hasSystemMessage = roomMessages.contains { $0.sender == "system" && $0.content.contains("password protected") }
XCTAssertTrue(hasSystemMessage, "Should add system message about password protection")
}
// MARK: - Command Tests
func testJoinCommand() {
let input = "/join #testroom"
viewModel.sendMessage(input)
XCTAssertTrue(viewModel.joinedRooms.contains("#testroom"))
XCTAssertEqual(viewModel.currentRoom, "#testroom")
}
func testJoinCommandAlias() {
let input = "/j #quick"
viewModel.sendMessage(input)
XCTAssertTrue(viewModel.joinedRooms.contains("#quick"))
XCTAssertEqual(viewModel.currentRoom, "#quick")
}
func testInvalidRoomName() {
let input = "/j #invalid-room!"
viewModel.sendMessage(input)
XCTAssertFalse(viewModel.joinedRooms.contains("#invalid-room!"))
// Should have system message about invalid name
let hasErrorMessage = viewModel.messages.contains { $0.sender == "system" && $0.content.contains("Invalid room name") }
XCTAssertTrue(hasErrorMessage)
}
// MARK: - Key Commitment Tests
func testKeyCommitmentVerification() {
let roomName = "#commitment"
let password = "testpass123"
// Join and set password
let _ = viewModel.joinRoom(roomName)
viewModel.setRoomPassword(password, for: roomName)
// Verify key commitment was stored
XCTAssertNotNil(viewModel.roomKeyCommitments[roomName], "Should store key commitment")
// Simulate another user with the stored commitment
let commitment = viewModel.roomKeyCommitments[roomName]!
viewModel.roomKeys.removeValue(forKey: roomName)
viewModel.roomPasswords.removeValue(forKey: roomName)
// Manually set the commitment as if received from network
viewModel.roomKeyCommitments[roomName] = commitment
// Try with wrong password - should fail immediately
let wrongSuccess = viewModel.joinRoom(roomName, password: "wrongpass")
XCTAssertFalse(wrongSuccess, "Should reject wrong password via commitment check")
// Try with correct password - should succeed
let correctSuccess = viewModel.joinRoom(roomName, password: password)
XCTAssertTrue(correctSuccess, "Should accept correct password via commitment check")
}
func testOwnershipTransfer() {
let roomName = "#transfertest"
let password = "ownerpass"
// Create room and set password
let _ = viewModel.joinRoom(roomName)
viewModel.setRoomPassword(password, for: roomName)
// Verify creator is set
XCTAssertEqual(viewModel.roomCreators[roomName], viewModel.meshService.myPeerID)
// Simulate transfer (in real app would use /transfer command)
let newOwnerID = "newowner123"
viewModel.roomCreators[roomName] = newOwnerID
// Verify ownership changed
XCTAssertEqual(viewModel.roomCreators[roomName], newOwnerID)
XCTAssertNotEqual(viewModel.roomCreators[roomName], viewModel.meshService.myPeerID)
}
}
// MARK: - Helper Extensions for Testing
extension PasswordProtectedRoomTests {
// Helper method to derive room key for testing
// This duplicates the logic from ChatViewModel for testing purposes
func deriveRoomKey(from password: String, roomName: String) -> SymmetricKey {
let salt = roomName.data(using: .utf8)!
let iterations = 100000
let keyLength = 32
var derivedKey = Data(count: keyLength)
let passwordData = password.data(using: .utf8)!
_ = derivedKey.withUnsafeMutableBytes { derivedKeyBytes in
salt.withUnsafeBytes { saltBytes in
passwordData.withUnsafeBytes { passwordBytes in
CCKeyDerivationPBKDF(
CCPBKDFAlgorithm(kCCPBKDF2),
passwordBytes.baseAddress, passwordData.count,
saltBytes.baseAddress, salt.count,
CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA256),
UInt32(iterations),
derivedKeyBytes.baseAddress, keyLength
)
}
}
}
return SymmetricKey(data: derivedKey)
}
}
+30
View File
@@ -47,3 +47,33 @@ targets:
CODE_SIGNING_ALLOWED: YES
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS: YES
bitchatTests_iOS:
type: bundle.unit-test
platform: iOS
sources:
- bitchatTests
dependencies:
- target: bitchat_iOS
settings:
PRODUCT_BUNDLE_IDENTIFIER: chat.bitchat.tests
INFOPLIST_FILE: bitchatTests/Info.plist
SWIFT_VERSION: 5.0
IPHONEOS_DEPLOYMENT_TARGET: 16.0
TEST_HOST: $(BUILT_PRODUCTS_DIR)/bitchat.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/bitchat
BUNDLE_LOADER: $(TEST_HOST)
bitchatTests_macOS:
type: bundle.unit-test
platform: macOS
sources:
- bitchatTests
dependencies:
- target: bitchat_macOS
settings:
PRODUCT_BUNDLE_IDENTIFIER: chat.bitchat.tests
INFOPLIST_FILE: bitchatTests/Info.plist
SWIFT_VERSION: 5.0
MACOSX_DEPLOYMENT_TARGET: 13.0
TEST_HOST: $(BUILT_PRODUCTS_DIR)/bitchat.app/Contents/MacOS/bitchat
BUNDLE_LOADER: $(TEST_HOST)