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
+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
+175 -66
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)
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
HStack(spacing: 4) {
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)
Button(action: {
viewModel.switchToRoom(nil)
}) {
HStack(spacing: 4) {
Image(systemName: "chevron.left")
.font(.system(size: 12))
Text("back")
.font(.system(size: 14, design: .monospaced))
}
.buttonStyle(.plain)
Spacer()
// Password button for room creator
if viewModel.roomCreators[currentRoom] == viewModel.meshService.myPeerID || viewModel.roomCreators[currentRoom] == nil {
.foregroundColor(textColor)
}
.buttonStyle(.plain)
Spacer()
Button(action: {
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
showSidebar.toggle()
sidebarDragOffset = 0
}
}) {
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()
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,7 +502,57 @@ struct ContentView: View {
}
private var inputView: some View {
HStack(alignment: .center, spacing: 4) {
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)> →")
.font(.system(size: 12, weight: .medium, design: .monospaced))
@@ -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,9 +668,17 @@ struct ContentView: View {
ForEach(Array(viewModel.joinedRooms).sorted(), id: \.self) { room in
Button(action: {
viewModel.switchToRoom(room)
withAnimation(.spring()) {
showSidebar = false
// 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 {
@@ -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,14 +721,18 @@ struct ContentView: View {
passwordInputRoom = room
}
}) {
Image(systemName: viewModel.passwordProtectedRooms.contains(room) ? "lock.open" : "lock")
.font(.system(size: 10))
.foregroundColor(secondaryTextColor)
.padding(4)
.overlay(
RoundedRectangle(cornerRadius: 4)
.stroke(secondaryTextColor.opacity(0.5), lineWidth: 1)
)
HStack(spacing: 2) {
Image(systemName: viewModel.passwordProtectedRooms.contains(room) ? "lock.fill" : "lock")
.font(.system(size: 10))
}
.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(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)
}
}
}