import 'dart:convert'; import 'dart:io'; import 'package:chat_server/models.dart'; import 'package:chat_server/objectbox.g.dart'; import 'package:relic/relic.dart'; import '../../config/config.dart'; import '../auth/auth_middleware.dart'; import '../auth/crypto_service.dart'; import '../state/app_state.dart'; import '../utils/extensions.dart'; class RemoteIdentity { final String userId; final String username; final String publicKey; final String? displayName; final String? avatarUrl; final String? bio; RemoteIdentity({ required this.userId, required this.username, required this.publicKey, this.displayName, this.avatarUrl, this.bio, }); } class ParsedUserId { final String username; final String host; ParsedUserId({ required this.username, required this.host, }); } /// Register on HOME server /// Creates user with keypair for identity Future registerHandler(Request req) async { final body = await req.asJson(); final username = (body['username'] as String?)?.trim(); final password = body['password'] as String?; if (username == null || username.isEmpty) { return Response.badRequest( body: Body.fromString('Username required'), ); } if (password == null || password.length < 6) { return Response.badRequest( body: Body.fromString('Password must be at least 6 characters'), ); } // Check if username exists on this home server final query = userBox .query( ChatUser_.username.equals(username) & ChatUser_.isLocal.equals(true), ) .build(); final existing = query.findFirst(); query.close(); if (existing != null) { return Response.badRequest( body: Body.fromString('Username already taken'), ); } // Generate keypair for identity final keyPair = CryptoService.generateKeyPair(); // Encrypt private key with password final encryptedKey = CryptoService.encryptPrivateKey( keyPair.privateKey, password, ); // Hash password final passwordHash = AuthService.hashPassword(password); // Create user with userId format: "username@this.server" final userId = '${username}@${Config.serverHost}'; final user = ChatUser( userId: userId, username: username, publicKey: keyPair.publicKey, isLocal: true, passwordHash: passwordHash, encryptedPrivateKey: encryptedKey.encryptedData, keyPairSalt: encryptedKey.salt, roles: ['member'], ); final localId = userBox.put(user); // Generate token final token = AuthService.generateToken(userId, username); return Response.ok( body: { 'token': token, 'userId': userId, 'username': username, 'publicKey': keyPair.publicKey, 'encryptedPrivateKey': encryptedKey.encryptedData, 'keyPairSalt': encryptedKey.salt, 'localId': localId, }.toBody, ); } /// Login on HOME server /// Returns token + encrypted keypair for device sync Future loginHandler(Request req) async { final body = await req.asJson(); final username = body['username'] as String?; final password = body['password'] as String?; if (username == null || password == null) { return Response.badRequest( body: Body.fromString('Username and password required'), ); } final query = userBox .query( ChatUser_.username.equals(username) & ChatUser_.isLocal.equals(true), ) .build(); final user = query.findFirst(); query.close(); if (user == null) { return Response.unauthorized( body: Body.fromString('Invalid credentials'), ); } // Check if this is actually their home server if (!user.isLocal) { return Response.badRequest( body: Body.fromString('This is not your home server. Login there instead.'), ); } // Verify password if (!AuthService.verifyPassword(password, user.passwordHash!)) { return Response.unauthorized( body: Body.fromString('Invalid credentials'), ); } // Generate token final token = AuthService.generateToken(user.userId, user.username); return Response.ok( body: { 'token': token, 'userId': user.userId, 'username': user.username, 'displayName': user.displayName, 'avatarUrl': user.avatarUrl, 'publicKey': user.publicKey, 'encryptedPrivateKey': user.encryptedPrivateKey, 'keyPairSalt': user.keyPairSalt, 'localId': user.id, }.toBody, ); } /// Join a GUEST server using signature verification /// No password required - proves identity via private key signature Future joinServerHandler(Request req) async { final body = await req.asJson(); final userId = body['userId'] as String?; // "alice@homeserver.com" final homeServer = body['homeServer'] as String?; // "https://homeserver.com" final timestamp = body['timestamp'] as int?; final signature = body['signature'] as String?; // Validate required fields if (userId == null || timestamp == null || signature == null) { return Response.badRequest( body: Body.fromString( 'userId, timestamp, and signature required (homeServer optional)'), ); } final parsedUser = parseUserId(userId); if (parsedUser == null) { return Response.badRequest( body: Body.fromString('Invalid userId format. Expected username@host'), ); } final resolvedHomeServer = normalizeHomeServer(homeServer ?? parsedUser.host); if (resolvedHomeServer == null) { return Response.badRequest( body: Body.fromString('Invalid homeServer URL'), ); } if (!homeServerMatchesUserIdHost(resolvedHomeServer, parsedUser.host)) { return Response.badRequest( body: Body.fromString('homeServer does not match userId host'), ); } // Check timestamp is recent (within 5 minutes) final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; if ((now - timestamp).abs() > 300) { return Response.badRequest( body: Body.fromString('Timestamp expired. Please sync your clock.'), ); } // Fetch current identity from home server final identity = await fetchIdentityFromHomeServer( resolvedHomeServer, parsedUser.username, ); if (identity == null) { return Response.badRequest( body: Body.fromString('Could not verify identity with home server'), ); } // Ensure fetched identity matches what caller claims if (identity.userId != userId || identity.username != parsedUser.username) { return Response.forbidden( body: Body.fromString('Identity mismatch from home server'), ); } // Verify signature // Signature is over: "{userId}:{timestamp}" final messageToVerify = '$userId:$timestamp'; final isValid = CryptoService.verify(messageToVerify, signature, identity.publicKey); if (!isValid) { return Response.forbidden( body: Body.fromString('Invalid signature'), ); } // Check if user already exists on this server final existingQuery = userBox.query(ChatUser_.userId.equals(userId)).build(); final existingUser = existingQuery.findFirst(); existingQuery.close(); if (existingUser != null) { existingUser.username = identity.username; existingUser.displayName = identity.displayName; existingUser.avatarUrl = identity.avatarUrl; existingUser.bio = identity.bio; existingUser.publicKey = identity.publicKey; existingUser.homeServer = resolvedHomeServer; existingUser.lastSyncedAt = DateTime.now(); userBox.put(existingUser); final token = AuthService.generateToken(existingUser.userId, existingUser.username); userLocalIdProperty[req] = existingUser.id; return Response.ok( body: { 'token': token, 'userId': existingUser.userId, 'username': existingUser.username, 'localId': existingUser.id, 'alreadyJoined': true, }.toBody, ); } // Create local shadow account final user = ChatUser( userId: userId, username: identity.username, displayName: identity.displayName, avatarUrl: identity.avatarUrl, bio: identity.bio, publicKey: identity.publicKey, isLocal: false, homeServer: resolvedHomeServer, roles: ['member'], lastSyncedAt: DateTime.now(), ); final localId = userBox.put(user); // Generate local token final token = AuthService.generateToken(userId, user.username); return Response.ok( body: { 'token': token, 'userId': userId, 'username': user.username, 'localId': localId, 'alreadyJoined': false, }.toBody, ); } /// Fetch public identity from user's home server Future fetchIdentityFromHomeServer( String homeServer, String username, ) async { final client = HttpClient(); client.connectionTimeout = const Duration(seconds: 5); try { final baseUrl = homeServer.replaceAll(RegExp(r'/$'), ''); final endpoint = Uri.parse( '$baseUrl/.well-known/users/${Uri.encodeComponent(username)}'); final request = await client.getUrl(endpoint); request.headers.set(HttpHeaders.acceptHeader, 'application/json'); final response = await request.close(); if (response.statusCode != HttpStatus.ok) { return null; } final responseBody = await utf8.decodeStream(response); final dynamic decoded = jsonDecode(responseBody); if (decoded is! Map) { return null; } final data = Map.from(decoded); final userId = data['userId'] as String?; final fetchedUsername = data['username'] as String?; final publicKey = data['publicKey'] as String?; if (userId == null || fetchedUsername == null || publicKey == null) { return null; } return RemoteIdentity( userId: userId, username: fetchedUsername, publicKey: publicKey, displayName: data['displayName'] as String?, avatarUrl: data['avatarUrl'] as String?, bio: data['bio'] as String?, ); } catch (e) { print('Error fetching public key: $e'); return null; } finally { client.close(force: true); } } ParsedUserId? parseUserId(String userId) { final parts = userId.split('@'); if (parts.length != 2) { return null; } final username = parts[0].trim(); final host = parts[1].trim(); if (username.isEmpty || host.isEmpty) { return null; } return ParsedUserId(username: username, host: host); } String? normalizeHomeServer(String homeServerOrHost) { final trimmed = homeServerOrHost.trim(); if (trimmed.isEmpty) { return null; } final hasScheme = trimmed.contains('://'); final candidate = hasScheme ? trimmed : '${defaultSchemeForHost(trimmed)}://$trimmed'; final uri = Uri.tryParse(candidate); if (uri == null || uri.host.isEmpty) { return null; } return uri .replace(path: '', query: null, fragment: null) .toString() .replaceAll(RegExp(r'/$'), ''); } String defaultSchemeForHost(String host) { final lowerHost = host.toLowerCase(); if (lowerHost.startsWith('localhost') || lowerHost.startsWith('127.') || lowerHost.startsWith('[::1]')) { return 'http'; } return 'https'; } bool homeServerMatchesUserIdHost(String homeServerUrl, String userIdHost) { final uri = Uri.tryParse(homeServerUrl); if (uri == null || uri.host.isEmpty) { return false; } final normalizedUserIdHost = userIdHost.trim().toLowerCase(); final urlHost = uri.host.toLowerCase(); final urlHostWithPort = uri.hasPort ? '$urlHost:${uri.port}' : urlHost; if (normalizedUserIdHost == urlHostWithPort) { return true; } if (!uri.hasPort) { final defaultPort = uri.scheme == 'http' ? 80 : 443; if (normalizedUserIdHost == '$urlHost:$defaultPort') { return true; } } return false; } /// Get current user's profile Future getMeHandler(Request req) async { final userId = userIdProperty[req]; if (userId == null) { return Response.unauthorized( body: Body.fromString('Not authenticated'), ); } final query = userBox.query(ChatUser_.userId.equals(userId)).build(); final user = query.findFirst(); query.close(); if (user == null) { return Response.notFound( body: Body.fromString('User not found'), ); } return Response.ok( body: { 'userId': user.userId, 'username': user.username, 'displayName': user.displayName, 'avatarUrl': user.avatarUrl, 'bio': user.bio, 'isLocal': user.isLocal, 'homeServer': user.homeServer, 'roles': user.roles, 'createdAt': user.createdAt.toIso8601String(), }.toBody, ); }