From f57fe229fc431a473c30c8bebf2c7c2d6610a775 Mon Sep 17 00:00:00 2001 From: Z User Date: Thu, 19 Feb 2026 23:07:14 +0000 Subject: [PATCH] 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 --- .gitignore | 3 +- ARCHITECTURE.md | 248 +++++++++++++++++++++ lib/config/config.dart | 14 ++ lib/models.dart | 126 +++++++++++ lib/server.dart | 86 ++++++++ lib/src/auth/auth_middleware.dart | 45 ++++ lib/src/auth/crypto_service.dart | 161 ++++++++++++++ lib/src/handlers/auth_handlers.dart | 294 +++++++++++++++++++++++++ lib/src/handlers/channel_handlers.dart | 82 +++++++ lib/src/handlers/message_handlers.dart | 238 ++++++++++++++++++++ lib/src/handlers/public_handlers.dart | 85 +++++++ lib/src/utils/extensions.dart | 25 +++ pubspec.yaml | 21 ++ 13 files changed, 1427 insertions(+), 1 deletion(-) create mode 100644 ARCHITECTURE.md create mode 100644 lib/config/config.dart create mode 100644 lib/models.dart create mode 100644 lib/server.dart create mode 100644 lib/src/auth/auth_middleware.dart create mode 100644 lib/src/auth/crypto_service.dart create mode 100644 lib/src/handlers/auth_handlers.dart create mode 100644 lib/src/handlers/channel_handlers.dart create mode 100644 lib/src/handlers/message_handlers.dart create mode 100644 lib/src/handlers/public_handlers.dart create mode 100644 lib/src/utils/extensions.dart create mode 100644 pubspec.yaml diff --git a/.gitignore b/.gitignore index a5d008a..8a50a45 100755 --- a/.gitignore +++ b/.gitignore @@ -48,4 +48,5 @@ prompt server.log # Skills directory -/skills/ \ No newline at end of file +/skills/download/ +*.g.dart diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..2c332ac --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,248 @@ +# Chat Server Architecture - Complete Reference + +## Project Overview + +Self-hosted Discord alternative with decentralized identity. Each user has a "home server" where their identity lives, and can join other "guest servers" seamlessly using cryptographic signatures. + +## Core Architecture Decisions + +### Home Server Model +- **NO identity server** - just use one regular server as "home" +- **NO federation protocol** - servers don't talk to each other +- **Client does everything** - syncs, cross-server requests, profile updates + +### Identity System +- Ed25519 keypair generated on registration +- Private key = proof of identity +- Public key stored on home server at `/.well-known/users/:username` +- userId format: `username@serverhost` + +### Single Sign-On Flow +1. User registers/logs in ONCE on home server +2. Gets JWT token + encrypted keypair +3. To join other servers: sign a message with private key +4. Other server verifies signature against home server's public key +5. No password ever sent to guest servers + +--- + +## What's Implemented + +### ✅ Done +- User model with keypair, home server reference +- Channel and Message models +- Crypto service (keypair generation, signing, verification) +- JWT authentication middleware +- Register handler (creates keypair) +- Login handler (returns encrypted keypair for device sync) +- Join server handler (signature-based verification - stub) +- Public endpoints for cross-server identity verification +- Channel CRUD +- Message CRUD with author caching + +### ❌ Not Implemented +- HTTP client for cross-server public key fetching +- WebSocket for real-time messages +- Profile sync (push updates to all servers) +- Sticker packs (future feature) +- File uploads +- Permissions system (roles exist but not enforced) +- Message editing/deletion permissions + +--- + +## Database Models + +```dart +// User - works for both HOME and GUEST servers +@Entity() +class User { + int id; + String userId; // "alice@homeserver.com" + String username; + String? displayName, avatarUrl, bio; + + String publicKey; // Ed25519, always stored + + // HOME SERVER ONLY: + String? passwordHash; + String? encryptedPrivateKey; // For device sync + String? keyPairSalt; + + bool isLocal; // true = this is home server + String? homeServer; // if guest, where is home? + List roles; // Server-specific + DateTime createdAt, lastSyncedAt; +} + +@Entity() +class Channel { + int id; + String name; + String? description; + DateTime createdAt; +} + +@Entity() +class Message { + int id; + int channelId; + + // Author info (includes cross-server fetch capability) + int authorLocalId; + String authorUserId; // "alice@home.server" + String? authorHomeServer; + + // Cached snapshot at message time + String authorUsername; + String? authorAvatarUrl; + + String content; + DateTime createdAt, editedAt; +} +``` + +--- + +## API Endpoints + +### Public (No Auth) +``` +POST /auth/register → Create account on HOME server (generates keypair) +POST /auth/login → Login to HOME server (returns keypair backup) +POST /auth/join → Join THIS server with signature (no password) + +GET /.well-known/users/:username → Public profile + public key +GET /.well-known/verify/:userId → Verify user exists +GET /.well-known/server → Server info +``` + +### Protected (JWT Required) +``` +GET /api/me → Current user profile + +GET /api/channels → List channels +POST /api/channels → Create channel +GET /api/channels/:id → Get channel + +GET /api/messages?channelId=X → Get messages +POST /api/messages → Send message +PUT /api/messages/:id → Edit message +DELETE /api/messages/:id → Delete message +``` + +--- + +## Key Files + +``` +lib/ +├── server.dart # Entry point, routes, ObjectBox init +├── models.dart # User, Channel, Message entities +├── objectbox.g.dart # Generated +│ +├── config/config.dart # Server host, JWT secret, port +│ +└── src/ + ├── auth/ + │ ├── crypto_service.dart # Keypair, signing, encryption + │ └── auth_middleware.dart # JWT verification + │ + ├── handlers/ + │ ├── auth_handlers.dart # register, login, join, me + │ ├── public_handlers.dart # .well-known/* + │ ├── channel_handlers.dart + │ └── message_handlers.dart + │ + └── utils/extensions.dart # Request/Response helpers +``` + +--- + +## Join Server Flow (Needs Implementation) + +``` +CLIENT GUEST SERVER HOME SERVER + │ │ │ + │ POST /auth/join │ │ + │ {userId, homeServer, timestamp, │ │ + │ signature} │ │ + │─────────────────────────────────────►│ │ + │ │ │ + │ │ GET /.well-known/users/ │ + │ │ alice │ + │ │────────────────────────►│ + │ │ │ + │ │ {publicKey, ...} │ + │ │◄────────────────────────│ + │ │ │ + │ │ verify signature │ + │ │ create shadow account │ + │ │ │ + │ {token, localId} │ │ + │◄─────────────────────────────────────│ │ +``` + +The `fetchPublicKeyFromHomeServer()` function in `auth_handlers.dart` needs actual HTTP implementation. + +--- + +## Tech Stack + +- **Framework**: Relic 1.0 (Dart web server) +- **Database**: ObjectBox +- **Auth**: JWT + Ed25519 signatures +- **Crypto**: crypton package for Ed25519 +- **Password**: bcrypt + +--- + +## Design Decisions Rationale + +| Decision | Why | +|----------|-----| +| No identity server | Avoids hosting another service, one server = home | +| No server-to-server | Eliminates federation complexity, client handles everything | +| Keypair identity | Allows passwordless joins, proves identity cryptographically | +| Message author caching | Messages readable even if home server is down | +| userId format `user@server` | Simple, familiar, self-documenting | + +--- + +## Constraints + +- User CANNOT quit home server (that's where identity lives) +- User CANNOT change home server easily (migration = future feature) +- Home server MUST be online to join NEW servers +- Home server offline = can still use already-joined servers (cached keys) + +--- + +## Future Features (Out of Scope for MVP) + +1. **Sticker Packs** - Telegram-style, live on community servers, clients cache +2. **Home Server Migration** - Sign migration certificate, chain of trust +3. **File Uploads** - With cross-server URLs +4. **Message Forwarding** - Embed with link to original +5. **Permissions** - Role-based access to channels +6. **WebSocket** - Real-time message delivery + +--- + +## Config to Change + +```dart +// lib/config/config.dart +static const String serverHost = 'your-actual-domain.com'; // CHANGE THIS +static const String jwtSecret = 'use-env-variable'; // CHANGE THIS +``` + +--- + +## Next Steps + +1. Implement HTTP client in `fetchPublicKeyFromHomeServer()` +2. Add WebSocket for real-time +3. Add profile sync (client pushes to all servers on change) +4. Test cross-server join flow +5. Add proper error handling and logging diff --git a/lib/config/config.dart b/lib/config/config.dart new file mode 100644 index 0000000..a6cfdc4 --- /dev/null +++ b/lib/config/config.dart @@ -0,0 +1,14 @@ +class Config { + // Server settings + static const int port = 9870; + + // IMPORTANT: Set this to your actual server host/domain + // Used for generating userId: "username@serverHost" + static const String serverHost = 'localhost:9870'; + static const String serverName = 'Chat Server'; + + // JWT settings + // TODO: Move to environment variable in production + static const String jwtSecret = 'your_super_secret_key_change_in_production'; + static const Duration tokenExpiry = Duration(days: 7); +} diff --git a/lib/models.dart b/lib/models.dart new file mode 100644 index 0000000..84ca816 --- /dev/null +++ b/lib/models.dart @@ -0,0 +1,126 @@ +import 'package:objectbox/objectbox.dart'; + +/// User entity - works for both HOME and GUEST servers +/// +/// On HOME server: +/// - isLocal = true +/// - stores password, encrypted private key +/// - this is where identity lives +/// +/// On GUEST server: +/// - isLocal = false +/// - homeServer contains the URL of user's home +/// - publicKey cached for verification +@Entity() +class User { + @Id() + int id = 0; + + // Global identifier: "username@homeserver.com" + @Unique() + String userId; + + // Basic profile + @Index() + String username; + String? displayName; + String? avatarUrl; + String? bio; + + // Identity + String publicKey; // Ed25519 public key, always stored + + // HOME SERVER ONLY: Auth & key backup + String? passwordHash; + String? encryptedPrivateKey; // Encrypted with user's password + String? keyPairSalt; + + // Server relationship + bool isLocal; // true = this is their home server + String? homeServer; // if guest, where is their home? (e.g., "https://my.server.com") + + // Server-specific + List roles; // ["admin", "moderator", "member"] + + @Property(type: PropertyType.date) + DateTime createdAt; + + @Property(type: PropertyType.date) + DateTime? lastSyncedAt; // When profile was last refreshed from home + + User({ + required this.userId, + required this.username, + required this.publicKey, + required this.isLocal, + this.displayName, + this.avatarUrl, + this.bio, + this.passwordHash, + this.encryptedPrivateKey, + this.keyPairSalt, + this.homeServer, + List? roles, + DateTime? createdAt, + DateTime? lastSyncedAt, + }) : roles = roles ?? ['member'], + createdAt = createdAt ?? DateTime.now(), + lastSyncedAt = lastSyncedAt; +} + +@Entity() +class Channel { + @Id() + int id = 0; + + @Unique() + String name; + + String? description; + + @Property(type: PropertyType.date) + DateTime createdAt; + + Channel({ + required this.name, + this.description, + DateTime? createdAt, + }) : createdAt = createdAt ?? DateTime.now(); +} + +@Entity() +class Message { + @Id() + int id = 0; + + int channelId; + + // Author info - includes enough for cross-server profile fetch + int authorLocalId; + String authorUserId; // "alice@homeserver.com" + String? authorHomeServer; // for profile fetching + + // Cached for display (snapshot at message time) + String authorUsername; + String? authorAvatarUrl; + + String content; + + @Property(type: PropertyType.date) + DateTime createdAt; + + @Property(type: PropertyType.date) + DateTime? editedAt; + + Message({ + required this.channelId, + required this.authorLocalId, + required this.authorUserId, + required this.authorUsername, + required this.content, + this.authorHomeServer, + this.authorAvatarUrl, + DateTime? createdAt, + this.editedAt, + }) : createdAt = createdAt ?? DateTime.now(); +} diff --git a/lib/server.dart b/lib/server.dart new file mode 100644 index 0000000..a25b0ea --- /dev/null +++ b/lib/server.dart @@ -0,0 +1,86 @@ +import 'package:chat_server/models.dart'; +import 'package:chat_server/objectbox.g.dart'; +import 'package:hotreloader/hotreloader.dart'; +import 'package:relic/relic.dart'; + +import 'config/config.dart'; +import 'src/auth/auth_middleware.dart'; +import 'src/handlers/auth_handlers.dart'; +import 'src/handlers/channel_handlers.dart'; +import 'src/handlers/message_handlers.dart'; +import 'src/handlers/public_handlers.dart'; +import 'src/utils/extensions.dart'; + +// Global ObjectBox store and boxes +late final Store store; +late final Box userBox; +late final Box channelBox; +late final Box messageBox; + +RelicApp? _currentApp; + +Future startServer() async { + await _currentApp?.close(); + + _currentApp = RelicApp() + // ============ PUBLIC ENDPOINTS (no auth) ============ + + // Auth + ..post('/auth/register', registerHandler) + ..post('/auth/login', loginHandler) + ..post('/auth/join', joinServerHandler) + + // Public identity verification (for cross-server) + ..get('/.well-known/users/', publicUserHandler) + ..get('/.well-known/verify/', publicVerifyHandler) + ..get('/.well-known/server', publicServerInfoHandler) + + // ============ PROTECTED ENDPOINTS (requires auth) ============ + ..use('/api', jwtMiddleware) + + // Current user + ..get('/api/me', getMeHandler) + + // Channels + ..get('/api/channels', getChannelsHandler) + ..post('/api/channels', createChannelHandler) + ..get('/api/channels/', getChannelHandler) + + // Messages + ..get('/api/messages', getMessagesHandler) + ..post('/api/messages', sendMessageHandler) + ..put('/api/messages/', editMessageHandler) + ..delete('/api/messages/', deleteMessageHandler) + + // ============ LOGGING & FALLBACK ============ + ..use('/', logRequests()) + ..fallback = respondWith((_) => Response.notFound()); + + print(''); + print('╔════════════════════════════════════════════╗'); + print('║ 🚀 Server starting... ║'); + print('║ Host: ${Config.serverHost.padRight(34)}║'); + print('║ URL: http://localhost:${Config.port.toString().padRight(26)}║'); + print('╚════════════════════════════════════════════╝'); + print(''); + + await _currentApp!.serve(port: Config.port); +} + +void main() async { + // Initialize ObjectBox + store = openStore(); + userBox = store.box(); + channelBox = store.box(); + messageBox = store.box(); + + // Enable hot reload in development + await HotReloader.create( + onAfterReload: (ctx) async { + print('🔄 Changes detected! Restarting server...'); + await startServer(); + }, + ); + + await startServer(); +} diff --git a/lib/src/auth/auth_middleware.dart b/lib/src/auth/auth_middleware.dart new file mode 100644 index 0000000..427883c --- /dev/null +++ b/lib/src/auth/auth_middleware.dart @@ -0,0 +1,45 @@ +import 'package:relic/relic.dart'; + +import '../../config/config.dart'; +import '../utils/extensions.dart'; +import 'crypto_service.dart'; + +final userIdProperty = ContextProperty('userId'); +final usernameProperty = ContextProperty('username'); +final userLocalIdProperty = ContextProperty('userLocalId'); + +/// JWT authentication middleware +final jwtMiddleware = createMiddleware( + onRequest: (request) { + final authHeader = request.headers.authorization; + + if (authHeader is! BearerAuthorizationHeader) { + return Response.unauthorized( + body: Body.fromString('Bearer token required'), + ); + } + + final payload = AuthService.verifyToken(authHeader.token); + if (payload == null) { + return Response.forbidden( + body: Body.fromString('Invalid or expired token'), + ); + } + + final userId = payload['sub'] as String?; + final username = payload['username'] as String?; + + if (userId == null) { + return Response.forbidden( + body: Body.fromString('Invalid token payload'), + ); + } + + userIdProperty[request] = userId; + if (username != null) { + usernameProperty[request] = username; + } + + return null; // Continue to handler + }, +); diff --git a/lib/src/auth/crypto_service.dart b/lib/src/auth/crypto_service.dart new file mode 100644 index 0000000..bd3457e --- /dev/null +++ b/lib/src/auth/crypto_service.dart @@ -0,0 +1,161 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:bcrypt/bcrypt.dart'; +import 'package:collection/collection.dart'; +import 'package:crypto/crypto.dart'; +import 'package:crypton/crypton.dart'; +import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart'; + +import '../config/config.dart'; + +class KeyPair { + final String publicKey; + final String privateKey; + + KeyPair({required this.publicKey, required this.privateKey}); + + Map toJson() => { + 'publicKey': publicKey, + 'privateKey': privateKey, + }; + + factory KeyPair.fromJson(Map json) { + return KeyPair( + publicKey: json['publicKey'] as String, + privateKey: json['privateKey'] as String, + ); + } +} + +class CryptoService { + /// Generate a new Ed25519 key pair + static KeyPair generateKeyPair() { + final keyPair = ECKeypair.fromRandom(); + return KeyPair( + publicKey: keyPair.publicKey.toString(), + privateKey: keyPair.privateKey.toString(), + ); + } + + /// Sign data with private key + static String sign(String data, String privateKey) { + final privKey = ECPrivateKey.fromString(privateKey); + final signature = privKey.createSignature(data); + return signature.toString(); + } + + /// Verify signature with public key + static bool verify(String data, String signature, String publicKey) { + try { + final pubKey = ECPublicKey.fromString(publicKey); + final sig = ECSignature.fromString(signature); + return pubKey.verifySignature(data, sig); + } catch (_) { + return false; + } + } + + /// Encrypt private key with password (for storage/backup) + static EncryptedKey encryptPrivateKey(String privateKey, String password) { + final salt = BCrypt.gensalt(); + final key = _deriveKey(password, salt); + final encrypted = _xorEncrypt(privateKey, key); + return EncryptedKey( + encryptedData: encrypted, + salt: salt, + ); + } + + /// Decrypt private key with password + static String? decryptPrivateKey( + String encryptedData, String salt, String password) { + try { + final key = _deriveKey(password, salt); + return _xorDecrypt(encryptedData, key); + } catch (_) { + return null; + } + } + + static String _deriveKey(String password, String salt) { + final bytes = utf8.encode(password + salt); + final hash = sha256.convert(bytes); + return hash.toString(); + } + + static String _xorEncrypt(String data, String key) { + final dataBytes = utf8.encode(data); + final keyBytes = utf8.encode(key); + final encrypted = []; + for (var i = 0; i < dataBytes.length; i++) { + encrypted.add(dataBytes[i] ^ keyBytes[i % keyBytes.length]); + } + return base64Encode(encrypted); + } + + static String _xorDecrypt(String encrypted, String key) { + final encryptedBytes = base64Decode(encrypted); + final keyBytes = utf8.encode(key); + final decrypted = []; + for (var i = 0; i < encryptedBytes.length; i++) { + decrypted.add(encryptedBytes[i] ^ keyBytes[i % keyBytes.length]); + } + return utf8.decode(decrypted); + } +} + +class EncryptedKey { + final String encryptedData; + final String salt; + + EncryptedKey({required this.encryptedData, required this.salt}); + + Map toJson() => { + 'encryptedData': encryptedData, + 'salt': salt, + }; +} + +class AuthService { + /// Generate JWT token for user + static String generateToken(String userId, String username) { + final payload = { + 'sub': userId, + 'username': username, + }; + + final jwt = JWT(payload); + return jwt.sign( + SecretKey(Config.jwtSecret), + expiresIn: Config.tokenExpiry, + ); + } + + /// Verify JWT token and return payload + static Map? verifyToken(String token) { + try { + final jwt = JWT.verify(token, SecretKey(Config.jwtSecret)); + return Map.from(jwt.payload as Map); + } catch (_) { + return null; + } + } + + /// Hash password with bcrypt + static String hashPassword(String password) { + return BCrypt.hashpw(password, BCrypt.gensalt()); + } + + /// Verify password against hash + static bool verifyPassword(String password, String hash) { + return BCrypt.checkpw(password, hash); + } + + /// Generate a nonce for signature verification + static String generateNonce() { + final bytes = List.generate(32, (_) => DateTime.now().microsecondsSinceEpoch % 256); + final randomBytes = sha256.convert(bytes as Uint8List); + return randomBytes.toString().substring(0, 32); + } +} diff --git a/lib/src/handlers/auth_handlers.dart b/lib/src/handlers/auth_handlers.dart new file mode 100644 index 0000000..ed5f92e --- /dev/null +++ b/lib/src/handlers/auth_handlers.dart @@ -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 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 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 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 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 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, + ); +} diff --git a/lib/src/handlers/channel_handlers.dart b/lib/src/handlers/channel_handlers.dart new file mode 100644 index 0000000..5661db0 --- /dev/null +++ b/lib/src/handlers/channel_handlers.dart @@ -0,0 +1,82 @@ +import 'package:chat_server/models.dart'; +import 'package:chat_server/objectbox.g.dart'; +import 'package:relic/relic.dart'; + +import '../utils/extensions.dart'; + +/// Get all channels +Future getChannelsHandler(Request req) async { + final channels = channelBox.getAll(); + + return Response.ok( + body: { + 'channels': channels + .map((c) => { + 'id': c.id, + 'name': c.name, + 'description': c.description, + 'createdAt': c.createdAt.toIso8601String(), + }) + .toList(), + }.toBody, + ); +} + +/// Create a channel (requires 'admin' role) +Future createChannelHandler(Request req) async { + final body = await req.asJson(); + final name = (body['name'] as String?)?.trim().toLowerCase().replaceAll(' ', '-'); + final description = body['description'] as String?; + + if (name == null || name.isEmpty) { + return Response.badRequest( + body: Body.fromString('Channel name required'), + ); + } + + // Check if channel exists + final existing = channelBox.query(Channel_.name.equals(name)).build().findFirst(); + if (existing != null) { + return Response.badRequest( + body: Body.fromString('Channel already exists'), + ); + } + + final channel = Channel(name: name, description: description); + final id = channelBox.put(channel); + + return Response.ok( + body: { + 'id': id, + 'name': name, + 'description': description, + 'createdAt': channel.createdAt.toIso8601String(), + }.toBody, + ); +} + +/// Get channel by ID +Future getChannelHandler(Request req, String channelIdStr) async { + final channelId = int.tryParse(channelIdStr); + if (channelId == null) { + return Response.badRequest( + body: Body.fromString('Invalid channel ID'), + ); + } + + final channel = channelBox.get(channelId); + if (channel == null) { + return Response.notFound( + body: Body.fromString('Channel not found'), + ); + } + + return Response.ok( + body: { + 'id': channel.id, + 'name': channel.name, + 'description': channel.description, + 'createdAt': channel.createdAt.toIso8601String(), + }.toBody, + ); +} diff --git a/lib/src/handlers/message_handlers.dart b/lib/src/handlers/message_handlers.dart new file mode 100644 index 0000000..da63d68 --- /dev/null +++ b/lib/src/handlers/message_handlers.dart @@ -0,0 +1,238 @@ +import 'package:chat_server/models.dart'; +import 'package:chat_server/objectbox.g.dart'; +import 'package:relic/relic.dart'; + +import '../auth/auth_middleware.dart'; +import '../utils/extensions.dart'; + +/// Get messages for a channel +/// Query params: channelId (required), limit, before +Future getMessagesHandler(Request req) async { + final channelIdStr = req.url.queryParameters['channelId']; + final limitStr = req.url.queryParameters['limit'] ?? '50'; + + if (channelIdStr == null) { + return Response.badRequest( + body: Body.fromString('channelId required'), + ); + } + + final channelId = int.tryParse(channelIdStr); + final limit = int.tryParse(limitStr) ?? 50; + + if (channelId == null) { + return Response.badRequest( + body: Body.fromString('Invalid channelId'), + ); + } + + // Check channel exists + final channel = channelBox.get(channelId); + if (channel == null) { + return Response.notFound( + body: Body.fromString('Channel not found'), + ); + } + + // Get messages + final query = messageBox + .query(Message_.channelId.equals(channelId)) + .order(Message_.createdAt, flags: Order.descending) + .build(); + + final messages = query.find(limit: limit); + query.close(); + + return Response.ok( + body: { + 'messages': messages + .map((m) => { + 'id': m.id, + 'content': m.content, + 'authorId': m.authorUserId, + 'authorUsername': m.authorUsername, + 'authorAvatarUrl': m.authorAvatarUrl, + 'authorHomeServer': m.authorHomeServer, + 'channelId': m.channelId, + 'createdAt': m.createdAt.toIso8601String(), + 'editedAt': m.editedAt?.toIso8601String(), + }) + .toList(), + 'channelId': channelId, + 'limit': limit, + }.toBody, + ); +} + +/// Send a message +Future sendMessageHandler(Request req) async { + final userId = userIdProperty[req]; + + if (userId == null) { + return Response.unauthorized( + body: Body.fromString('Not authenticated'), + ); + } + + final body = await req.asJson(); + final content = body['content'] as String?; + final channelId = body['channelId'] as int?; + + if (content == null || content.trim().isEmpty) { + return Response.badRequest( + body: Body.fromString('Content required'), + ); + } + + if (channelId == null) { + return Response.badRequest( + body: Body.fromString('channelId required'), + ); + } + + // Check channel exists + final channel = channelBox.get(channelId); + if (channel == null) { + return Response.notFound( + body: Body.fromString('Channel not found'), + ); + } + + // Get user info for caching + final userQuery = userBox.query(User_.userId.equals(userId)).build(); + final user = userQuery.findFirst(); + userQuery.close(); + + if (user == null) { + return Response.unauthorized( + body: Body.fromString('User not found'), + ); + } + + // Create message with cached author info + final message = Message( + channelId: channelId, + authorLocalId: user.id, + authorUserId: user.userId, + authorUsername: user.username, + authorAvatarUrl: user.avatarUrl, + authorHomeServer: user.homeServer, + content: content.trim(), + ); + + final id = messageBox.put(message); + + return Response.ok( + body: { + 'id': id, + 'content': message.content, + 'authorId': user.userId, + 'authorUsername': user.username, + 'authorAvatarUrl': user.avatarUrl, + 'channelId': channelId, + 'createdAt': message.createdAt.toIso8601String(), + }.toBody, + ); +} + +/// Edit a message +Future editMessageHandler(Request req, String messageIdStr) async { + final userId = userIdProperty[req]; + + if (userId == null) { + return Response.unauthorized( + body: Body.fromString('Not authenticated'), + ); + } + + final messageId = int.tryParse(messageIdStr); + if (messageId == null) { + return Response.badRequest( + body: Body.fromString('Invalid message ID'), + ); + } + + final message = messageBox.get(messageId); + if (message == null) { + return Response.notFound( + body: Body.fromString('Message not found'), + ); + } + + // Get user to verify ownership + final userQuery = userBox.query(User_.userId.equals(userId)).build(); + final user = userQuery.findFirst(); + userQuery.close(); + + if (user == null || message.authorLocalId != user.id) { + return Response.forbidden( + body: Body.fromString('You can only edit your own messages'), + ); + } + + final body = await req.asJson(); + final content = body['content'] as String?; + + if (content == null || content.trim().isEmpty) { + return Response.badRequest( + body: Body.fromString('Content required'), + ); + } + + message.content = content.trim(); + message.editedAt = DateTime.now(); + messageBox.put(message); + + return Response.ok( + body: { + 'id': message.id, + 'content': message.content, + 'editedAt': message.editedAt?.toIso8601String(), + }.toBody, + ); +} + +/// Delete a message +Future deleteMessageHandler(Request req, String messageIdStr) async { + final userId = userIdProperty[req]; + + if (userId == null) { + return Response.unauthorized( + body: Body.fromString('Not authenticated'), + ); + } + + final messageId = int.tryParse(messageIdStr); + if (messageId == null) { + return Response.badRequest( + body: Body.fromString('Invalid message ID'), + ); + } + + final message = messageBox.get(messageId); + if (message == null) { + return Response.notFound( + body: Body.fromString('Message not found'), + ); + } + + // Get user to verify ownership + final userQuery = userBox.query(User_.userId.equals(userId)).build(); + final user = userQuery.findFirst(); + userQuery.close(); + + if (user == null || message.authorLocalId != user.id) { + return Response.forbidden( + body: Body.fromString('You can only delete your own messages'), + ); + } + + messageBox.remove(messageId); + + return Response.ok( + body: { + 'deleted': true, + 'id': messageId, + }.toBody, + ); +} diff --git a/lib/src/handlers/public_handlers.dart b/lib/src/handlers/public_handlers.dart new file mode 100644 index 0000000..9ddd232 --- /dev/null +++ b/lib/src/handlers/public_handlers.dart @@ -0,0 +1,85 @@ +import 'package:chat_server/models.dart'; +import 'package:chat_server/objectbox.g.dart'; +import 'package:relic/relic.dart'; + +import '../utils/extensions.dart'; + +/// Public endpoint for cross-server identity verification +/// Called by other servers to verify a user's public key +/// +/// GET /.well-known/users/{username} +/// Returns: { userId, publicKey, username, displayName, avatarUrl } +Future publicUserHandler(Request req, String username) async { + final query = userBox.query( + User_.username.equals(username) & User_.isLocal.equals(true), + ).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, + 'publicKey': user.publicKey, + 'homeServer': Config.serverHost, + 'createdAt': user.createdAt.toIso8601String(), + }.toBody, + ); +} + +/// Public endpoint to verify a user exists +/// Lightweight check without full profile +/// +/// GET /.well-known/verify/{userId} +/// Returns: { exists, userId, publicKey } +Future publicVerifyHandler(Request req, String userId) async { + final query = userBox.query( + User_.userId.equals(userId) & User_.isLocal.equals(true), + ).build(); + + final user = query.findFirst(); + query.close(); + + if (user == null) { + return Response.ok( + body: { + 'exists': false, + 'userId': userId, + }.toBody, + ); + } + + return Response.ok( + body: { + 'exists': true, + 'userId': user.userId, + 'publicKey': user.publicKey, + 'username': user.username, + }.toBody, + ); +} + +/// Public server info +/// +/// GET /.well-known/server +/// Returns: { serverHost, serverName, version } +Future publicServerInfoHandler(Request req) async { + return Response.ok( + body: { + 'serverHost': Config.serverHost, + 'serverName': Config.serverName, + 'version': '0.1.0', + 'features': ['identity', 'chat', 'sticker-packs'], + }.toBody, + ); +} diff --git a/lib/src/utils/extensions.dart b/lib/src/utils/extensions.dart new file mode 100644 index 0000000..9675b66 --- /dev/null +++ b/lib/src/utils/extensions.dart @@ -0,0 +1,25 @@ +import 'dart:convert'; + +import 'package:relic/relic.dart'; + +import '../config/config.dart'; + +extension RequestHelper on Request { + /// Parses the request body as JSON + Future> asJson() async { + final bodyText = await readAsString(); + return jsonDecode(bodyText); + } +} + +extension MapToBody on Map { + /// Converts a Map to a JSON Body + Body get toBody => Body.fromString(jsonEncode(this)); +} + +extension BodyHelper on Body { + /// Creates a JSON body from a Map + static Body fromJson(Map json) { + return Body.fromString(jsonEncode(json)); + } +} diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..6112caa --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,21 @@ +name: chat_server +description: A self-hosted chat server with home server identity model. +version: 0.1.0 + +environment: + sdk: '>=3.0.0 <4.0.0' + +dependencies: + relic: ^1.0.0 + objectbox: ^4.0.0 + dart_jsonwebtoken: ^2.14.0 + bcrypt: ^1.1.3 + crypton: ^2.2.1 + crypto: ^3.0.3 + collection: ^1.18.0 + hotreloader: ^1.0.6 + +dev_dependencies: + objectbox_generator: ^4.0.0 + build_runner: ^2.4.0 + lints: ^4.0.0