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:
commit
6a8977c4c3
14 files changed
+1494
No files matched your search
@@ -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,
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user