feat: Initial MVP with home server identity model

- User model with Ed25519 keypair identity
- Register/login on home server (creates keypair)
- Join guest servers with signature verification
- Public endpoints for cross-server identity
- Channel and message CRUD
- JWT authentication middleware

Architecture: Home server model, no federation, client-driven sync
This commit is contained in:
Z User committed 2026-02-19 23:29:52 +00:00
commit 6a8977c4c3
14 files changed
+1494

No files matched your search

+294
View File
@@ -0,0 +1,294 @@
import 'package:chat_server/models.dart';
import 'package:chat_server/objectbox.g.dart';
import 'package:relic/relic.dart';
import '../auth/auth_middleware.dart';
import '../auth/crypto_service.dart';
import '../utils/extensions.dart';
/// Register on HOME server
/// Creates user with keypair for identity
Future<Response> 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
final query = userBox.query(User_.username.equals(username)).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 = User(
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<Response> 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(User_.username.equals(username)).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<Response> 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?;
final username = body['username'] as String?;
// Validate required fields
if (userId == null || homeServer == null || timestamp == null || signature == null) {
return Response.badRequest(
body: Body.fromString('userId, homeServer, timestamp, and signature required'),
);
}
// 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.'),
);
}
// Check if user already exists on this server
final existingQuery = userBox.query(User_.userId.equals(userId)).build();
final existingUser = existingQuery.findFirst();
existingQuery.close();
if (existingUser != null) {
// User already joined - just return a new token
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,
);
}
// Fetch public key from home server
final publicKey = await fetchPublicKeyFromHomeServer(homeServer, username ?? userId.split('@').first);
if (publicKey == null) {
return Response.badRequest(
body: Body.fromString('Could not verify identity with home server'),
);
}
// Verify signature
// Signature is over: "{userId}:{timestamp}"
final messageToVerify = '$userId:$timestamp';
final isValid = CryptoService.verify(messageToVerify, signature, publicKey);
if (!isValid) {
return Response.forbidden(
body: Body.fromString('Invalid signature'),
);
}
// Create local shadow account
final user = User(
userId: userId,
username: username ?? userId.split('@').first,
publicKey: publicKey,
isLocal: false,
homeServer: homeServer,
roles: ['member'],
);
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 key from user's home server
Future<String?> fetchPublicKeyFromHomeServer(
String homeServer,
String username,
) async {
try {
// Remove trailing slash
final baseUrl = homeServer.replaceAll(RegExp(r'/$'), '');
// Use http client to fetch
// Endpoint: GET /.well-known/users/{username}
// Returns: { publicKey, userId, ... }
// For now, return null to indicate we need http client
// This will be implemented with actual HTTP client
print('Would fetch: $baseUrl/.well-known/users/$username');
// TODO: Implement actual HTTP request
// final response = await http.get(Uri.parse('$baseUrl/.well-known/users/$username'));
// if (response.statusCode == 200) {
// final data = jsonDecode(response.body);
// return data['publicKey'] as String;
// }
return null;
} catch (e) {
print('Error fetching public key: $e');
return null;
}
}
/// Get current user's profile
Future<Response> getMeHandler(Request req) async {
final userId = userIdProperty[req];
if (userId == null) {
return Response.unauthorized(
body: Body.fromString('Not authenticated'),
);
}
final query = userBox.query(User_.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,
);
}