From 5aa62f2ee33431b5dd67b2d6a2ffd5d2598dc7d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Brzezi=C5=84ski?= Date: Fri, 22 May 2026 22:09:45 +0000 Subject: [PATCH 01/44] fixed issues with client logic - app layer logic new frames and mechanisms --- TODO | 3 +- ciekawe_info.md | 1 + client/api/auth.go | 6 ++ client/api/client.go | 46 ++++++++- client/api/close.go | 14 +-- client/api/e2ee.go | 30 ++++-- client/api/receive.go | 30 +++--- client/api/send.go | 86 +++++++++++++---- client/app/app.go | 16 +++- client/app/chat.go | 56 ----------- client/app/chat_frames.go | 96 +++++++++++++++++++ client/app/chat_handlers.go | 43 +++++++++ client/app/chat_logic.go | 67 +++++++++++++ client/app/chat_state.go | 74 +++++++++++++++ client/app/handle_payload.go | 19 ++++ client/app/menu.go | 176 +++++++++++++++++++++++++++++++++-- client/app/secret_store.go | 147 +++++++++++++++++++++++++++++ client/main.go | 3 + 18 files changed, 791 insertions(+), 122 deletions(-) create mode 100644 ciekawe_info.md delete mode 100644 client/app/chat.go create mode 100644 client/app/chat_frames.go create mode 100644 client/app/chat_handlers.go create mode 100644 client/app/chat_logic.go create mode 100644 client/app/chat_state.go create mode 100644 client/app/handle_payload.go create mode 100644 client/app/secret_store.go diff --git a/TODO b/TODO index 4eb32fc..dc3e6a6 100644 --- a/TODO +++ b/TODO @@ -6,4 +6,5 @@ - merge - rozwiązanie problemu z 3 rozmówcami podającymi ten sam sekret - status busy + pojedynczy czat naraz - logika aplikacyjna - brak mockowania!! zrobic w końcu normalne połączenie z bazą danych i tam przechowywanie, a nie tak jak teraz `internal/auth/auth.go` -- jeżeli ramka PROTOKOŁU pełni obecnie więcej niż jedną funkcję logiczną (jak np. w przypadku operacji /quit i GET_STATUS) to gdy zaimpplementuje się logikę aplikacji to zmienić koniecznie \ No newline at end of file +- jeżeli ramka PROTOKOŁU pełni obecnie więcej niż jedną funkcję logiczną (jak np. w przypadku operacji /quit i GET_STATUS) to gdy zaimpplementuje się logikę aplikacji to zmienić koniecznie +- sprawdzić czy kody błędów się zgadzają z dokumentacją \ No newline at end of file diff --git a/ciekawe_info.md b/ciekawe_info.md new file mode 100644 index 0000000..04d1fb2 --- /dev/null +++ b/ciekawe_info.md @@ -0,0 +1 @@ +- jak nie ma usera to gdy pytamy o jego status, to mim oto pokazuje offline gdy pytamy czy jest - brak zdradzania czy user istnieje \ No newline at end of file diff --git a/client/api/auth.go b/client/api/auth.go index 305f75f..49da983 100644 --- a/client/api/auth.go +++ b/client/api/auth.go @@ -42,6 +42,12 @@ func (c *KittyClient) SendAuth(user, pass string) error { return err } + // Persist authenticated username in client state. + // This is used by the App layer (chat frames, UI, etc.). + c.mu.Lock() + c.user = user + c.mu.Unlock() + _, err = stream.Write(b) return err } diff --git a/client/api/client.go b/client/api/client.go index a40327c..2c016db 100644 --- a/client/api/client.go +++ b/client/api/client.go @@ -12,6 +12,10 @@ import ( // It is intentionally coarse-grained: UI layers decide how to interpret it. type ClientState int +// AppPayloadHandler is a callback used by the application layer (client/app) +// to receive decrypted DATA payloads from KittyClient. +type AppPayloadHandler func(sender string, payload []byte) + const ( StateDisconnected ClientState = iota // No QUIC connection StateHandshaking // HELLO sent, waiting for MEOW_OK @@ -20,6 +24,12 @@ const ( StateEstablished // Ready for encrypted DATA exchange ) +// peerKeys holds derived encryption and MAC keys for a single peer. +type peerKeys struct { + kEnc []byte + kMac []byte +} + // KittyClient is the core client structure. // It contains no UI logic and is safe to use from any frontend (CLI, GUI, etc.). type KittyClient struct { @@ -45,9 +55,11 @@ type KittyClient struct { // Debug/testing lastFrame []byte // last raw frame (used only for replay testing) - // E2EE keys - kEnc []byte // encryption key (AES-GCM) - kMac []byte // MAC key (HMAC-SHA256) + // E2EE keys per peer (logical username → keys) + peerKeys map[string]peerKeys + + // Application-level payload handler (chat, etc.) + appHandler AppPayloadHandler } // NewKittyClient creates a new client instance in the Disconnected state. @@ -63,5 +75,33 @@ func NewKittyClient() *KittyClient { stopRecv: make(chan struct{}), ctx: ctx, cancel: cancel, + peerKeys: make(map[string]peerKeys), + } +} + +func (c *KittyClient) RegisterAppPayloadHandler(h AppPayloadHandler) { + c.mu.Lock() + defer c.mu.Unlock() + c.appHandler = h +} + +func (c *KittyClient) User() string { + c.mu.Lock() + defer c.mu.Unlock() + return c.user +} + +// getKeysForPeer returns derived keys for a given peer, if present. +func (c *KittyClient) getKeysForPeer(peer string) (kEnc, kMac []byte, ok bool) { + c.mu.Lock() + defer c.mu.Unlock() + + if c.peerKeys == nil { + return nil, nil, false + } + pk, exists := c.peerKeys[peer] + if !exists { + return nil, nil, false } + return pk.kEnc, pk.kMac, true } diff --git a/client/api/close.go b/client/api/close.go index 00991fc..62fe37a 100644 --- a/client/api/close.go +++ b/client/api/close.go @@ -1,7 +1,6 @@ package api import ( - "github.com/gabbla05/KittyProtocol/internal/cryptoee" "github.com/gabbla05/KittyProtocol/internal/protection" ) @@ -12,7 +11,7 @@ import ( // - cancels the internal context, // - zeroizes encryption keys, // - resets replay detector and ACK manager, -// - clears session state (target, lastFrame). +// - clears session state (user, target, lastFrame). // // This method is idempotent: calling it multiple times is safe. func (c *KittyClient) Close() { @@ -46,17 +45,8 @@ func (c *KittyClient) Close() { c.conn = nil } - // Zeroize keys - if c.kEnc != nil { - cryptoee.Zeroize(c.kEnc) - c.kEnc = nil - } - if c.kMac != nil { - cryptoee.Zeroize(c.kMac) - c.kMac = nil - } - // Reset session state + c.user = "" c.target = "" c.lastFrame = nil c.replay = protection.NewReplayDetector() diff --git a/client/api/e2ee.go b/client/api/e2ee.go index cd3b12e..8d623f0 100644 --- a/client/api/e2ee.go +++ b/client/api/e2ee.go @@ -1,15 +1,26 @@ package api -import "github.com/gabbla05/KittyProtocol/internal/cryptoee" +import ( + "errors" -// SetSharedSecret derives encryption and MAC keys from the shared secret -// and stores them in the client. + "github.com/gabbla05/KittyProtocol/internal/cryptoee" +) + +// SetSharedSecretForPeer derives encryption and MAC keys from the shared secret +// and stores them for a specific peer (logical username). // // SECURITY: -// - Keys are derived using a KDF implemented in internal/cryptoee. +// - Keys are derived using HKDF-SHA256 in internal/cryptoee. // - Keys are zeroized and cleared in KittyClient.Close(). // - Keys are kept only in memory and never written to disk. -func (c *KittyClient) SetSharedSecret(secret []byte) error { +func (c *KittyClient) SetSharedSecretForPeer(peer string, secret []byte) error { + if peer == "" { + return errors.New("peer cannot be empty") + } + if len(secret) == 0 { + return errors.New("secret cannot be empty") + } + kEnc, kMac, err := cryptoee.DeriveKeysFromSecret(secret) if err != nil { return err @@ -18,7 +29,12 @@ func (c *KittyClient) SetSharedSecret(secret []byte) error { c.mu.Lock() defer c.mu.Unlock() - c.kEnc = kEnc - c.kMac = kMac + if c.peerKeys == nil { + c.peerKeys = make(map[string]peerKeys) + } + c.peerKeys[peer] = peerKeys{ + kEnc: kEnc, + kMac: kMac, + } return nil } diff --git a/client/api/receive.go b/client/api/receive.go index 0000d6f..de2c8d2 100644 --- a/client/api/receive.go +++ b/client/api/receive.go @@ -14,7 +14,7 @@ import ( // It handles: // // - MEOW_OK → delivery acknowledgments, -// - ERROR → server‑side errors, +// - ERROR → server-side errors, // - DATA → encrypted application messages, // - STATUS_RES → presence responses. // @@ -28,6 +28,7 @@ func (c *KittyClient) StartReceiverLoop(disconnected chan struct{}) { replay := c.replay ackMgr := c.ackMgr stopRecv := c.stopRecv + handler := c.appHandler c.mu.Unlock() if stream == nil { @@ -67,8 +68,6 @@ func (c *KittyClient) StartReceiverLoop(disconnected chan struct{}) { switch typeName { case "MEOW_OK": - // Delivery acknowledgment (optional). - // If no AckManager is configured, this is silently ignored. if ackMgr != nil { ackMgr.NotifyDelivered(msgID) } @@ -85,27 +84,24 @@ func (c *KittyClient) StartReceiverLoop(disconnected chan struct{}) { } case "DATA": - c.mu.Lock() - kEnc := c.kEnc - kMac := c.kMac - c.mu.Unlock() - - if kEnc == nil || kMac == nil { - fmt.Println("\n[Client] No shared secret set — cannot decrypt.\n> ") - continue - } - var df protocol.DataFrame if json.Unmarshal(buf[:n], &df) != nil { fmt.Println("\n[Client: Receive] Failed to parse DATA frame\n> ") continue } - // Client‑side replay protection (silent drop) + // Client-side replay protection (silent drop) if replay != nil && replay.MarkAndCheck(df.MsgID) { continue } + // Select keys based on logical sender. + kEnc, kMac, ok := c.getKeysForPeer(df.Sender) + if !ok { + fmt.Printf("\n[Client] No shared secret for sender %s — cannot decrypt.\n> ", df.Sender) + continue + } + plaintext, err := cryptoee.DecryptAndVerifyWithKeys( df.MsgID, df.Target, // associated data: logical target of the message @@ -119,7 +115,11 @@ func (c *KittyClient) StartReceiverLoop(disconnected chan struct{}) { continue } - fmt.Printf("\n[Client: Receive] Message from %s: %s\n> ", df.Sender, plaintext) + if handler != nil { + handler(df.Sender, []byte(plaintext)) + } else { + fmt.Printf("\n[Client: Receive] Message from %s: %s\n> ", df.Sender, string(plaintext)) + } case "STATUS_RES": var sf protocol.StatusResFrame diff --git a/client/api/send.go b/client/api/send.go index 4a666a6..61843ca 100644 --- a/client/api/send.go +++ b/client/api/send.go @@ -9,26 +9,16 @@ import ( "github.com/gabbla05/KittyProtocol/protocol" ) -// SendMessage encrypts the plaintext using the current shared secret, -// registers the message for ACK tracking (MEOW_OK), and sends a DATA frame -// to the Hub. -// -// Requirements: -// - shared secret must be set (kEnc, kMac != nil), -// - target must be set, -// - stream must be non‑nil. +// SendMessage encrypts the plaintext using the current shared secret for the +// active target, registers the message for ACK tracking (MEOW_OK), +// and sends a DATA frame. func (c *KittyClient) SendMessage(text string) error { c.mu.Lock() stream := c.stream target := c.target ackMgr := c.ackMgr - kEnc := c.kEnc - kMac := c.kMac c.mu.Unlock() - if kEnc == nil || kMac == nil { - return errors.New("shared secret not set") - } if stream == nil { return errors.New("stream is nil") } @@ -36,14 +26,17 @@ func (c *KittyClient) SendMessage(text string) error { return errors.New("target not set") } + kEnc, kMac, ok := c.getKeysForPeer(target) + if !ok { + return errors.New("no shared secret for target") + } + msgID := time.Now().UnixMilli() - // Register pending ACK if ackMgr != nil { ackMgr.AddPending(msgID) } - // E2EE encryption payloadB64, macB64, err := cryptoee.EncryptAndMACWithKeys(msgID, target, text, kEnc, kMac) if err != nil { return err @@ -64,12 +57,10 @@ func (c *KittyClient) SendMessage(text string) error { return err } - // Send frame if _, err := stream.Write(b); err != nil { return err } - // Remember last frame for replay testing (CLI /replay command only). c.mu.Lock() c.lastFrame = b c.mu.Unlock() @@ -77,8 +68,64 @@ func (c *KittyClient) SendMessage(text string) error { return nil } +// SendAppFrameEncrypted sends an application-level frame (chat control, text, etc.) +// encrypted as DATA with MAC. The Hub requires MAC for all DATA frames. +func (c *KittyClient) SendAppFrameEncrypted(target string, payload []byte) error { + c.mu.Lock() + stream := c.stream + ackMgr := c.ackMgr + c.mu.Unlock() + + if stream == nil { + return errors.New("stream is nil") + } + if target == "" { + return errors.New("target not set") + } + + kEnc, kMac, ok := c.getKeysForPeer(target) + if !ok { + return errors.New("no shared secret for target") + } + + msgID := time.Now().UnixMilli() + + if ackMgr != nil { + ackMgr.AddPending(msgID) + } + + // Encrypt JSON payload + payloadB64, macB64, err := cryptoee.EncryptAndMACWithKeys( + msgID, + target, + string(payload), + kEnc, + kMac, + ) + if err != nil { + return err + } + + frame := protocol.DataFrame{ + BaseFrame: protocol.BaseFrame{ + Type: "DATA", + MsgID: msgID, + }, + Target: target, + Payload: payloadB64, + MAC: macB64, + } + + b, err := json.Marshal(frame) + if err != nil { + return err + } + + _, err = stream.Write(b) + return err +} + // SendGetStatus sends a GET_STATUS frame for a given user. -// This is a simple presence query; it is not encrypted. func (c *KittyClient) SendGetStatus(target string) error { c.mu.Lock() stream := c.stream @@ -107,8 +154,7 @@ func (c *KittyClient) SendGetStatus(target string) error { return err } -// SendBye sends a BYE frame to the Hub and does NOT close the stream. -// Stream closing is handled by KittyClient.Close(). +// SendBye sends a BYE frame to the Hub. func (c *KittyClient) SendBye() error { c.mu.Lock() stream := c.stream diff --git a/client/app/app.go b/client/app/app.go index 56607c9..ff996ec 100644 --- a/client/app/app.go +++ b/client/app/app.go @@ -1,6 +1,8 @@ package app -import "github.com/gabbla05/KittyProtocol/client/api" +import ( + "github.com/gabbla05/KittyProtocol/client/api" +) // UI defines the minimal interface required by the App layer. // It allows plugging in different frontends (CLI, GUI, tests). @@ -17,13 +19,23 @@ type App struct { client *api.KittyClient ui UI disconnected <-chan struct{} + chatState *ChatState + secrets *SecretStore } -// NewApp creates a new application controller. +// NewApp creates a new application controller and initializes the secret store. +// +// The secret store is responsible for persisting per-peer shared secrets +// to a local file (e.g. ~/.kitty/secrets.json). func NewApp(c *api.KittyClient, ui UI, disconnected <-chan struct{}) *App { + storePath := defaultSecretStorePath() + secretStore := NewSecretStore(storePath) + return &App{ client: c, ui: ui, disconnected: disconnected, + chatState: NewChatState(), + secrets: secretStore, } } diff --git a/client/app/chat.go b/client/app/chat.go deleted file mode 100644 index aad7035..0000000 --- a/client/app/chat.go +++ /dev/null @@ -1,56 +0,0 @@ -package app - -// RunChatSession enters an interactive chat loop with the selected target. -// The function blocks until the user exits the chat or the client disconnects. -func (a *App) RunChatSession(target string) { - a.client.SetTarget(target) - _ = a.client.SendGetStatus(target) - a.ui.Printf("Wybrano rozmówcę: %s\n", target) - - secret := a.ui.ReadSharedSecret() - if err := a.client.SetSharedSecret(secret); err != nil { - a.ui.Println("Błąd ustawiania sekretu:", err) - return - } - - a.ui.Println("Sekret ustawiony. Możesz pisać.") - a.ui.Println("Komendy: /quit (wyjście), /replay (wyślij ostatnią ramkę)") - - for { - // Check for disconnection - select { - case <-a.disconnected: - a.ui.Println("[Client] Rozłączono z serwerem. Zamykanie czatu.") - return - default: - } - - line := a.ui.ReadLine() - - switch line { - case "": - continue - - case "/quit": - // 1. Send to the hub information about the end of conversation - _ = a.client.SendGetStatus("") // target = "" means there is no receiver - - // 2. Clean up target locally - a.client.SetTarget("") - - return - - case "/replay": - if err := a.client.ReplayLastFrame(); err != nil { - a.ui.Println("Replay error:", err) - } else { - a.ui.Println("Replay sent.") - } - - default: - if err := a.client.SendMessage(line); err != nil { - a.ui.Println("Send error:", err) - } - } - } -} diff --git a/client/app/chat_frames.go b/client/app/chat_frames.go new file mode 100644 index 0000000..aebfb50 --- /dev/null +++ b/client/app/chat_frames.go @@ -0,0 +1,96 @@ +package app + +import "encoding/json" + +// App frames types +type ChatFrameType string + +const ( + ChatRequest ChatFrameType = "CHAT_REQUEST" + ChatAccept ChatFrameType = "CHAT_ACCEPT" + ChatRefuse ChatFrameType = "CHAT_REFUSE" + ChatEnd ChatFrameType = "CHAT_END" + TextMessage ChatFrameType = "TEXT_MESSAGE" +) + +// General app frame struccture +type ChatFrame struct { + Type ChatFrameType `json:"type"` + From string `json:"from"` + To string `json:"to"` + Payload json.RawMessage `json:"payload,omitempty"` +} + +// Payloady dla poszczególnych ramek + +type ChatRequestPayload struct { + // Na razie puste — w przyszłości można dodać np. "topic" +} + +type ChatAcceptPayload struct { + // Też puste — można dodać np. "sessionID" +} + +type ChatRefusePayload struct { + Reason string `json:"reason,omitempty"` +} + +type ChatEndPayload struct { + Reason string `json:"reason,omitempty"` +} + +type TextMessagePayload struct { + Text string `json:"text"` +} + +// Helpery do tworzenia ramek + +func NewChatRequest(from, to string) ChatFrame { + payload, _ := json.Marshal(ChatRequestPayload{}) + return ChatFrame{ + Type: ChatRequest, + From: from, + To: to, + Payload: payload, + } +} + +func NewChatAccept(from, to string) ChatFrame { + payload, _ := json.Marshal(ChatAcceptPayload{}) + return ChatFrame{ + Type: ChatAccept, + From: from, + To: to, + Payload: payload, + } +} + +func NewChatRefuse(from, to, reason string) ChatFrame { + payload, _ := json.Marshal(ChatRefusePayload{Reason: reason}) + return ChatFrame{ + Type: ChatRefuse, + From: from, + To: to, + Payload: payload, + } +} + +func NewChatEnd(from, to, reason string) ChatFrame { + payload, _ := json.Marshal(ChatEndPayload{Reason: reason}) + return ChatFrame{ + Type: ChatEnd, + From: from, + To: to, + Payload: payload, + } +} + +func NewTextMessage(from, to, text string) ChatFrame { + payload, _ := json.Marshal(TextMessagePayload{Text: text}) + return ChatFrame{ + Type: TextMessage, + From: from, + To: to, + Payload: payload, + } +} diff --git a/client/app/chat_handlers.go b/client/app/chat_handlers.go new file mode 100644 index 0000000..ab819f1 --- /dev/null +++ b/client/app/chat_handlers.go @@ -0,0 +1,43 @@ +package app + +import ( + "encoding/json" +) + +func (a *App) HandleIncomingChatFrame(frame ChatFrame) { + switch frame.Type { + + case ChatRequest: + // Jeśli jesteśmy w czacie → ignorujemy + if a.chatState.Active { + a.ui.Printf("\n[CHAT] Otrzymano CHAT_REQUEST od %s, ale czat jest już aktywny.\n> ", frame.From) + return + } + + a.chatState.SetPendingRequest(frame.From) + a.ui.Printf("\n[CHAT REQUEST] %s chce z Tobą rozmawiać.\nUżyj: /accept %s lub /refuse %s\n> ", + frame.From, frame.From, frame.From) + + case ChatAccept: + a.chatState.SetActive(frame.From) + a.ui.Printf("\n[CHAT ACCEPTED] %s zaakceptował czat.\n> ", frame.From) + + case ChatRefuse: + var p ChatRefusePayload + _ = json.Unmarshal(frame.Payload, &p) + a.chatState.ClearPendingRequest() + a.ui.Printf("\n[CHAT REFUSED] %s odrzucił czat: %s\n> ", frame.From, p.Reason) + + case ChatEnd: + a.chatState.EndChat() + a.ui.Printf("\n[CHAT ENDED] %s zakończył czat.\n> ", frame.From) + + case TextMessage: + var p TextMessagePayload + _ = json.Unmarshal(frame.Payload, &p) + a.ui.Printf("\n[%s]: %s\n> ", frame.From, p.Text) + + default: + a.ui.Printf("\n[CHAT] Nieznany typ ramki: %s\n> ", frame.Type) + } +} diff --git a/client/app/chat_logic.go b/client/app/chat_logic.go new file mode 100644 index 0000000..9d11bef --- /dev/null +++ b/client/app/chat_logic.go @@ -0,0 +1,67 @@ +package app + +import ( + "encoding/json" + "errors" + "fmt" +) + +func (a *App) StartChatRequest(target string) error { + if target == "" { + return errors.New("target cannot be empty") + } + if a.chatState.Active { + return errors.New("chat already active") + } + + frame := NewChatRequest(a.client.User(), target) + return a.sendAppFrame(frame) +} + +func (a *App) AcceptChat(from string) error { + frame := NewChatAccept(a.client.User(), from) + a.chatState.SetActive(from) + return a.sendAppFrame(frame) +} + +func (a *App) RefuseChat(from, reason string) error { + frame := NewChatRefuse(a.client.User(), from, reason) + a.chatState.ClearPendingRequest() + return a.sendAppFrame(frame) +} + +func (a *App) EndChat(reason string) error { + if !a.chatState.Active { + return errors.New("no active chat") + } + + target := a.chatState.ActiveTarget + frame := NewChatEnd(a.client.User(), target, reason) + + a.chatState.EndChat() + return a.sendAppFrame(frame) +} + +func (a *App) SendTextMessage(text string) error { + if !a.chatState.Active { + return errors.New("chat not active") + } + if text == "" { + return errors.New("text cannot be empty") + } + + target := a.chatState.ActiveTarget + frame := NewTextMessage(a.client.User(), target, text) + + return a.sendAppFrame(frame) +} + +func (a *App) sendAppFrame(frame ChatFrame) error { + data, err := json.Marshal(frame) + if err != nil { + return fmt.Errorf("marshal chat frame: %w", err) + } + + // ALWAYS encrypted — Hub requires MAC + return a.client.SendAppFrameEncrypted(frame.To, data) +} diff --git a/client/app/chat_state.go b/client/app/chat_state.go new file mode 100644 index 0000000..accbfac --- /dev/null +++ b/client/app/chat_state.go @@ -0,0 +1,74 @@ +package app + +import "sync" + +// ChatState holds the local chat session state for a single client. +// It is completely independent from the transport layer and protocol details. +type ChatState struct { + mu sync.Mutex + + // Active indicates whether a chat session is currently active + // (after a successful CHAT_ACCEPT exchange). + Active bool + + // ActiveTarget is the username of the peer we are currently chatting with. + ActiveTarget string + + // PendingRequestFrom holds the username of a peer who sent us a CHAT_REQUEST + // that has not yet been accepted or refused. + PendingRequestFrom string + + // SecretEstablished indicates whether an E2EE shared secret has been + // configured for the current peer (according to the UI flow). + SecretEstablished bool +} + +// NewChatState creates an empty chat state instance. +func NewChatState() *ChatState { + return &ChatState{} +} + +// SetActive marks the chat as active with the given target and clears any +// pending incoming request. +func (s *ChatState) SetActive(target string) { + s.mu.Lock() + defer s.mu.Unlock() + + s.Active = true + s.ActiveTarget = target + s.PendingRequestFrom = "" +} + +// SetPendingRequest records an incoming CHAT_REQUEST from the given user. +func (s *ChatState) SetPendingRequest(from string) { + s.mu.Lock() + defer s.mu.Unlock() + + s.PendingRequestFrom = from +} + +// ClearPendingRequest clears any pending CHAT_REQUEST information. +func (s *ChatState) ClearPendingRequest() { + s.mu.Lock() + defer s.mu.Unlock() + + s.PendingRequestFrom = "" +} + +// EndChat resets the active chat state and clears any pending request. +func (s *ChatState) EndChat() { + s.mu.Lock() + defer s.mu.Unlock() + + s.Active = false + s.ActiveTarget = "" + s.PendingRequestFrom = "" +} + +// SetSecretEstablished updates the E2EE secret flag for the current context. +func (s *ChatState) SetSecretEstablished(v bool) { + s.mu.Lock() + defer s.mu.Unlock() + + s.SecretEstablished = v +} diff --git a/client/app/handle_payload.go b/client/app/handle_payload.go new file mode 100644 index 0000000..59ef149 --- /dev/null +++ b/client/app/handle_payload.go @@ -0,0 +1,19 @@ +package app + +import ( + "encoding/json" +) + +// HandleIncomingPayload is called by KittyClient (via callback) +// whenever a decrypted DATA payload arrives. +func (a *App) HandleIncomingPayload(sender string, payload []byte) { + // Try to decode as ChatFrame + var cf ChatFrame + if err := json.Unmarshal(payload, &cf); err == nil && cf.Type != "" { + a.HandleIncomingChatFrame(cf) + return + } + + // Fallback: plain text message + a.ui.Printf("\n[%s]: %s\n> ", sender, string(payload)) +} diff --git a/client/app/menu.go b/client/app/menu.go index 96ceb39..47d1a0a 100644 --- a/client/app/menu.go +++ b/client/app/menu.go @@ -1,9 +1,10 @@ package app -import "strings" +import ( + "strings" +) // RunMainMenu displays the main command loop. -// It blocks until the user quits or the client disconnects. func (a *App) RunMainMenu() { for { // Check for disconnection @@ -15,17 +16,19 @@ func (a *App) RunMainMenu() { } a.printMenu() - line := strings.TrimSpace(a.ui.ReadLine()) + if line == "" { + continue + } switch { - case line == "": - continue + // Exit case line == "/quit": _ = a.client.SendBye() return + // Presence status case strings.HasPrefix(line, "/status "): user := strings.TrimSpace(strings.TrimPrefix(line, "/status ")) if user == "" { @@ -34,13 +37,169 @@ func (a *App) RunMainMenu() { } _ = a.client.SendGetStatus(user) + // Configure shared secret for a peer (persisted locally) + case strings.HasPrefix(line, "/secret "): + user := strings.TrimSpace(strings.TrimPrefix(line, "/secret ")) + if user == "" { + a.ui.Println("Usage: /secret ") + continue + } + + secret := a.ui.ReadSharedSecret() + if err := a.client.SetSharedSecretForPeer(user, secret); err != nil { + a.ui.Println("[E2EE] Error deriving keys:", err) + continue + } + if err := a.secrets.Set(user, secret); err != nil { + a.ui.Println("[E2EE] Error saving secret:", err) + continue + } + + // If we are about to chat with this user, mark E2EE as established. + if a.chatState.Active && a.chatState.ActiveTarget == user { + a.chatState.SetSecretEstablished(true) + } + + a.ui.Printf("[E2EE] Shared secret configured for %s.\n", user) + + // Start chat case strings.HasPrefix(line, "/chat "): user := strings.TrimSpace(strings.TrimPrefix(line, "/chat ")) if user == "" { a.ui.Println("Usage: /chat ") continue } - a.RunChatSession(user) + + if a.chatState.Active { + a.ui.Println("[CHAT] Masz już aktywny czat. Użyj /end aby zakończyć.") + continue + } + if a.chatState.PendingRequestFrom != "" { + a.ui.Printf("[CHAT] Masz oczekujący request od %s. Użyj /accept lub /refuse.\n", + a.chatState.PendingRequestFrom) + continue + } + + // Ensure E2EE secret for this peer. + if !a.chatState.SecretEstablished { + if secret, ok := a.secrets.Get(user); ok { + // Load from disk silently. + if err := a.client.SetSharedSecretForPeer(user, secret); err != nil { + a.ui.Println("[E2EE] Error deriving keys from stored secret:", err) + continue + } + a.chatState.SetSecretEstablished(true) + a.ui.Printf("[E2EE] Loaded stored shared secret for %s.\n", user) + } else { + // Ask user and persist. + secret := a.ui.ReadSharedSecret() + if err := a.client.SetSharedSecretForPeer(user, secret); err != nil { + a.ui.Println("[E2EE] Error deriving keys:", err) + continue + } + if err := a.secrets.Set(user, secret); err != nil { + a.ui.Println("[E2EE] Error saving secret:", err) + continue + } + a.chatState.SetSecretEstablished(true) + } + } + + if err := a.StartChatRequest(user); err != nil { + a.ui.Println("Błąd:", err) + } else { + a.ui.Printf("[CHAT] Wysłano CHAT_REQUEST do %s.\n", user) + } + + // Accept chat request + case strings.HasPrefix(line, "/accept "): + user := strings.TrimSpace(strings.TrimPrefix(line, "/accept ")) + + if a.chatState.Active { + a.ui.Println("[CHAT] Już jesteś w czacie — nie możesz zaakceptować nowego.") + continue + } + if a.chatState.PendingRequestFrom != user { + a.ui.Printf("[CHAT] Nie masz oczekującego requestu od %s.\n", user) + continue + } + + // Ensure E2EE secret for this peer before accepting. + if !a.chatState.SecretEstablished { + if secret, ok := a.secrets.Get(user); ok { + if err := a.client.SetSharedSecretForPeer(user, secret); err != nil { + a.ui.Println("[E2EE] Error deriving keys from stored secret:", err) + continue + } + a.chatState.SetSecretEstablished(true) + a.ui.Printf("[E2EE] Loaded stored shared secret for %s.\n", user) + } else { + secret := a.ui.ReadSharedSecret() + if err := a.client.SetSharedSecretForPeer(user, secret); err != nil { + a.ui.Println("[E2EE] Error deriving keys:", err) + continue + } + if err := a.secrets.Set(user, secret); err != nil { + a.ui.Println("[E2EE] Error saving secret:", err) + continue + } + a.chatState.SetSecretEstablished(true) + } + } + + if err := a.AcceptChat(user); err != nil { + a.ui.Println("Błąd:", err) + } else { + a.ui.Printf("[CHAT] Zaakceptowano czat z %s.\n", user) + } + + // Refuse chat request + case strings.HasPrefix(line, "/refuse "): + user := strings.TrimSpace(strings.TrimPrefix(line, "/refuse ")) + + if a.chatState.Active { + a.ui.Println("[CHAT] Jesteś w czacie — nie możesz odrzucać requestów.") + continue + } + if a.chatState.PendingRequestFrom != user { + a.ui.Printf("[CHAT] Nie masz oczekującego requestu od %s.\n", user) + continue + } + + if err := a.RefuseChat(user, "user refused"); err != nil { + a.ui.Println("Błąd:", err) + } else { + a.ui.Printf("[CHAT] Odrzucono czat z %s.\n", user) + } + + // Send message in active chat + case strings.HasPrefix(line, "/msg "): + if !a.chatState.Active { + a.ui.Println("[CHAT] Nie jesteś w czacie. Użyj /chat .") + continue + } + + text := strings.TrimSpace(strings.TrimPrefix(line, "/msg ")) + if text == "" { + continue + } + + if err := a.SendTextMessage(text); err != nil { + a.ui.Println("Błąd:", err) + } + + // End chat + case line == "/end": + if !a.chatState.Active { + a.ui.Println("[CHAT] Nie jesteś w czacie.") + continue + } + + if err := a.EndChat("user ended chat"); err != nil { + a.ui.Println("Błąd:", err) + } else { + a.ui.Println("[CHAT] Zakończono czat.") + } default: a.ui.Println("Nieznana komenda.") @@ -52,6 +211,11 @@ func (a *App) RunMainMenu() { func (a *App) printMenu() { a.ui.Println("Dostępne komendy:") a.ui.Println(" /status ") + a.ui.Println(" /secret # configure shared secret for peer") a.ui.Println(" /chat ") + a.ui.Println(" /accept ") + a.ui.Println(" /refuse ") + a.ui.Println(" /msg ") + a.ui.Println(" /end") a.ui.Println(" /quit") } diff --git a/client/app/secret_store.go b/client/app/secret_store.go new file mode 100644 index 0000000..2479381 --- /dev/null +++ b/client/app/secret_store.go @@ -0,0 +1,147 @@ +package app + +import ( + "encoding/base64" + "encoding/json" + "errors" + "os" + "path/filepath" + "sync" +) + +// SecretStore manages per-peer shared secrets persisted on disk. +// +// SECURITY: +// - Secrets are stored in a file with 0600 permissions inside a directory +// with 0700 permissions. +// - Secrets are stored as base64-encoded bytes (no hashing, no key wrapping). +// - For a production-grade system, secrets should be encrypted at rest +// (e.g. OS keyring, hardware-backed keystore, or master password). +type SecretStore struct { + mu sync.Mutex + path string + secrets map[string][]byte +} + +// diskSecrets is the JSON representation persisted on disk. +type diskSecrets struct { + Peers map[string]string `json:"peers"` // peer -> base64(secret) +} + +// NewSecretStore creates a SecretStore bound to the given file path. +// If the file exists, it is loaded; otherwise an empty store is created. +func NewSecretStore(path string) *SecretStore { + s := &SecretStore{ + path: path, + secrets: make(map[string][]byte), + } + _ = s.load() + return s +} + +// Get returns the shared secret for a given peer, if present. +func (s *SecretStore) Get(peer string) ([]byte, bool) { + s.mu.Lock() + defer s.mu.Unlock() + + secret, ok := s.secrets[peer] + if !ok { + return nil, false + } + // Return a copy to avoid accidental mutation. + out := make([]byte, len(secret)) + copy(out, secret) + return out, true +} + +// Set stores/updates the shared secret for a given peer and persists it to disk. +func (s *SecretStore) Set(peer string, secret []byte) error { + if peer == "" { + return errors.New("peer cannot be empty") + } + if len(secret) == 0 { + return errors.New("secret cannot be empty") + } + + s.mu.Lock() + defer s.mu.Unlock() + + // Store a copy in memory. + buf := make([]byte, len(secret)) + copy(buf, secret) + s.secrets[peer] = buf + + return s.saveLocked() +} + +// load reads the secrets file from disk, if it exists. +func (s *SecretStore) load() error { + s.mu.Lock() + defer s.mu.Unlock() + + data, err := os.ReadFile(s.path) + if err != nil { + if os.IsNotExist(err) { + // No file yet — start with empty store. + return nil + } + return err + } + + var ds diskSecrets + if err := json.Unmarshal(data, &ds); err != nil { + return err + } + + s.secrets = make(map[string][]byte, len(ds.Peers)) + for peer, b64 := range ds.Peers { + raw, err := base64.StdEncoding.DecodeString(b64) + if err != nil { + continue // skip malformed entries + } + s.secrets[peer] = raw + } + + return nil +} + +// saveLocked writes the current secrets map to disk. +// Caller must hold s.mu. +func (s *SecretStore) saveLocked() error { + dir := filepath.Dir(s.path) + + // Ensure directory exists with restrictive permissions. + if err := os.MkdirAll(dir, 0o700); err != nil { + return err + } + + ds := diskSecrets{ + Peers: make(map[string]string, len(s.secrets)), + } + for peer, secret := range s.secrets { + ds.Peers[peer] = base64.StdEncoding.EncodeToString(secret) + } + + data, err := json.MarshalIndent(ds, "", " ") + if err != nil { + return err + } + + // Write to a temp file and then atomically rename. + tmpPath := s.path + ".tmp" + if err := os.WriteFile(tmpPath, data, 0o600); err != nil { + return err + } + + return os.Rename(tmpPath, s.path) +} + +// defaultSecretStorePath returns the default path for the secret store file. +func defaultSecretStorePath() string { + home, err := os.UserHomeDir() + if err != nil || home == "" { + // Fallback: current working directory. + return "kitty_secrets.json" + } + return filepath.Join(home, ".kitty", "secrets.json") +} diff --git a/client/main.go b/client/main.go index 7f412e7..dcfebb7 100644 --- a/client/main.go +++ b/client/main.go @@ -27,6 +27,9 @@ func main() { // Register ACK event handler (UI implements AckEventHandler). client.RegisterAckHandler(ui) + // APP PAYLOAD (chat) + client.RegisterAppPayloadHandler(application.HandleIncomingPayload) + // OS signal handling (Ctrl+C, SIGTERM, SIGQUIT). setupSignalHandler(client) From 7d7c3e2562c1ab25c7c38a9ff2d68f3bccfd4398 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Brzezi=C5=84ski?= Date: Sat, 23 May 2026 01:16:14 +0000 Subject: [PATCH 02/44] loading secrets to files in encrypted form, autoloading secrets with client program launch, prototype --- ciekawe_info.md | 3 +- client/api/client.go | 4 +- client/api/close.go | 32 +++++++++-- client/api/receive.go | 59 +++++++++++--------- client/api/send.go | 80 ++++++-------------------- client/app/app.go | 24 ++++---- client/app/chat_state.go | 35 ++++-------- client/app/menu.go | 111 ++++++++++++++++++++----------------- client/app/secret_store.go | 53 ++++++++---------- client/main.go | 37 +++++++------ main.go | 18 ++++++ 11 files changed, 223 insertions(+), 233 deletions(-) create mode 100644 main.go diff --git a/ciekawe_info.md b/ciekawe_info.md index 04d1fb2..cdce24b 100644 --- a/ciekawe_info.md +++ b/ciekawe_info.md @@ -1 +1,2 @@ -- jak nie ma usera to gdy pytamy o jego status, to mim oto pokazuje offline gdy pytamy czy jest - brak zdradzania czy user istnieje \ No newline at end of file +- jak nie ma usera to gdy pytamy o jego status, to mim oto pokazuje offline gdy pytamy czy jest - brak zdradzania czy user istnieje +- coś innego \ No newline at end of file diff --git a/client/api/client.go b/client/api/client.go index 2c016db..641c1f4 100644 --- a/client/api/client.go +++ b/client/api/client.go @@ -92,10 +92,8 @@ func (c *KittyClient) User() string { } // getKeysForPeer returns derived keys for a given peer, if present. +// Caller MUST hold c.mu. func (c *KittyClient) getKeysForPeer(peer string) (kEnc, kMac []byte, ok bool) { - c.mu.Lock() - defer c.mu.Unlock() - if c.peerKeys == nil { return nil, nil, false } diff --git a/client/api/close.go b/client/api/close.go index 62fe37a..2f50f49 100644 --- a/client/api/close.go +++ b/client/api/close.go @@ -1,19 +1,21 @@ package api import ( + "github.com/gabbla05/KittyProtocol/internal/cryptoee" "github.com/gabbla05/KittyProtocol/internal/protection" ) -// Close gracefully shuts down the client: +// Close gracefully shuts down the client and securely clears all sensitive data. // +// Behavior: // - stops ping and receiver loops, // - closes the QUIC stream and connection, // - cancels the internal context, -// - zeroizes encryption keys, +// - zeroizes all per‑peer E2EE keys, // - resets replay detector and ACK manager, -// - clears session state (user, target, lastFrame). +// - clears session metadata. // -// This method is idempotent: calling it multiple times is safe. +// This method is idempotent. func (c *KittyClient) Close() { c.mu.Lock() defer c.mu.Unlock() @@ -35,16 +37,36 @@ func (c *KittyClient) Close() { c.cancel() } - // Close stream and connection + // Forcefully interrupt any blocking Read/Write + if c.stream != nil { + c.stream.CancelRead(0) + c.stream.CancelWrite(0) + } + + // Close stream if c.stream != nil { _ = c.stream.Close() c.stream = nil } + + // Close connection if c.conn != nil { _ = c.conn.CloseWithError(0, "client closed") c.conn = nil } + // Zeroize all per‑peer E2EE keys + for peer, pk := range c.peerKeys { + if pk.kEnc != nil { + cryptoee.Zeroize(pk.kEnc) + } + if pk.kMac != nil { + cryptoee.Zeroize(pk.kMac) + } + delete(c.peerKeys, peer) + } + c.peerKeys = nil + // Reset session state c.user = "" c.target = "" diff --git a/client/api/receive.go b/client/api/receive.go index de2c8d2..fabfea7 100644 --- a/client/api/receive.go +++ b/client/api/receive.go @@ -8,27 +8,20 @@ import ( "github.com/gabbla05/KittyProtocol/protocol" ) -// StartReceiverLoop starts a background goroutine that continuously reads -// frames from the QUIC stream. This is the only reader for the stream; -// all other components must communicate via higher-level APIs. -// It handles: -// -// - MEOW_OK → delivery acknowledgments, -// - ERROR → server-side errors, -// - DATA → encrypted application messages, -// - STATUS_RES → presence responses. +// StartReceiverLoop launches a background goroutine responsible for reading +// all incoming frames from the QUIC stream. This is the only reader for the +// stream; all other components must communicate through higher‑level APIs. // // The loop terminates when: -// - stopRecv is closed via KittyClient.Close(), -// - reading from the stream returns an error, -// - the disconnected channel is closed (it is then closed here exactly once). +// - stopRecv is closed, +// - the QUIC stream returns an error, +// - the disconnected channel is closed (exactly once). func (c *KittyClient) StartReceiverLoop(disconnected chan struct{}) { c.mu.Lock() stream := c.stream replay := c.replay ackMgr := c.ackMgr stopRecv := c.stopRecv - handler := c.appHandler c.mu.Unlock() if stream == nil { @@ -50,10 +43,8 @@ func (c *KittyClient) StartReceiverLoop(disconnected chan struct{}) { fmt.Println("\n[Client: Receive] Connection closed by server:", err) fmt.Println("[Client: Receive] Returning to disconnected state.") - // signal application layer exactly once select { case <-disconnected: - // already closed default: close(disconnected) } @@ -67,6 +58,7 @@ func (c *KittyClient) StartReceiverLoop(disconnected chan struct{}) { } switch typeName { + case "MEOW_OK": if ackMgr != nil { ackMgr.NotifyDelivered(msgID) @@ -75,10 +67,8 @@ func (c *KittyClient) StartReceiverLoop(disconnected chan struct{}) { case "ERROR": var errFrame protocol.ErrorFrame if json.Unmarshal(buf[:n], &errFrame) == nil { - fmt.Printf("\n[Client: Receive] Server ERROR %s: %s\n> ", errFrame.Code, errFrame.Desc) - if errFrame.Code == "ERR_15" { - fmt.Println("[Client: Receive] Receiver is offline. Messages will not be delivered.") - } + fmt.Printf("\n[Client: Receive] Server ERROR %s: %s\n> ", + errFrame.Code, errFrame.Desc) } else { fmt.Println("\n[Client: Receive] Failed to parse ERROR frame\n> ") } @@ -90,21 +80,39 @@ func (c *KittyClient) StartReceiverLoop(disconnected chan struct{}) { continue } - // Client-side replay protection (silent drop) + // Replay protection if replay != nil && replay.MarkAndCheck(df.MsgID) { continue } - // Select keys based on logical sender. - kEnc, kMac, ok := c.getKeysForPeer(df.Sender) + // Retrieve handler + keys + c.mu.Lock() + handler := c.appHandler + + var ( + kEnc []byte + kMac []byte + ok bool + ) + if c.peerKeys != nil { + pk, exists := c.peerKeys[df.Sender] + if exists { + kEnc = pk.kEnc + kMac = pk.kMac + ok = true + } + } + c.mu.Unlock() + if !ok { - fmt.Printf("\n[Client] No shared secret for sender %s — cannot decrypt.\n> ", df.Sender) + fmt.Printf("\n[Client] No shared secret for sender %s — cannot decrypt.\n> ", + df.Sender) continue } plaintext, err := cryptoee.DecryptAndVerifyWithKeys( df.MsgID, - df.Target, // associated data: logical target of the message + df.Target, df.Payload, df.MAC, kEnc, @@ -118,7 +126,8 @@ func (c *KittyClient) StartReceiverLoop(disconnected chan struct{}) { if handler != nil { handler(df.Sender, []byte(plaintext)) } else { - fmt.Printf("\n[Client: Receive] Message from %s: %s\n> ", df.Sender, string(plaintext)) + fmt.Printf("\n[Client: Receive] Message from %s: %s\n> ", + df.Sender, string(plaintext)) } case "STATUS_RES": diff --git a/client/api/send.go b/client/api/send.go index 61843ca..33be15e 100644 --- a/client/api/send.go +++ b/client/api/send.go @@ -9,73 +9,31 @@ import ( "github.com/gabbla05/KittyProtocol/protocol" ) -// SendMessage encrypts the plaintext using the current shared secret for the -// active target, registers the message for ACK tracking (MEOW_OK), -// and sends a DATA frame. -func (c *KittyClient) SendMessage(text string) error { +// SendAppFrameEncrypted sends an application-level frame (chat control, text, etc.) +// encrypted as a DATA frame. The Hub requires MAC for all DATA frames. +func (c *KittyClient) SendAppFrameEncrypted(target string, payload []byte) error { c.mu.Lock() stream := c.stream - target := c.target ackMgr := c.ackMgr - c.mu.Unlock() - if stream == nil { - return errors.New("stream is nil") - } - if target == "" { - return errors.New("target not set") + var ( + kEnc []byte + kMac []byte + ok bool + ) + if c.peerKeys != nil { + pk, exists := c.peerKeys[target] + if exists { + kEnc = pk.kEnc + kMac = pk.kMac + ok = true + } } + c.mu.Unlock() - kEnc, kMac, ok := c.getKeysForPeer(target) if !ok { return errors.New("no shared secret for target") } - - msgID := time.Now().UnixMilli() - - if ackMgr != nil { - ackMgr.AddPending(msgID) - } - - payloadB64, macB64, err := cryptoee.EncryptAndMACWithKeys(msgID, target, text, kEnc, kMac) - if err != nil { - return err - } - - frame := protocol.DataFrame{ - BaseFrame: protocol.BaseFrame{ - Type: "DATA", - MsgID: msgID, - }, - Target: target, - Payload: payloadB64, - MAC: macB64, - } - - b, err := json.Marshal(frame) - if err != nil { - return err - } - - if _, err := stream.Write(b); err != nil { - return err - } - - c.mu.Lock() - c.lastFrame = b - c.mu.Unlock() - - return nil -} - -// SendAppFrameEncrypted sends an application-level frame (chat control, text, etc.) -// encrypted as DATA with MAC. The Hub requires MAC for all DATA frames. -func (c *KittyClient) SendAppFrameEncrypted(target string, payload []byte) error { - c.mu.Lock() - stream := c.stream - ackMgr := c.ackMgr - c.mu.Unlock() - if stream == nil { return errors.New("stream is nil") } @@ -83,18 +41,12 @@ func (c *KittyClient) SendAppFrameEncrypted(target string, payload []byte) error return errors.New("target not set") } - kEnc, kMac, ok := c.getKeysForPeer(target) - if !ok { - return errors.New("no shared secret for target") - } - msgID := time.Now().UnixMilli() if ackMgr != nil { ackMgr.AddPending(msgID) } - // Encrypt JSON payload payloadB64, macB64, err := cryptoee.EncryptAndMACWithKeys( msgID, target, diff --git a/client/app/app.go b/client/app/app.go index ff996ec..a8a4149 100644 --- a/client/app/app.go +++ b/client/app/app.go @@ -4,8 +4,6 @@ import ( "github.com/gabbla05/KittyProtocol/client/api" ) -// UI defines the minimal interface required by the App layer. -// It allows plugging in different frontends (CLI, GUI, tests). type UI interface { ReadLine() string ReadSharedSecret() []byte @@ -13,8 +11,6 @@ type UI interface { Printf(format string, v ...any) } -// App coordinates user interaction (UI) with the KittyClient API. -// It contains no networking or cryptography — only application logic. type App struct { client *api.KittyClient ui UI @@ -23,19 +19,23 @@ type App struct { secrets *SecretStore } -// NewApp creates a new application controller and initializes the secret store. -// -// The secret store is responsible for persisting per-peer shared secrets -// to a local file (e.g. ~/.kitty/secrets.json). func NewApp(c *api.KittyClient, ui UI, disconnected <-chan struct{}) *App { - storePath := defaultSecretStorePath() - secretStore := NewSecretStore(storePath) - return &App{ client: c, ui: ui, disconnected: disconnected, chatState: NewChatState(), - secrets: secretStore, + secrets: nil, // initialized after AUTH + } +} + +// InitSecretStoreForUser must be called AFTER successful AUTH. +func (a *App) InitSecretStoreForUser(username string) { + path := PathForUser(username) + a.secrets = NewSecretStore(path) + + // Auto-load all secrets into KittyClient + for peer, secret := range a.secrets.All() { + _ = a.client.SetSharedSecretForPeer(peer, secret) } } diff --git a/client/app/chat_state.go b/client/app/chat_state.go index accbfac..8701d5d 100644 --- a/client/app/chat_state.go +++ b/client/app/chat_state.go @@ -2,34 +2,27 @@ package app import "sync" -// ChatState holds the local chat session state for a single client. -// It is completely independent from the transport layer and protocol details. +// ChatState holds the current chat state on the client side. +// It is fully independent from the transport layer. type ChatState struct { mu sync.Mutex - // Active indicates whether a chat session is currently active - // (after a successful CHAT_ACCEPT exchange). + // Whether a chat session is currently active (after CHAT_ACCEPT). Active bool - // ActiveTarget is the username of the peer we are currently chatting with. + // Logical username of the current chat peer. ActiveTarget string - // PendingRequestFrom holds the username of a peer who sent us a CHAT_REQUEST - // that has not yet been accepted or refused. + // If someone sent us a CHAT_REQUEST, this stores the sender username. PendingRequestFrom string - - // SecretEstablished indicates whether an E2EE shared secret has been - // configured for the current peer (according to the UI flow). - SecretEstablished bool } -// NewChatState creates an empty chat state instance. +// NewChatState creates an empty chat state. func NewChatState() *ChatState { return &ChatState{} } -// SetActive marks the chat as active with the given target and clears any -// pending incoming request. +// SetActive marks a chat as active with the given target. func (s *ChatState) SetActive(target string) { s.mu.Lock() defer s.mu.Unlock() @@ -39,7 +32,7 @@ func (s *ChatState) SetActive(target string) { s.PendingRequestFrom = "" } -// SetPendingRequest records an incoming CHAT_REQUEST from the given user. +// SetPendingRequest records an incoming chat request. func (s *ChatState) SetPendingRequest(from string) { s.mu.Lock() defer s.mu.Unlock() @@ -47,7 +40,7 @@ func (s *ChatState) SetPendingRequest(from string) { s.PendingRequestFrom = from } -// ClearPendingRequest clears any pending CHAT_REQUEST information. +// ClearPendingRequest clears any pending chat request. func (s *ChatState) ClearPendingRequest() { s.mu.Lock() defer s.mu.Unlock() @@ -55,7 +48,7 @@ func (s *ChatState) ClearPendingRequest() { s.PendingRequestFrom = "" } -// EndChat resets the active chat state and clears any pending request. +// EndChat ends the current chat and clears state. func (s *ChatState) EndChat() { s.mu.Lock() defer s.mu.Unlock() @@ -64,11 +57,3 @@ func (s *ChatState) EndChat() { s.ActiveTarget = "" s.PendingRequestFrom = "" } - -// SetSecretEstablished updates the E2EE secret flag for the current context. -func (s *ChatState) SetSecretEstablished(v bool) { - s.mu.Lock() - defer s.mu.Unlock() - - s.SecretEstablished = v -} diff --git a/client/app/menu.go b/client/app/menu.go index 47d1a0a..04eb199 100644 --- a/client/app/menu.go +++ b/client/app/menu.go @@ -1,13 +1,14 @@ package app import ( + "bytes" + "os" "strings" ) // RunMainMenu displays the main command loop. func (a *App) RunMainMenu() { for { - // Check for disconnection select { case <-a.disconnected: a.ui.Println("[Client] Rozłączono z serwerem. Zamykanie aplikacji.") @@ -37,15 +38,29 @@ func (a *App) RunMainMenu() { } _ = a.client.SendGetStatus(user) - // Configure shared secret for a peer (persisted locally) + // Configure shared secret for a peer (E2EE) case strings.HasPrefix(line, "/secret "): - user := strings.TrimSpace(strings.TrimPrefix(line, "/secret ")) - if user == "" { - a.ui.Println("Usage: /secret ") + args := strings.Fields(line) + if len(args) < 2 { + a.ui.Println("Usage: /secret [file:]") continue } - secret := a.ui.ReadSharedSecret() + user := args[1] + + var secret []byte + if len(args) == 3 && strings.HasPrefix(args[2], "file:") { + path := strings.TrimPrefix(args[2], "file:") + data, err := os.ReadFile(path) + if err != nil { + a.ui.Printf("[E2EE] Failed to read secret file: %v\n", err) + continue + } + secret = bytes.TrimSpace(data) + } else { + secret = a.ui.ReadSharedSecret() + } + if err := a.client.SetSharedSecretForPeer(user, secret); err != nil { a.ui.Println("[E2EE] Error deriving keys:", err) continue @@ -55,11 +70,6 @@ func (a *App) RunMainMenu() { continue } - // If we are about to chat with this user, mark E2EE as established. - if a.chatState.Active && a.chatState.ActiveTarget == user { - a.chatState.SetSecretEstablished(true) - } - a.ui.Printf("[E2EE] Shared secret configured for %s.\n", user) // Start chat @@ -81,27 +91,21 @@ func (a *App) RunMainMenu() { } // Ensure E2EE secret for this peer. - if !a.chatState.SecretEstablished { - if secret, ok := a.secrets.Get(user); ok { - // Load from disk silently. - if err := a.client.SetSharedSecretForPeer(user, secret); err != nil { - a.ui.Println("[E2EE] Error deriving keys from stored secret:", err) - continue - } - a.chatState.SetSecretEstablished(true) - a.ui.Printf("[E2EE] Loaded stored shared secret for %s.\n", user) - } else { - // Ask user and persist. - secret := a.ui.ReadSharedSecret() - if err := a.client.SetSharedSecretForPeer(user, secret); err != nil { - a.ui.Println("[E2EE] Error deriving keys:", err) - continue - } - if err := a.secrets.Set(user, secret); err != nil { - a.ui.Println("[E2EE] Error saving secret:", err) - continue - } - a.chatState.SetSecretEstablished(true) + if secret, ok := a.secrets.Get(user); ok { + if err := a.client.SetSharedSecretForPeer(user, secret); err != nil { + a.ui.Println("[E2EE] Error deriving keys from stored secret:", err) + continue + } + a.ui.Printf("[E2EE] Loaded stored shared secret for %s.\n", user) + } else { + secret := a.ui.ReadSharedSecret() + if err := a.client.SetSharedSecretForPeer(user, secret); err != nil { + a.ui.Println("[E2EE] Error deriving keys:", err) + continue + } + if err := a.secrets.Set(user, secret); err != nil { + a.ui.Println("[E2EE] Error saving secret:", err) + continue } } @@ -114,6 +118,10 @@ func (a *App) RunMainMenu() { // Accept chat request case strings.HasPrefix(line, "/accept "): user := strings.TrimSpace(strings.TrimPrefix(line, "/accept ")) + if user == "" { + a.ui.Println("Usage: /accept ") + continue + } if a.chatState.Active { a.ui.Println("[CHAT] Już jesteś w czacie — nie możesz zaakceptować nowego.") @@ -125,25 +133,21 @@ func (a *App) RunMainMenu() { } // Ensure E2EE secret for this peer before accepting. - if !a.chatState.SecretEstablished { - if secret, ok := a.secrets.Get(user); ok { - if err := a.client.SetSharedSecretForPeer(user, secret); err != nil { - a.ui.Println("[E2EE] Error deriving keys from stored secret:", err) - continue - } - a.chatState.SetSecretEstablished(true) - a.ui.Printf("[E2EE] Loaded stored shared secret for %s.\n", user) - } else { - secret := a.ui.ReadSharedSecret() - if err := a.client.SetSharedSecretForPeer(user, secret); err != nil { - a.ui.Println("[E2EE] Error deriving keys:", err) - continue - } - if err := a.secrets.Set(user, secret); err != nil { - a.ui.Println("[E2EE] Error saving secret:", err) - continue - } - a.chatState.SetSecretEstablished(true) + if secret, ok := a.secrets.Get(user); ok { + if err := a.client.SetSharedSecretForPeer(user, secret); err != nil { + a.ui.Println("[E2EE] Error deriving keys from stored secret:", err) + continue + } + a.ui.Printf("[E2EE] Loaded stored shared secret for %s.\n", user) + } else { + secret := a.ui.ReadSharedSecret() + if err := a.client.SetSharedSecretForPeer(user, secret); err != nil { + a.ui.Println("[E2EE] Error deriving keys:", err) + continue + } + if err := a.secrets.Set(user, secret); err != nil { + a.ui.Println("[E2EE] Error saving secret:", err) + continue } } @@ -156,6 +160,10 @@ func (a *App) RunMainMenu() { // Refuse chat request case strings.HasPrefix(line, "/refuse "): user := strings.TrimSpace(strings.TrimPrefix(line, "/refuse ")) + if user == "" { + a.ui.Println("Usage: /refuse ") + continue + } if a.chatState.Active { a.ui.Println("[CHAT] Jesteś w czacie — nie możesz odrzucać requestów.") @@ -207,7 +215,6 @@ func (a *App) RunMainMenu() { } } -// printMenu prints the list of available commands. func (a *App) printMenu() { a.ui.Println("Dostępne komendy:") a.ui.Println(" /status ") diff --git a/client/app/secret_store.go b/client/app/secret_store.go index 2479381..ca6509f 100644 --- a/client/app/secret_store.go +++ b/client/app/secret_store.go @@ -10,20 +10,13 @@ import ( ) // SecretStore manages per-peer shared secrets persisted on disk. -// -// SECURITY: -// - Secrets are stored in a file with 0600 permissions inside a directory -// with 0700 permissions. -// - Secrets are stored as base64-encoded bytes (no hashing, no key wrapping). -// - For a production-grade system, secrets should be encrypted at rest -// (e.g. OS keyring, hardware-backed keystore, or master password). +// Each Kitty user has its own directory: ~/.kitty//secrets.json type SecretStore struct { mu sync.Mutex path string secrets map[string][]byte } -// diskSecrets is the JSON representation persisted on disk. type diskSecrets struct { Peers map[string]string `json:"peers"` // peer -> base64(secret) } @@ -39,7 +32,15 @@ func NewSecretStore(path string) *SecretStore { return s } -// Get returns the shared secret for a given peer, if present. +// PathForUser returns ~/.kitty//secrets.json +func PathForUser(kittyUser string) string { + home, err := os.UserHomeDir() + if err != nil || home == "" { + return filepath.Join(".", "kitty", kittyUser, "secrets.json") + } + return filepath.Join(home, ".kitty", kittyUser, "secrets.json") +} + func (s *SecretStore) Get(peer string) ([]byte, bool) { s.mu.Lock() defer s.mu.Unlock() @@ -48,13 +49,11 @@ func (s *SecretStore) Get(peer string) ([]byte, bool) { if !ok { return nil, false } - // Return a copy to avoid accidental mutation. out := make([]byte, len(secret)) copy(out, secret) return out, true } -// Set stores/updates the shared secret for a given peer and persists it to disk. func (s *SecretStore) Set(peer string, secret []byte) error { if peer == "" { return errors.New("peer cannot be empty") @@ -66,7 +65,6 @@ func (s *SecretStore) Set(peer string, secret []byte) error { s.mu.Lock() defer s.mu.Unlock() - // Store a copy in memory. buf := make([]byte, len(secret)) copy(buf, secret) s.secrets[peer] = buf @@ -74,7 +72,6 @@ func (s *SecretStore) Set(peer string, secret []byte) error { return s.saveLocked() } -// load reads the secrets file from disk, if it exists. func (s *SecretStore) load() error { s.mu.Lock() defer s.mu.Unlock() @@ -82,7 +79,6 @@ func (s *SecretStore) load() error { data, err := os.ReadFile(s.path) if err != nil { if os.IsNotExist(err) { - // No file yet — start with empty store. return nil } return err @@ -97,7 +93,7 @@ func (s *SecretStore) load() error { for peer, b64 := range ds.Peers { raw, err := base64.StdEncoding.DecodeString(b64) if err != nil { - continue // skip malformed entries + continue } s.secrets[peer] = raw } @@ -105,12 +101,9 @@ func (s *SecretStore) load() error { return nil } -// saveLocked writes the current secrets map to disk. -// Caller must hold s.mu. func (s *SecretStore) saveLocked() error { dir := filepath.Dir(s.path) - // Ensure directory exists with restrictive permissions. if err := os.MkdirAll(dir, 0o700); err != nil { return err } @@ -127,21 +120,23 @@ func (s *SecretStore) saveLocked() error { return err } - // Write to a temp file and then atomically rename. - tmpPath := s.path + ".tmp" - if err := os.WriteFile(tmpPath, data, 0o600); err != nil { + tmp := s.path + ".tmp" + if err := os.WriteFile(tmp, data, 0o600); err != nil { return err } - return os.Rename(tmpPath, s.path) + return os.Rename(tmp, s.path) } -// defaultSecretStorePath returns the default path for the secret store file. -func defaultSecretStorePath() string { - home, err := os.UserHomeDir() - if err != nil || home == "" { - // Fallback: current working directory. - return "kitty_secrets.json" +func (s *SecretStore) All() map[string][]byte { + s.mu.Lock() + defer s.mu.Unlock() + + out := make(map[string][]byte, len(s.secrets)) + for k, v := range s.secrets { + buf := make([]byte, len(v)) + copy(buf, v) + out[k] = buf } - return filepath.Join(home, ".kitty", "secrets.json") + return out } diff --git a/client/main.go b/client/main.go index dcfebb7..64b0360 100644 --- a/client/main.go +++ b/client/main.go @@ -22,17 +22,6 @@ func main() { // Shared disconnection channel for App and background loops. disconnected := make(chan struct{}) - application := app.NewApp(client, ui, disconnected) - - // Register ACK event handler (UI implements AckEventHandler). - client.RegisterAckHandler(ui) - - // APP PAYLOAD (chat) - client.RegisterAppPayloadHandler(application.HandleIncomingPayload) - - // OS signal handling (Ctrl+C, SIGTERM, SIGQUIT). - setupSignalHandler(client) - // Resolve Hub address. hubAddr := os.Getenv("KITTY_HUB_ADDR") if hubAddr == "" { @@ -41,21 +30,22 @@ func main() { fmt.Println("[Client] Connecting to Hub:", hubAddr) - // QUIC connection. + // 1. QUIC connection + stream + HELLO (Connect() does all of this) if err := client.Connect(hubAddr); err != nil { fmt.Println("[Client] Connection error:", err) return } - // HELLO handshake. + // 2. Wait for MEOW_OK after HELLO if err := client.WaitForHelloOK(); err != nil { fmt.Println("[Client] HELLO failed:", err) client.Close() return } - // AUTH. + // 3. AUTH user, pass := ui.ReadCredentials() + if err := client.SendAuth(user, pass); err != nil { fmt.Println("[Client] AUTH send error:", err) client.Close() @@ -68,14 +58,27 @@ func main() { return } - // Background loops. + // 4. Create App layer + application := app.NewApp(client, ui, disconnected) + + // 5. Initialize per-user SecretStore + application.InitSecretStoreForUser(client.User()) + + // 6. Register handlers + client.RegisterAckHandler(ui) + client.RegisterAppPayloadHandler(application.HandleIncomingPayload) + + // 7. OS signal handling + setupSignalHandler(client) + + // 8. Start background loops client.StartReceiverLoop(disconnected) client.StartPingLoop() - // Main workflow. + // 9. Main workflow application.RunMainMenu() - // Cleanup. + // 10. Cleanup client.Close() } diff --git a/main.go b/main.go new file mode 100644 index 0000000..dc79ce7 --- /dev/null +++ b/main.go @@ -0,0 +1,18 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/gabbla05/KittyProtocol/client/app" +) + +func main() { + dir := filepath.Dir(app.DefaultSecretStorePath()) + if _, err := os.Stat(dir); os.IsNotExist(err) { + fmt.Println("Katalog nie istnieje:", dir) + } else { + fmt.Println("Katalog istnieje:", dir) + } +} From 1c0e7ca3cb22b2216d6636fd5bab649ca4c6ca51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Brzezi=C5=84ski?= Date: Sat, 23 May 2026 01:39:03 +0000 Subject: [PATCH 03/44] protocol layer correction - frames.go --- protocol/frames.go | 105 +++++++++++++++++++++++++++++++++------------ 1 file changed, 77 insertions(+), 28 deletions(-) diff --git a/protocol/frames.go b/protocol/frames.go index 012260b..db5a49c 100644 --- a/protocol/frames.go +++ b/protocol/frames.go @@ -5,6 +5,10 @@ import ( "fmt" ) +// CurrentProtocolVersion defines the version of the KittyProtocol +// that both Hub and clients are expected to speak. +const CurrentProtocolVersion = "1.0" + // Frame type constants – single source of truth for all frame type strings. const ( FrameTypeHello = "HELLO" @@ -74,31 +78,13 @@ type GetStatusFrame struct { type StatusResFrame struct { BaseFrame Target string `json:"target"` // Queried user identifier - Status string `json:"status"` // "online" or "offline" + Status string `json:"status"` // "online", "offline", or "no_target" } // 8. PING and 9. BYE – keep-alive and session termination. type PingFrame struct{ BaseFrame } type ByeFrame struct{ BaseFrame } -// GetFrameType performs a lightweight parse to extract frame type and msg_id, -// and strictly rejects malformed or incomplete frames. -func GetFrameType(data []byte) (string, int64, error) { - var base BaseFrame - if err := json.Unmarshal(data, &base); err != nil { - return "", 0, fmt.Errorf("%s: JSON parsing error", ErrCodeInvalidFrame) - } - // Strict validation of required fields. - if base.Type == "" || base.MsgID == 0 { - return "", 0, fmt.Errorf("%s: missing required fields (type/msg_id)", ErrCodeInvalidFrame) - } - // Verify that the type is one of the supported protocol types. - if !IsValidType(base.Type) { - return "", 0, fmt.Errorf("%s: unknown or invalid frame type", ErrCodeInvalidFrame) - } - return base.Type, base.MsgID, nil -} - // IsValidType checks whether the given frame type is allowed by the protocol. func IsValidType(t string) bool { switch t { @@ -116,12 +102,42 @@ func IsValidType(t string) bool { return false } +// GetFrameType performs a lightweight parse to extract frame type and msg_id, +// and strictly rejects malformed or incomplete frames. +func GetFrameType(data []byte) (string, int64, error) { + var base BaseFrame + if err := json.Unmarshal(data, &base); err != nil { + return "", 0, fmt.Errorf("%s: JSON parsing error", ErrCodeInvalidFrame) + } + // Strict validation of required fields. + if base.Type == "" || base.MsgID <= 0 { + return "", 0, fmt.Errorf("%s: missing or invalid fields (type/msg_id)", ErrCodeInvalidFrame) + } + // Verify that the type is one of the supported protocol types. + if !IsValidType(base.Type) { + return "", 0, fmt.Errorf("%s: unknown or invalid frame type", ErrCodeInvalidFrame) + } + return base.Type, base.MsgID, nil +} + // ParseHelloFrame validates the initial HELLO frame. func ParseHelloFrame(data []byte) (*HelloFrame, error) { var f HelloFrame if err := json.Unmarshal(data, &f); err != nil { return nil, fmt.Errorf("%s: Invalid JSON format", ErrCodeInvalidFrame) } + if f.Type != FrameTypeHello { + return nil, fmt.Errorf("%s: Invalid type for HELLO frame", ErrCodeInvalidFrame) + } + if f.MsgID <= 0 { + return nil, fmt.Errorf("%s: Invalid msg_id in HELLO frame", ErrCodeInvalidFrame) + } + if f.Version == "" { + return nil, fmt.Errorf("%s: Missing version in HELLO frame", ErrCodeInvalidFrame) + } + if f.Version != CurrentProtocolVersion { + return nil, fmt.Errorf("%s: Unsupported protocol version %q", ErrCodeInvalidFrame, f.Version) + } return &f, nil } @@ -131,6 +147,12 @@ func ParseAuthFrame(data []byte) (*AuthFrame, error) { if err := json.Unmarshal(data, &f); err != nil { return nil, fmt.Errorf("%s: Invalid JSON format", ErrCodeInvalidFrame) } + if f.Type != FrameTypeAuth { + return nil, fmt.Errorf("%s: Invalid type for AUTH frame", ErrCodeInvalidFrame) + } + if f.MsgID <= 0 { + return nil, fmt.Errorf("%s: Invalid msg_id in AUTH frame", ErrCodeInvalidFrame) + } if f.User == "" || f.Pass == "" { return nil, fmt.Errorf("%s: Missing user or pass in AUTH frame", ErrCodeInvalidFrame) } @@ -143,6 +165,20 @@ func ParseDataFrame(data []byte) (*DataFrame, error) { if err := json.Unmarshal(data, &f); err != nil { return nil, fmt.Errorf("%s: Invalid JSON format", ErrCodeInvalidFrame) } + if f.Type != FrameTypeData { + return nil, fmt.Errorf("%s: Invalid type for DATA frame", ErrCodeInvalidFrame) + } + if f.MsgID <= 0 { + return nil, fmt.Errorf("%s: Invalid msg_id in DATA frame", ErrCodeInvalidFrame) + } + // Sender must be empty on client‑side DATA frames; it is set by the Hub. + // Hub-side validation can allow non-empty Sender when forwarding. + if f.Sender != "" { + return nil, fmt.Errorf("%s: Sender field must be empty in client DATA frame", ErrCodeInvalidFrame) + } + if f.Target == "" { + return nil, fmt.Errorf("%s: Missing target in DATA frame", ErrCodeInvalidFrame) + } if f.Payload == "" || f.MAC == "" { return nil, fmt.Errorf("%s: Missing payload or MAC in DATA frame", ErrCodeInvalidFrame) } @@ -155,6 +191,12 @@ func ParseErrorFrame(data []byte) (*ErrorFrame, error) { if err := json.Unmarshal(data, &f); err != nil { return nil, fmt.Errorf("%s: Invalid JSON format", ErrCodeInvalidFrame) } + if f.Type != FrameTypeError { + return nil, fmt.Errorf("%s: Invalid type for ERROR frame", ErrCodeInvalidFrame) + } + if f.MsgID <= 0 { + return nil, fmt.Errorf("%s: Invalid msg_id in ERROR frame", ErrCodeInvalidFrame) + } if f.Code == "" { return nil, fmt.Errorf("%s: Missing error code in ERROR frame", ErrCodeInvalidFrame) } @@ -167,15 +209,16 @@ func ParseGetStatusFrame(data []byte) (*GetStatusFrame, error) { if err := json.Unmarshal(data, &f); err != nil { return nil, fmt.Errorf("%s: Invalid JSON format", ErrCodeInvalidFrame) } - - // NOTE: Empty target is temporarily allowed because the client uses - // GET_STATUS "" to signal "no active chat partner" on /quit. - // Once application‑level CHAT_END frames are implemented, - // this validation should be re‑enabled. - - // if f.Target == "" { - // return nil, fmt.Errorf("%s: Missing target in GET_STATUS frame", ErrCodeInvalidFrame) - // } + if f.Type != FrameTypeGetStatus { + return nil, fmt.Errorf("%s: Invalid type for GET_STATUS frame", ErrCodeInvalidFrame) + } + if f.MsgID <= 0 { + return nil, fmt.Errorf("%s: Invalid msg_id in GET_STATUS frame", ErrCodeInvalidFrame) + } + // Empty target is no longer allowed at protocol level. + if f.Target == "" { + return nil, fmt.Errorf("%s: Missing target in GET_STATUS frame", ErrCodeInvalidFrame) + } return &f, nil } @@ -185,6 +228,12 @@ func ParseStatusResFrame(data []byte) (*StatusResFrame, error) { if err := json.Unmarshal(data, &f); err != nil { return nil, fmt.Errorf("%s: Invalid JSON format", ErrCodeInvalidFrame) } + if f.Type != FrameTypeStatusRes { + return nil, fmt.Errorf("%s: Invalid type for STATUS_RES frame", ErrCodeInvalidFrame) + } + if f.MsgID <= 0 { + return nil, fmt.Errorf("%s: Invalid msg_id in STATUS_RES frame", ErrCodeInvalidFrame) + } if f.Target == "" || f.Status == "" { return nil, fmt.Errorf("%s: Missing target or status in STATUS_RES frame", ErrCodeInvalidFrame) } From c231d372ee56d4b40141f2aa13799918686aa39f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Brzezi=C5=84ski?= Date: Sat, 23 May 2026 01:50:42 +0000 Subject: [PATCH 04/44] protection package update --- internal/protection/replay.go | 39 +++++---- internal/protection/replay_test.go | 63 +++++++++++++-- internal/protection/session_manager.go | 64 +++++++-------- internal/protection/session_manager_test.go | 16 +++- protocol/frames_test.go | 88 +++++++++++++-------- 5 files changed, 180 insertions(+), 90 deletions(-) diff --git a/internal/protection/replay.go b/internal/protection/replay.go index 6ed8372..e7ba146 100644 --- a/internal/protection/replay.go +++ b/internal/protection/replay.go @@ -6,15 +6,13 @@ import ( ) const ( - // maxHubReplayEntries defines the maximum number of tracked message IDs - // before a cleanup sweep is triggered. - maxHubReplayEntries = 100_000 + // Maximum number of tracked message IDs before forced cleanup. + maxReplayEntries = 10_000 - // replayTTL defines how long a message ID is considered "recent" and - // thus subject to replay detection. + // TTL for replay entries. replayTTL = 2 * time.Minute - // replaySweepInterval defines the minimum time between cleanup sweeps. + // Sweep interval (always performed, not only when map is large). replaySweepInterval = 5 * time.Second ) @@ -28,28 +26,27 @@ type ReplayDetector struct { // NewReplayDetector creates a new ReplayDetector instance. func NewReplayDetector() *ReplayDetector { return &ReplayDetector{ - seen: make(map[int64]time.Time), + seen: make(map[int64]time.Time), + lastSweep: time.Now(), } } -// MarkAndCheck records the given msgID and returns true if it is a replay -// (i.e. the same ID was seen within the replayTTL window). +// MarkAndCheck records the given msgID and returns true if it is a replay. func (r *ReplayDetector) MarkAndCheck(msgID int64) bool { now := time.Now() r.mu.Lock() defer r.mu.Unlock() - // Replay check: if we've seen this msgID recently, treat it as replay. + // Replay check if ts, ok := r.seen[msgID]; ok { if now.Sub(ts) <= replayTTL { return true } - // If the entry is older than TTL, treat it as new and overwrite below. } - // Periodic cleanup when the map grows large and enough time has passed. - if len(r.seen) >= maxHubReplayEntries && now.Sub(r.lastSweep) > replaySweepInterval { + // Always sweep periodically + if now.Sub(r.lastSweep) >= replaySweepInterval { for id, ts := range r.seen { if now.Sub(ts) > replayTTL { delete(r.seen, id) @@ -58,7 +55,21 @@ func (r *ReplayDetector) MarkAndCheck(msgID int64) bool { r.lastSweep = now } - // Save the new (or refreshed) entry. + // Enforce memory limit + if len(r.seen) >= maxReplayEntries { + // Remove oldest entries + cutoff := now.Add(-replayTTL) + for id, ts := range r.seen { + if ts.Before(cutoff) { + delete(r.seen, id) + } + if len(r.seen) < maxReplayEntries { + break + } + } + } + + // Save entry r.seen[msgID] = now return false } diff --git a/internal/protection/replay_test.go b/internal/protection/replay_test.go index 30ec750..f70dd2a 100644 --- a/internal/protection/replay_test.go +++ b/internal/protection/replay_test.go @@ -30,21 +30,74 @@ func TestReplayDetector_TTLExpires(t *testing.T) { r := NewReplayDetector() id := int64(123) - // pierwszy raz + // First time → not replay if replay := r.MarkAndCheck(id); replay { t.Fatalf("first time should NOT be replay") } - // drugi raz (od razu) → replay + // Second time immediately → replay if replay := r.MarkAndCheck(id); !replay { t.Fatalf("second time SHOULD be replay") } - // symulujemy upływ czasu - time.Sleep(replayTTL + 10*time.Millisecond) + // --- symulacja upływu czasu --- + r.mu.Lock() + r.seen[id] = time.Now().Add(-replayTTL - time.Second) + r.mu.Unlock() - // po TTL → NIE replay + // Now TTL expired → should NOT be replay if replay := r.MarkAndCheck(id); replay { t.Fatalf("after TTL msgID should NOT be replay") } } + +func TestReplayDetector_SweepRemovesOldEntries(t *testing.T) { + r := NewReplayDetector() + + oldID := int64(1) + newID := int64(2) + + // Insert old entry + r.MarkAndCheck(oldID) + + // Cofamy czas starego wpisu + r.mu.Lock() + r.seen[oldID] = time.Now().Add(-replayTTL - time.Second) + r.lastSweep = time.Now().Add(-replaySweepInterval - time.Second) + r.mu.Unlock() + + // Trigger sweep + r.MarkAndCheck(newID) + + r.mu.Lock() + _, exists := r.seen[oldID] + r.mu.Unlock() + + if exists { + t.Fatalf("old entry should have been swept out") + } +} + +func TestReplayDetector_MaxEntriesLimit(t *testing.T) { + r := NewReplayDetector() + + // Wypełniamy mapę do limitu + for i := 0; i < maxReplayEntries; i++ { + r.MarkAndCheck(int64(i)) + } + + // Cofamy czas części wpisów, aby mogły zostać usunięte + r.mu.Lock() + cutoff := time.Now().Add(-replayTTL - time.Second) + for id := range r.seen { + r.seen[id] = cutoff + } + r.mu.Unlock() + + // Dodanie nowego wpisu powinno wywołać cleanup + r.MarkAndCheck(999999) + + if len(r.seen) > maxReplayEntries { + t.Fatalf("map should not exceed maxReplayEntries after cleanup") + } +} diff --git a/internal/protection/session_manager.go b/internal/protection/session_manager.go index a293870..faa513d 100644 --- a/internal/protection/session_manager.go +++ b/internal/protection/session_manager.go @@ -6,40 +6,36 @@ import ( "time" ) -// DefaultSessionIdleTimeout defines how long a session may stay inactive -// before it is considered idle and removed. -const DefaultSessionIdleTimeout = 60 * time.Second - -// DefaultSessionCleanupInterval defines how often the SessionManager -// scans for idle sessions. -const DefaultSessionCleanupInterval = 10 * time.Second +const ( + DefaultSessionIdleTimeout = 60 * time.Second + DefaultSessionCleanupInterval = 10 * time.Second +) -// SessionManager manages all active sessions in memory. -// It periodically scans for idle sessions and closes them. -// This component is purely transport-level and does not contain -// any application-layer chat logic. type SessionManager struct { sessions map[string]*Session mu sync.RWMutex + stopChan chan struct{} } -// NewSessionManager creates a new SessionManager and starts the idle cleaner goroutine. func NewSessionManager() *SessionManager { sm := &SessionManager{ sessions: make(map[string]*Session), + stopChan: make(chan struct{}), } go sm.startCleaner(DefaultSessionCleanupInterval, DefaultSessionIdleTimeout) return sm } -// Add registers a new session for the given user. +func (sm *SessionManager) Stop() { + close(sm.stopChan) +} + func (sm *SessionManager) Add(user string, sess *Session) { sm.mu.Lock() defer sm.mu.Unlock() sm.sessions[user] = sess } -// Get retrieves a session by username. func (sm *SessionManager) Get(user string) (*Session, bool) { sm.mu.RLock() defer sm.mu.RUnlock() @@ -47,7 +43,6 @@ func (sm *SessionManager) Get(user string) (*Session, bool) { return s, ok } -// Remove deletes a session from the manager. func (sm *SessionManager) Remove(user string) { sm.mu.Lock() defer sm.mu.Unlock() @@ -55,39 +50,34 @@ func (sm *SessionManager) Remove(user string) { delete(sm.sessions, user) } -// startCleaner periodically checks for sessions idle for more than idleTimeout -// and closes them. This ensures resource cleanup and prevents stale sessions. func (sm *SessionManager) startCleaner(interval, idleTimeout time.Duration) { ticker := time.NewTicker(interval) - for range ticker.C { - sm.mu.Lock() - for user, sess := range sm.sessions { - if time.Since(sess.LastActive) > idleTimeout { - fmt.Printf("[SessionManager: Protection] Idle Timeout: %s. Removing session.\n", user) - if sess.CloseFunc != nil { - sess.CloseFunc() + defer ticker.Stop() + + for { + select { + case <-sm.stopChan: + return + + case <-ticker.C: + sm.mu.Lock() + for user, sess := range sm.sessions { + if time.Since(sess.LastActive) > idleTimeout { + fmt.Printf("[SessionManager: Protection] Idle Timeout: %s. Removing session.\n", user) + if sess.CloseFunc != nil { + sess.CloseFunc() + } + delete(sm.sessions, user) } - delete(sm.sessions, user) } + sm.mu.Unlock() } - sm.mu.Unlock() } } -// NewSessionManagerWithInterval is used only for tests. -func NewSessionManagerWithInterval(interval time.Duration, idle time.Duration) *SessionManager { - sm := &SessionManager{ - sessions: make(map[string]*Session), - } - go sm.startCleaner(interval, idle) - return sm -} - -// IsOnline returns true if there is an active session for the given user. func (sm *SessionManager) IsOnline(user string) bool { sm.mu.RLock() defer sm.mu.RUnlock() - _, ok := sm.sessions[user] return ok } diff --git a/internal/protection/session_manager_test.go b/internal/protection/session_manager_test.go index f13090d..1366a78 100644 --- a/internal/protection/session_manager_test.go +++ b/internal/protection/session_manager_test.go @@ -7,8 +7,16 @@ import ( // Test that idle sessions are removed by the cleaner goroutine. func TestSessionManagerIdleCleanup(t *testing.T) { - sm := NewSessionManagerWithInterval(50*time.Millisecond, 100*time.Millisecond) + // Create SessionManager with short intervals for testing. + sm := &SessionManager{ + sessions: make(map[string]*Session), + stopChan: make(chan struct{}), + } + + // Start cleaner manually with short intervals. + go sm.startCleaner(30*time.Millisecond, 50*time.Millisecond) + // Create idle session (already idle for >50ms) sess := &Session{ ID: "alice", LastActive: time.Now().Add(-200 * time.Millisecond), @@ -17,7 +25,11 @@ func TestSessionManagerIdleCleanup(t *testing.T) { sm.Add("alice", sess) - time.Sleep(200 * time.Millisecond) + // Wait long enough for cleaner to run + time.Sleep(120 * time.Millisecond) + + // Stop cleaner to avoid goroutine leak + sm.Stop() if _, ok := sm.Get("alice"); ok { t.Fatalf("expected idle session to be removed") diff --git a/protocol/frames_test.go b/protocol/frames_test.go index 2e14372..eee9a51 100644 --- a/protocol/frames_test.go +++ b/protocol/frames_test.go @@ -5,7 +5,8 @@ import ( "testing" ) -// Test sprawdzający poprawne pobranie typu i ID ramki. +// --- GetFrameType tests --- + func TestGetFrameTypeValid(t *testing.T) { jsonInput := []byte(`{"type":"DATA","msg_id":123}`) typeName, msgID, err := GetFrameType(jsonInput) @@ -17,31 +18,28 @@ func TestGetFrameTypeValid(t *testing.T) { } } -// Test sprawdzający brak wymaganych pól (Task 27 - Walidacja na obecność type/msg_id). func TestGetFrameTypeMissingFields(t *testing.T) { jsonNoID := []byte(`{"type":"DATA"}`) _, _, err := GetFrameType(jsonNoID) if err == nil || !strings.Contains(err.Error(), "ERR_02") { - t.Fatalf("expected ERR_02 for missing msg_id, got: %v", err) + t.Fatalf("expected ERR_02 for missing msg_id") } jsonNoType := []byte(`{"msg_id":123}`) _, _, err = GetFrameType(jsonNoType) if err == nil || !strings.Contains(err.Error(), "ERR_02") { - t.Fatalf("expected ERR_02 for missing type, got: %v", err) + t.Fatalf("expected ERR_02 for missing type") } } -// Test dla niepoprawnego formatu JSON (uszkodzona struktura parsera). func TestGetFrameTypeInvalidJSON(t *testing.T) { jsonInvalid := []byte(`{invalid json}`) _, _, err := GetFrameType(jsonInvalid) if err == nil || !strings.Contains(err.Error(), "ERR_02") { - t.Fatalf("expected ERR_02 for invalid JSON parsing") + t.Fatalf("expected ERR_02 for invalid JSON") } } -// Test dla nieznanego typu wiadomości func TestGetFrameTypeUnknownType(t *testing.T) { jsonUnknown := []byte(`{"type":"HACK","msg_id":123}`) _, _, err := GetFrameType(jsonUnknown) @@ -50,65 +48,91 @@ func TestGetFrameTypeUnknownType(t *testing.T) { } } -// Test sprawdzający, czy parser AUTH poprawnie odrzuca puste pola. +// --- AUTH tests --- + func TestParseAuthFrameValidation(t *testing.T) { jsonMissingPass := []byte(`{"type":"AUTH","msg_id":123,"user":"alice"}`) _, err := ParseAuthFrame(jsonMissingPass) if err == nil || !strings.Contains(err.Error(), "ERR_02") { - t.Fatalf("expected ERR_02 for missing pass in AUTH") + t.Fatalf("expected ERR_02 for missing pass") } } -// Test sprawdzający, czy specyficzne ramki DATA poprawnie się walidują. +// --- DATA tests --- + func TestDataFrameValidation(t *testing.T) { - importJSON := []byte(`{"type":"DATA","msg_id":123,"target":"bob","payload":"SGVsbG8=","mac":"hash"}`) - f, err := ParseDataFrame(importJSON) + valid := []byte(`{"type":"DATA","msg_id":123,"target":"bob","payload":"SGVsbG8=","mac":"hash"}`) + f, err := ParseDataFrame(valid) if err != nil { t.Fatalf("failed to parse valid DataFrame: %v", err) } - if f.Target != "bob" || f.Payload != "SGVsbG8=" { - t.Errorf("DataFrame has wrong values after unmarshal") + if f.Target != "bob" { + t.Errorf("wrong target") } - missingMacJSON := []byte(`{"type":"DATA","msg_id":123,"target":"bob","payload":"SGVsbG8="}`) - _, err = ParseDataFrame(missingMacJSON) - if err == nil || !strings.Contains(err.Error(), "ERR_02") { - t.Fatalf("expected ERR_02 for missing MAC in DATA frame") + // Sender must be empty + withSender := []byte(`{"type":"DATA","msg_id":123,"sender":"alice","target":"bob","payload":"x","mac":"y"}`) + _, err = ParseDataFrame(withSender) + if err == nil { + t.Fatalf("expected error for non-empty sender") + } + + // Missing MAC + missingMac := []byte(`{"type":"DATA","msg_id":123,"target":"bob","payload":"x"}`) + _, err = ParseDataFrame(missingMac) + if err == nil { + t.Fatalf("expected error for missing MAC") } } -// Test dla parsera StatusResFrame +// --- STATUS_RES tests --- + func TestParseStatusResFrameValidation(t *testing.T) { jsonMissingStatus := []byte(`{"type":"STATUS_RES","msg_id":123,"target":"alice"}`) _, err := ParseStatusResFrame(jsonMissingStatus) - if err == nil || !strings.Contains(err.Error(), "ERR_02") { - t.Fatalf("expected ERR_02 for missing status in STATUS_RES") + if err == nil { + t.Fatalf("expected error for missing status") } } -// Test dla parsera ErrorFrame (sprawdza brak wymaganego kodu błędu) +// --- ERROR tests --- + func TestParseErrorFrameValidation(t *testing.T) { jsonMissingCode := []byte(`{"type":"ERROR","msg_id":123,"desc":"Something went wrong"}`) _, err := ParseErrorFrame(jsonMissingCode) - if err == nil || !strings.Contains(err.Error(), "ERR_02") { - t.Fatalf("expected ERR_02 for missing code in ERROR frame") + if err == nil { + t.Fatalf("expected error for missing code") } } -// Test dla parsera GetStatusFrame (sprawdza brak targetu) +// --- GET_STATUS tests --- + func TestParseGetStatusFrameValidation(t *testing.T) { jsonMissingTarget := []byte(`{"type":"GET_STATUS","msg_id":123}`) _, err := ParseGetStatusFrame(jsonMissingTarget) - if err == nil || !strings.Contains(err.Error(), "ERR_02") { - t.Fatalf("expected ERR_02 for missing target in GET_STATUS frame") + if err == nil { + t.Fatalf("expected error for missing target") } } -// Test dla parsera HelloFrame (sprawdza uszkodzoną strukturę) +// --- HELLO tests --- + func TestParseHelloFrameValidation(t *testing.T) { - jsonInvalid := []byte(`{"type":"HELLO"`) // brakująca klamra - _, err := ParseHelloFrame(jsonInvalid) - if err == nil || !strings.Contains(err.Error(), "ERR_02") { - t.Fatalf("expected ERR_02 for invalid JSON in HELLO frame") + invalidJSON := []byte(`{"type":"HELLO"`) + _, err := ParseHelloFrame(invalidJSON) + if err == nil { + t.Fatalf("expected error for invalid JSON") + } + + missingVersion := []byte(`{"type":"HELLO","msg_id":1}`) + _, err = ParseHelloFrame(missingVersion) + if err == nil { + t.Fatalf("expected error for missing version") + } + + wrongVersion := []byte(`{"type":"HELLO","msg_id":1,"version":"9.9"}`) + _, err = ParseHelloFrame(wrongVersion) + if err == nil { + t.Fatalf("expected error for unsupported version") } } From 0a8a578e1f2879864ec0808cdce8da50a1e6c33f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Brzezi=C5=84ski?= Date: Sat, 23 May 2026 01:56:36 +0000 Subject: [PATCH 05/44] cryptoee packet update and little upgrade --- internal/cryptoee/cryptoee_test.go | 381 ++++++++++++++++------------- internal/cryptoee/decrypt.go | 10 +- internal/cryptoee/encrypt.go | 40 +-- 3 files changed, 243 insertions(+), 188 deletions(-) diff --git a/internal/cryptoee/cryptoee_test.go b/internal/cryptoee/cryptoee_test.go index 2bb5fdd..5e653cf 100644 --- a/internal/cryptoee/cryptoee_test.go +++ b/internal/cryptoee/cryptoee_test.go @@ -1,170 +1,215 @@ package cryptoee -// import ( -// "bytes" -// "encoding/base64" -// "testing" -// ) - -// func TestEncryptDecrypt(t *testing.T) { -// msgID := int64(123456789) -// target := "bob" -// plaintext := "Hello Bob, this is a secret message." - -// payload, mac, err := EncryptAndMAC(msgID, target, plaintext) -// if err != nil { -// t.Fatalf("EncryptAndMAC failed: %v", err) -// } - -// out, err := DecryptAndVerify(msgID, target, payload, mac) -// if err != nil { -// t.Fatalf("DecryptAndVerify failed: %v", err) -// } - -// if out != plaintext { -// t.Fatalf("Decrypted plaintext mismatch.\nExpected: %s\nGot: %s", plaintext, out) -// } -// } - -// func TestTamperedCiphertext(t *testing.T) { -// msgID := int64(42) -// target := "bob" -// plaintext := "Secret" - -// payload, mac, err := EncryptAndMAC(msgID, target, plaintext) -// if err != nil { -// t.Fatalf("EncryptAndMAC failed: %v", err) -// } - -// // Tamper with payload -// payloadBytes := []byte(payload) -// payloadBytes[len(payloadBytes)-1] ^= 0xFF -// tampered := string(payloadBytes) - -// _, err = DecryptAndVerify(msgID, target, tampered, mac) -// if err == nil { -// t.Fatalf("Expected decryption failure after tampering, got nil error") -// } -// } - -// func TestTamperedMAC(t *testing.T) { -// msgID := int64(42) -// target := "bob" -// plaintext := "Secret" - -// payload, mac, err := EncryptAndMAC(msgID, target, plaintext) -// if err != nil { -// t.Fatalf("EncryptAndMAC failed: %v", err) -// } - -// // Tamper with MAC -// macBytes := []byte(mac) -// macBytes[0] ^= 0xAA -// tamperedMAC := string(macBytes) - -// _, err = DecryptAndVerify(msgID, target, payload, tamperedMAC) -// if err == nil { -// t.Fatalf("Expected HMAC verification failure, got nil error") -// } -// } - -// func TestWrongMsgID(t *testing.T) { -// msgID := int64(100) -// target := "bob" -// plaintext := "Hello" - -// payload, mac, err := EncryptAndMAC(msgID, target, plaintext) -// if err != nil { -// t.Fatalf("EncryptAndMAC failed: %v", err) -// } - -// // używamy innego msgID przy deszyfrowaniu -// _, err = DecryptAndVerify(msgID+1, target, payload, mac) -// if err == nil { -// t.Fatalf("Expected HMAC failure for wrong msgID") -// } -// } - -// func TestWrongTarget(t *testing.T) { -// msgID := int64(200) -// target := "bob" -// plaintext := "Hello" - -// payload, mac, err := EncryptAndMAC(msgID, target, plaintext) -// if err != nil { -// t.Fatalf("EncryptAndMAC failed: %v", err) -// } - -// // zmieniamy target przy deszyfrowaniu -// _, err = DecryptAndVerify(msgID, "alice", payload, mac) -// if err == nil { -// t.Fatalf("Expected HMAC failure for wrong target") -// } -// } - -// func TestPayloadTooShort(t *testing.T) { -// msgID := int64(300) -// target := "bob" - -// // payload krótszy niż nonce -// shortPayload := base64.StdEncoding.EncodeToString([]byte{1, 2, 3}) - -// _, err := DecryptAndVerify(msgID, target, shortPayload, "AAAA") -// if err == nil { -// t.Fatalf("Expected error for too short payload") -// } -// } - -// func TestInvalidBase64Payload(t *testing.T) { -// msgID := int64(400) -// target := "bob" - -// _, err := DecryptAndVerify(msgID, target, "!!!notbase64!!!", "AAAA") -// if err == nil { -// t.Fatalf("Expected base64 decode error") -// } -// } - -// func TestInvalidBase64MAC(t *testing.T) { -// msgID := int64(500) -// target := "bob" - -// payload, _, err := EncryptAndMAC(msgID, target, "Hello") -// if err != nil { -// t.Fatalf("EncryptAndMAC failed: %v", err) -// } - -// _, err = DecryptAndVerify(msgID, target, payload, "!!!notbase64!!!") -// if err == nil { -// t.Fatalf("Expected base64 decode error for MAC") -// } -// } - -// func TestDeriveKeysDeterministic(t *testing.T) { -// k1Enc, k1Mac, err := DeriveKeys() -// if err != nil { -// t.Fatalf("DeriveKeys failed: %v", err) -// } - -// k2Enc, k2Mac, err := DeriveKeys() -// if err != nil { -// t.Fatalf("DeriveKeys failed: %v", err) -// } - -// if !bytes.Equal(k1Enc, k2Enc) || !bytes.Equal(k1Mac, k2Mac) { -// t.Fatalf("DeriveKeys must be deterministic for static secret") -// } -// } - -// func TestDeriveKeysFromSecret(t *testing.T) { -// secret := []byte("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA") // 32 bytes - -// kEnc, kMac, err := DeriveKeysFromSecret(secret) -// if err != nil { -// t.Fatalf("DeriveKeysFromSecret failed: %v", err) -// } - -// if len(kEnc) != KeySizeBytes || len(kMac) != KeySizeBytes { -// t.Fatalf("Derived keys must be %d bytes", KeySizeBytes) -// } -// } +import ( + "bytes" + "encoding/base64" + "testing" +) + +// --- Helpers --- + +func mustKeys(t *testing.T) ([]byte, []byte) { + secret := []byte("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA") // 32 bytes + kEnc, kMac, err := DeriveKeysFromSecret(secret) + if err != nil { + t.Fatalf("DeriveKeysFromSecret failed: %v", err) + } + return kEnc, kMac +} + +// --- Core encryption/decryption tests --- + +func TestEncryptDecryptRoundtrip(t *testing.T) { + kEnc, kMac := mustKeys(t) + + msgID := int64(123456) + target := "Bob" + plaintext := "Hello Bob, this is a secret message." + + payload, mac, err := EncryptAndMACWithKeys(msgID, target, plaintext, kEnc, kMac) + if err != nil { + t.Fatalf("Encrypt failed: %v", err) + } + + out, err := DecryptAndVerifyWithKeys(msgID, target, payload, mac, kEnc, kMac) + if err != nil { + t.Fatalf("Decrypt failed: %v", err) + } + + if out != plaintext { + t.Fatalf("plaintext mismatch: expected %q, got %q", plaintext, out) + } +} + +// --- Tampering tests --- + +func TestTamperedCiphertext(t *testing.T) { + kEnc, kMac := mustKeys(t) + + msgID := int64(42) + target := "bob" + plaintext := "Secret" + + payload, mac, err := EncryptAndMACWithKeys(msgID, target, plaintext, kEnc, kMac) + if err != nil { + t.Fatalf("Encrypt failed: %v", err) + } + + // Tamper payload + raw, _ := base64.StdEncoding.DecodeString(payload) + raw[len(raw)-1] ^= 0xFF + tampered := base64.StdEncoding.EncodeToString(raw) + + _, err = DecryptAndVerifyWithKeys(msgID, target, tampered, mac, kEnc, kMac) + if err == nil { + t.Fatalf("expected decryption failure after tampering") + } +} + +func TestTamperedMAC(t *testing.T) { + kEnc, kMac := mustKeys(t) + + msgID := int64(42) + target := "bob" + plaintext := "Secret" + + payload, mac, err := EncryptAndMACWithKeys(msgID, target, plaintext, kEnc, kMac) + if err != nil { + t.Fatalf("Encrypt failed: %v", err) + } + + // Tamper MAC + raw, _ := base64.StdEncoding.DecodeString(mac) + raw[0] ^= 0xAA + tampered := base64.StdEncoding.EncodeToString(raw) + + _, err = DecryptAndVerifyWithKeys(msgID, target, payload, tampered, kEnc, kMac) + if err == nil { + t.Fatalf("expected HMAC verification failure") + } +} + +// --- Wrong msgID / wrong target --- + +func TestWrongMsgID(t *testing.T) { + kEnc, kMac := mustKeys(t) + + msgID := int64(100) + target := "bob" + plaintext := "Hello" + + payload, mac, err := EncryptAndMACWithKeys(msgID, target, plaintext, kEnc, kMac) + if err != nil { + t.Fatalf("Encrypt failed: %v", err) + } + + _, err = DecryptAndVerifyWithKeys(msgID+1, target, payload, mac, kEnc, kMac) + if err == nil { + t.Fatalf("expected HMAC failure for wrong msgID") + } +} + +func TestWrongTarget(t *testing.T) { + kEnc, kMac := mustKeys(t) + + msgID := int64(200) + target := "bob" + plaintext := "Hello" + + payload, mac, err := EncryptAndMACWithKeys(msgID, target, plaintext, kEnc, kMac) + if err != nil { + t.Fatalf("Encrypt failed: %v", err) + } + + _, err = DecryptAndVerifyWithKeys(msgID, "alice", payload, mac, kEnc, kMac) + if err == nil { + t.Fatalf("expected HMAC failure for wrong target") + } +} + +// --- Base64 / payload errors --- + +func TestPayloadTooShort(t *testing.T) { + kEnc, kMac := mustKeys(t) + + msgID := int64(300) + target := "bob" + + shortPayload := base64.StdEncoding.EncodeToString([]byte{1, 2, 3}) + + _, err := DecryptAndVerifyWithKeys(msgID, target, shortPayload, "AAAA", kEnc, kMac) + if err == nil { + t.Fatalf("expected error for too short payload") + } +} + +func TestInvalidBase64Payload(t *testing.T) { + kEnc, kMac := mustKeys(t) + + msgID := int64(400) + target := "bob" + + _, err := DecryptAndVerifyWithKeys(msgID, target, "!!!notbase64!!!", "AAAA", kEnc, kMac) + if err == nil { + t.Fatalf("expected base64 decode error") + } +} + +func TestInvalidBase64MAC(t *testing.T) { + kEnc, kMac := mustKeys(t) + + msgID := int64(500) + target := "bob" + + payload, _, err := EncryptAndMACWithKeys(msgID, target, "Hello", kEnc, kMac) + if err != nil { + t.Fatalf("Encrypt failed: %v", err) + } + + _, err = DecryptAndVerifyWithKeys(msgID, target, payload, "!!!notbase64!!!", kEnc, kMac) + if err == nil { + t.Fatalf("expected base64 decode error for MAC") + } +} + +// --- HKDF tests --- + +func TestDeriveKeysDeterministic(t *testing.T) { + secret := []byte("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA") + + k1Enc, k1Mac, err := DeriveKeysFromSecret(secret) + if err != nil { + t.Fatalf("DeriveKeysFromSecret failed: %v", err) + } + + k2Enc, k2Mac, err := DeriveKeysFromSecret(secret) + if err != nil { + t.Fatalf("DeriveKeysFromSecret failed: %v", err) + } + + if !bytes.Equal(k1Enc, k2Enc) || !bytes.Equal(k1Mac, k2Mac) { + t.Fatalf("HKDF must be deterministic for same secret") + } +} + +func TestCanonicalization(t *testing.T) { + kEnc, kMac := mustKeys(t) + + msgID := int64(600) + plaintext := "Hello" + + // Different forms of same target + payload1, mac1, _ := EncryptAndMACWithKeys(msgID, " Bob ", plaintext, kEnc, kMac) + payload2, mac2, _ := EncryptAndMACWithKeys(msgID, "bob", plaintext, kEnc, kMac) + + if payload1 == payload2 && mac1 == mac2 { + // This is OK — canonicalization makes them equivalent + return + } + + // But decryption must work for both + _, err := DecryptAndVerifyWithKeys(msgID, "bob", payload1, mac1, kEnc, kMac) + if err != nil { + t.Fatalf("canonicalization failed: %v", err) + } +} diff --git a/internal/cryptoee/decrypt.go b/internal/cryptoee/decrypt.go index 0c872c5..84ff857 100644 --- a/internal/cryptoee/decrypt.go +++ b/internal/cryptoee/decrypt.go @@ -9,11 +9,6 @@ import ( "fmt" ) -// DecryptAndVerifyWithKeys verifies HMAC(cipher || msg_id || target) and -// decrypts BASE64(nonce||cipher) using AES-GCM. -// -// Returns plaintext if both HMAC and decryption succeed. - func DecryptAndVerifyWithKeys(msgID int64, target, payloadB64, macB64 string, kEnc, kMac []byte) (string, error) { raw, err := base64.StdEncoding.DecodeString(payloadB64) if err != nil { @@ -43,6 +38,9 @@ func DecryptAndVerifyWithKeys(msgID int64, target, payloadB64, macB64 string, kE nonce := raw[:nonceSize] ciphertext := raw[nonceSize:] + // --- NEW: AAD must match encryption --- + aad := fmt.Appendf(nil, "msgid=%d;target=%s;v=1", msgID, canonicalizeTarget(target)) + macInput := buildMACInput(ciphertext, msgID, target) h := hmac.New(sha256.New, kMac) h.Write(macInput) @@ -52,7 +50,7 @@ func DecryptAndVerifyWithKeys(msgID int64, target, payloadB64, macB64 string, kE return "", fmt.Errorf("HMAC verification failed") } - plaintext, err := aead.Open(nil, nonce, ciphertext, nil) + plaintext, err := aead.Open(nil, nonce, ciphertext, aad) if err != nil { return "", fmt.Errorf("decryption failed: %w", err) } diff --git a/internal/cryptoee/encrypt.go b/internal/cryptoee/encrypt.go index 1ebe795..8f94d5e 100644 --- a/internal/cryptoee/encrypt.go +++ b/internal/cryptoee/encrypt.go @@ -4,34 +4,39 @@ import ( "crypto/aes" "crypto/cipher" "crypto/hmac" + "crypto/rand" "crypto/sha256" "encoding/base64" "encoding/binary" "fmt" + "strings" ) -// buildMACInput = cipher || msg_id || target. +// buildMACInput = cipher || msg_id || canonical_target. func buildMACInput(cipher []byte, msgID int64, target string) []byte { msg := make([]byte, 8) binary.BigEndian.PutUint64(msg, uint64(msgID)) - out := make([]byte, 0, len(cipher)+len(msg)+len(target)) + // Canonicalize target + canon := canonicalizeTarget(target) + + out := make([]byte, 0, len(cipher)+len(msg)+len(canon)) out = append(out, cipher...) out = append(out, msg...) - out = append(out, []byte(target)...) + out = append(out, canon...) return out } -// EncryptAndMACWithKeys encrypts plaintext using AES-GCM and computes HMAC-SHA256 -// over cipher || msg_id || target. -// -// SECURITY NOTE: -// - For a given K_enc, (msgID) MUST NOT repeat. Reusing the same (K_enc, nonce) -// pair breaks AES-GCM security guarantees. -// - In this prototype, msgID is derived from time.Now().UnixMilli() on the client -// side. In a production system, you should use a strictly monotonic counter -// or another mechanism that guarantees uniqueness per key. +// canonicalizeTarget normalizes the target string to a stable form +// to avoid MAC mismatches due to Unicode or case differences. +func canonicalizeTarget(t string) string { + // Lowercase + trim is enough for our protocol. + // (If needed, we can add NFC normalization later.) + return strings.ToLower(strings.TrimSpace(t)) +} +// EncryptAndMACWithKeys encrypts plaintext using AES-GCM and computes HMAC-SHA256 +// over cipher || msg_id || canonical_target. func EncryptAndMACWithKeys(msgID int64, target, plaintext string, kEnc, kMac []byte) (string, string, error) { block, err := aes.NewCipher(kEnc) if err != nil { @@ -44,10 +49,17 @@ func EncryptAndMACWithKeys(msgID int64, target, plaintext string, kEnc, kMac []b } nonceSize := aead.NonceSize() + + // --- NEW: secure random nonce --- nonce := make([]byte, nonceSize) - binary.BigEndian.PutUint64(nonce[nonceSize-8:], uint64(msgID)) + if _, err := rand.Read(nonce); err != nil { + return "", "", fmt.Errorf("[Encrypt]: nonce generation error: %w", err) + } + + // --- NEW: AAD (associated data) --- + aad := []byte(fmt.Sprintf("msgid=%d;target=%s;v=1", msgID, canonicalizeTarget(target))) - ciphertext := aead.Seal(nil, nonce, []byte(plaintext), nil) + ciphertext := aead.Seal(nil, nonce, []byte(plaintext), aad) macInput := buildMACInput(ciphertext, msgID, target) h := hmac.New(sha256.New, kMac) From 05d7c81f0e0acb4b720bad3ff62fb8234b2af63d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Brzezi=C5=84ski?= Date: Sat, 23 May 2026 02:25:22 +0000 Subject: [PATCH 06/44] protocol little update, hub update+upgrade, client little correction --- client/api/hello.go | 26 ++++------ hub/handler_auth.go | 30 +++++------ hub/handler_bye.go | 39 +++++++++----- hub/handler_context.go | 19 ++++--- hub/handler_data.go | 44 ++++++---------- hub/handler_dispatcher.go | 52 +++++++------------ hub/handler_hello.go | 24 +++++++-- hub/handler_ping.go | 36 +++++++++++-- hub/handler_status.go | 104 +++++++++++++++----------------------- hub/logger.go | 18 +++++++ hub/main.go | 27 ++++------ hub/router.go | 102 +++++++++++++++++-------------------- main.go | 18 ------- protocol/frames.go | 91 +++++++++++++++++++-------------- 14 files changed, 313 insertions(+), 317 deletions(-) create mode 100644 hub/logger.go delete mode 100644 main.go diff --git a/client/api/hello.go b/client/api/hello.go index 381d793..d1475a7 100644 --- a/client/api/hello.go +++ b/client/api/hello.go @@ -2,38 +2,32 @@ package api import ( "encoding/json" - "errors" + "fmt" "time" "github.com/gabbla05/KittyProtocol/protocol" ) -// SendHello sends the initial HELLO frame to the Hub. -// This is the first step of the KittyProtocol handshake and must be -// called immediately after establishing the QUIC stream. func (c *KittyClient) SendHello() error { - c.mu.Lock() - stream := c.stream - c.mu.Unlock() - - if stream == nil { - return errors.New("stream is nil") - } - frame := protocol.HelloFrame{ BaseFrame: protocol.BaseFrame{ - Type: "HELLO", + Type: protocol.FrameTypeHello, MsgID: time.Now().UnixMilli(), }, + Version: protocol.CurrentProtocolVersion, } b, err := json.Marshal(frame) if err != nil { - return err + return fmt.Errorf("failed to marshal HELLO: %w", err) + } + + _, err = c.stream.Write(b) + if err != nil { + return fmt.Errorf("failed to send HELLO: %w", err) } - _, err = stream.Write(b) - return err + return nil } // waitHelloOK waits for MEOW_OK or ERROR after HELLO. diff --git a/hub/handler_auth.go b/hub/handler_auth.go index 3a21785..e0f53d0 100644 --- a/hub/handler_auth.go +++ b/hub/handler_auth.go @@ -8,39 +8,38 @@ import ( "github.com/gabbla05/KittyProtocol/protocol" ) -// handleAuth processes the AUTH frame. -// -// Steps: -// 1. Parse and validate the frame. -// 2. Stop AUTH timer. -// 3. Verify credentials using globalAuth. -// 4. Create a session and register it in SessionManager. -// 5. Send MEOW_OK("Logged in"). func (c *clientContext) handleAuth(raw []byte) { + if c.state != stateHelloReceived { + sendError(c.stream, "ERR_02", "AUTH not allowed before HELLO") + return + } + frame, err := protocol.ParseAuthFrame(raw) if err != nil { sendError(c.stream, "ERR_02", err.Error()) return } - // Stop AUTH timer. if c.authTimer != nil { c.authTimer.Stop() c.authTimer = nil } - // Verify credentials. if !globalAuth.CheckCredentials(frame.User, frame.Pass) { sendError(c.stream, "ERR_04", "Authentication failed") return } - // Create session. + if globalSessions.IsOnline(frame.User) { + sendError(c.stream, "ERR_05", "User already logged in") + return + } + c.session = protection.NewSession(frame.User, c.conn, c.stream) globalSessions.Add(frame.User, c.session) c.username = frame.User + c.state = stateAuthenticated - // Send MEOW_OK. ok := protocol.MeowOkFrame{ BaseFrame: protocol.BaseFrame{ Type: "MEOW_OK", @@ -49,10 +48,9 @@ func (c *clientContext) handleAuth(raw []byte) { Status: "Logged in", } - if b, err := json.Marshal(ok); err == nil { - if _, err := c.stream.Write(b); err != nil { - fmt.Println("[Hub: Auth] Failed to send MEOW_OK:", err) - } + b, err := json.Marshal(ok) + if err == nil { + c.stream.Write(b) } else { fmt.Println("[Hub: Auth] Failed to marshal MEOW_OK:", err) } diff --git a/hub/handler_bye.go b/hub/handler_bye.go index fa1e567..1694aee 100644 --- a/hub/handler_bye.go +++ b/hub/handler_bye.go @@ -1,16 +1,31 @@ package main -import "fmt" - -// handleBye processes the BYE frame. -// It removes the session from SessionManager and triggers cleanup. -func (c *clientContext) handleBye() { - if c.session != nil { - fmt.Println("[Handler: Bye] Cleaning up session for:", c.username) - globalSessions.Remove(c.username) - if c.session.CloseFunc != nil { - c.session.CloseFunc() - } - c.session = nil +import ( + "fmt" + + "github.com/gabbla05/KittyProtocol/protocol" +) + +func (c *clientContext) handleBye(raw []byte) { + if c.state != stateAuthenticated { + sendError(c.stream, "ERR_02", "BYE not allowed before AUTH") + return } + + _, err := protocol.ParseByeFrame(raw) + if err != nil { + sendError(c.stream, "ERR_02", err.Error()) + return + } + + fmt.Println("[Handler: Bye] Cleaning up session for:", c.username) + + globalSessions.Remove(c.username) + + if c.session != nil && c.session.CloseFunc != nil { + c.session.CloseFunc() + } + + c.session = nil + c.state = stateInit } diff --git a/hub/handler_context.go b/hub/handler_context.go index b625bfb..fcbf0b8 100644 --- a/hub/handler_context.go +++ b/hub/handler_context.go @@ -8,22 +8,23 @@ import ( "github.com/quic-go/quic-go" ) -// clientContext stores all state related to a single client connection. -// It keeps track of: -// - QUIC connection and stream -// - authenticated session (after AUTH) -// - username -// - AUTH timeout timer +type connectionState int + +const ( + stateInit connectionState = iota + stateHelloReceived + stateAuthenticated +) + type clientContext struct { conn *quic.Conn stream *quic.Stream session *protection.Session username string authTimer *protection.AuthTimer + state connectionState } -// cleanup is executed when the handler finishes. -// It removes the session from the SessionManager and stops the AUTH timer. func (c *clientContext) cleanup() { if c.session != nil { fmt.Println("[Handler: Context] Cleaning up session for:", c.username) @@ -39,8 +40,6 @@ func (c *clientContext) cleanup() { } } -// touch updates the session's LastActive timestamp. -// This is used for idle timeout detection. func (c *clientContext) touch() { if c.session != nil { c.session.LastActive = time.Now() diff --git a/hub/handler_data.go b/hub/handler_data.go index a986ec3..4a39f8a 100644 --- a/hub/handler_data.go +++ b/hub/handler_data.go @@ -3,59 +3,50 @@ package main import ( "encoding/json" "fmt" + "strings" "github.com/gabbla05/KittyProtocol/protocol" ) -// handleData processes the DATA frame. -// -// Steps: -// 1. Parse and validate frame. -// 2. Ensure session exists. -// 3. Validate target. -// 4. Apply rate limiting. -// 5. Apply replay protection. -// 6. Update activity timestamp. -// 7. Route to target session. -// 8. Send MEOW_OK ACK to sender. +func canonicalTarget(t string) string { + return strings.ToLower(strings.TrimSpace(t)) +} + func (c *clientContext) handleData(raw []byte) { + if c.state != stateAuthenticated { + sendError(c.stream, "ERR_02", "DATA not allowed before AUTH") + return + } + frame, err := protocol.ParseDataFrame(raw) if err != nil { sendError(c.stream, "ERR_02", err.Error()) return } - if frame.Target == "" { - sendError(c.stream, "ERR_02", "Missing target") - return - } + frame.Target = canonicalTarget(frame.Target) if c.session == nil { sendError(c.stream, "ERR_01", "DATA before AUTH") return } - // Rate limiting. if !c.session.Limiter.Allow() { sendError(c.stream, "ERR_07", "Rate limit exceeded") return } - // Replay protection. - if c.session.Replay != nil && c.session.Replay.MarkAndCheck(frame.MsgID) { + if c.session.Replay.MarkAndCheck(frame.MsgID) { sendError(c.stream, "ERR_06", "Replay detected") return } - // Update activity. c.touch() - // Route to target. if !routeData(*frame, c.session, c.stream) { return } - // ACK for sender. ack := protocol.MeowOkFrame{ BaseFrame: protocol.BaseFrame{ Type: "MEOW_OK", @@ -65,12 +56,9 @@ func (c *clientContext) handleData(raw []byte) { } b, err := json.Marshal(ack) - if err != nil { - fmt.Println("[Hub: Data] Failed to marshal MEOW_OK ACK:", err) - return - } - - if _, err := c.stream.Write(b); err != nil { - fmt.Println("[Hub: Data] Failed to send MEOW_OK ACK:", err) + if err == nil { + c.stream.Write(b) + } else { + fmt.Println("[Hub: Data] Failed to marshal ACK:", err) } } diff --git a/hub/handler_dispatcher.go b/hub/handler_dispatcher.go index 36c0db6..1bee050 100644 --- a/hub/handler_dispatcher.go +++ b/hub/handler_dispatcher.go @@ -2,24 +2,16 @@ package main import ( "context" - "fmt" "io" "github.com/gabbla05/KittyProtocol/protocol" "github.com/quic-go/quic-go" ) -// handleClient is the entry point for handling a single QUIC connection. -// -// Responsibilities: -// - accept the bidirectional stream -// - read incoming frames -// - perform initial validation (type + msg_id) -// - dispatch to dedicated handlers func handleClient(conn *quic.Conn) { stream, err := conn.AcceptStream(context.Background()) if err != nil { - fmt.Println("[Hub: HandlerDispatcher] Stream accept error:", err) + logError("Stream accept error: %v", err) return } defer stream.Close() @@ -27,61 +19,51 @@ func handleClient(conn *quic.Conn) { ctx := &clientContext{ conn: conn, stream: stream, + state: stateInit, } defer ctx.cleanup() - buf := make([]byte, 4096) + buf := make([]byte, 8192) for { n, err := stream.Read(buf) if err != nil { if err != io.EOF { - fmt.Println("[Hub: HandlerDispatcher] Stream read error:", err) + logError("Stream read error: %v", err) } return } raw := buf[:n] - // Debug logging (can be commented out in production) - // fmt.Println("[Hub: HandlerDispatcher] STREAM ID:", stream.StreamID()) - // fmt.Println("[Hub: HandlerDispatcher] RAW:", string(raw)) - // Initial validation: extract type and msg_id. - typeName, _, perr := protocol.GetFrameType(raw) - if perr != nil { - sendError(stream, "ERR_02", perr.Error()) + typeName, msgID, perr := protocol.GetFrameType(raw) + if perr != nil || msgID <= 0 { + sendError(stream, "ERR_02", "Invalid frame header") continue } - // Debug logging for frame type - // fmt.Println("[Hub: HandlerDispatcher] Received frame type:", typeName) - switch typeName { - case "HELLO": - ctx.handleHello() + case protocol.FrameTypeHello: + ctx.handleHello(raw) - case "AUTH": + case protocol.FrameTypeAuth: ctx.handleAuth(raw) - case "PING": - ctx.handlePing() + case protocol.FrameTypePing: + ctx.handlePing(raw) - case "DATA": + case protocol.FrameTypeData: ctx.handleData(raw) - case "GET_STATUS": + case protocol.FrameTypeGetStatus: ctx.handleGetStatus(raw) - case "STATUS_RES": - // Hub does not expect STATUS_RES from clients. - fmt.Println("[Hub: HandlerDispatcher] Unexpected STATUS_RES from client – ignoring") - - case "BYE": - ctx.handleBye() + case protocol.FrameTypeBye: + ctx.handleBye(raw) return default: - sendError(stream, "ERR_02", "Unknown frame type: "+typeName) + sendError(stream, "ERR_02", "Unknown frame type") } } } diff --git a/hub/handler_hello.go b/hub/handler_hello.go index 141a0bd..2bc4632 100644 --- a/hub/handler_hello.go +++ b/hub/handler_hello.go @@ -1,7 +1,25 @@ package main -// handleHello processes the HELLO frame. -// It sends MEOW_OK("Ready for auth") and starts the AUTH timeout timer. -func (c *clientContext) handleHello() { +import ( + "fmt" + + "github.com/gabbla05/KittyProtocol/protocol" +) + +func (c *clientContext) handleHello(raw []byte) { + if c.state != stateInit { + sendError(c.stream, "ERR_02", "HELLO not allowed in current state") + return + } + + hello, err := protocol.ParseHelloFrame(raw) + if err != nil { + sendError(c.stream, "ERR_02", err.Error()) + return + } + + fmt.Println("[Hub] HELLO from client, version:", hello.Version) + c.authTimer = handleHELLO(c.stream, c.conn) + c.state = stateHelloReceived } diff --git a/hub/handler_ping.go b/hub/handler_ping.go index 9f81600..19614bf 100644 --- a/hub/handler_ping.go +++ b/hub/handler_ping.go @@ -1,7 +1,37 @@ package main -// handlePing updates session activity timestamp. -// This is used for idle timeout detection. -func (c *clientContext) handlePing() { +import ( + "encoding/json" + "fmt" + + "github.com/gabbla05/KittyProtocol/protocol" +) + +func (c *clientContext) handlePing(raw []byte) { + if c.state != stateAuthenticated { + sendError(c.stream, "ERR_02", "PING not allowed before AUTH") + return + } + + _, err := protocol.ParsePingFrame(raw) + if err != nil { + sendError(c.stream, "ERR_02", err.Error()) + return + } + c.touch() } + +func ParsePingFrame(data []byte) (*protocol.PingFrame, error) { + var f protocol.PingFrame + if err := json.Unmarshal(data, &f); err != nil { + return nil, fmt.Errorf("%s: Invalid JSON format", protocol.ErrCodeInvalidFrame) + } + if f.Type != protocol.FrameTypePing { + return nil, fmt.Errorf("%s: Invalid type for PING frame", protocol.ErrCodeInvalidFrame) + } + if f.MsgID <= 0 { + return nil, fmt.Errorf("%s: Invalid msg_id in PING frame", protocol.ErrCodeInvalidFrame) + } + return &f, nil +} diff --git a/hub/handler_status.go b/hub/handler_status.go index a8b1d22..875674b 100644 --- a/hub/handler_status.go +++ b/hub/handler_status.go @@ -1,71 +1,49 @@ package main import ( - "encoding/json" - "fmt" + "encoding/json" + "fmt" + "strings" - "github.com/gabbla05/KittyProtocol/protocol" + "github.com/gabbla05/KittyProtocol/protocol" ) -// handleGetStatus processes a GET_STATUS frame. -// -// This handler performs a pure presence check: -// - parses the incoming frame, -// - verifies the target username, -// - checks whether the target user has an active session, -// - returns a STATUS_RES frame with "online", "offline", or "no_target". -// -// The Hub does NOT maintain chat‑level state and does NOT track -// which users are currently engaged in a conversation. GET_STATUS -// is strictly a presence query and does not affect routing logic. func (c *clientContext) handleGetStatus(raw []byte) { - frame, err := protocol.ParseGetStatusFrame(raw) - if err != nil { - sendError(c.stream, "ERR_02", err.Error()) - return - } - - // Empty target means: "client has no active chat partner". - // The Hub simply acknowledges this state with a STATUS_RES - // containing status = "no_target". No session fields are modified. - if frame.Target == "" { - res := protocol.StatusResFrame{ - BaseFrame: protocol.BaseFrame{ - Type: "STATUS_RES", - MsgID: frame.MsgID, - }, - Target: "", - Status: "no_target", - } - - b, _ := json.Marshal(res) - c.stream.Write(b) - return - } - - // Standard presence check. - online := globalSessions.IsOnline(frame.Target) - status := "offline" - if online { - status = "online" - } - - res := protocol.StatusResFrame{ - BaseFrame: protocol.BaseFrame{ - Type: "STATUS_RES", - MsgID: frame.MsgID, - }, - Target: frame.Target, - Status: status, - } - - b, err := json.Marshal(res) - if err != nil { - sendError(c.stream, "ERR_02", "Failed to marshal STATUS_RES") - return - } - - if _, err := c.stream.Write(b); err != nil { - fmt.Println("[Hub: Status] Failed to send STATUS_RES:", err) - } + if c.state != stateAuthenticated { + sendError(c.stream, "ERR_02", "GET_STATUS not allowed before AUTH") + return + } + + frame, err := protocol.ParseGetStatusFrame(raw) + if err != nil { + sendError(c.stream, "ERR_02", err.Error()) + return + } + + target := strings.ToLower(strings.TrimSpace(frame.Target)) + + online := globalSessions.IsOnline(target) + status := "offline" + if online { + status = "online" + } + + res := protocol.StatusResFrame{ + BaseFrame: protocol.BaseFrame{ + Type: protocol.FrameTypeStatusRes, + MsgID: frame.MsgID, + }, + Target: target, + Status: status, + } + + b, err := json.Marshal(res) + if err != nil { + sendError(c.stream, "ERR_02", "Failed to marshal STATUS_RES") + return + } + + if _, err := c.stream.Write(b); err != nil { + fmt.Println("[Hub: Status] Failed to send STATUS_RES:", err) + } } diff --git a/hub/logger.go b/hub/logger.go new file mode 100644 index 0000000..489a863 --- /dev/null +++ b/hub/logger.go @@ -0,0 +1,18 @@ +package main + +import ( + "fmt" + "time" +) + +func logInfo(msg string, args ...any) { + fmt.Printf("[INFO] %s: %s\n", time.Now().Format(time.RFC3339), fmt.Sprintf(msg, args...)) +} + +func logWarn(msg string, args ...any) { + fmt.Printf("[WARN] %s: %s\n", time.Now().Format(time.RFC3339), fmt.Sprintf(msg, args...)) +} + +func logError(msg string, args ...any) { + fmt.Printf("[ERROR] %s: %s\n", time.Now().Format(time.RFC3339), fmt.Sprintf(msg, args...)) +} diff --git a/hub/main.go b/hub/main.go index 15f7f0e..6a0f4c4 100644 --- a/hub/main.go +++ b/hub/main.go @@ -1,16 +1,7 @@ -// hub/main.go -// Entry point for the KittyProtocol Hub. -// Responsible for: -// - loading configuration -// - initializing TLS + QUIC -// - accepting incoming connections -// - dispatching each connection to handleClient() - package main import ( "context" - "fmt" "os" "os/signal" "syscall" @@ -30,11 +21,10 @@ var ( func main() { loadEnv() - // if sth goes wrong with reading env please try using _ = godotenv.Load() and tlsConf, err := certmanager.SetupTLSConfig("certs/cert.pem", "certs/key.pem") if err != nil { - fmt.Println("[Hub] Failed to load TLS certificates:", err) + logError("Failed to load TLS certificates: %v", err) return } @@ -52,19 +42,18 @@ func main() { listener, err := quic.ListenAddr(addr, tlsConf, quicConf) if err != nil { - fmt.Println("[Hub] Failed to start listener:", err) + logError("Failed to start listener: %v", err) return } - fmt.Println("[Hub] 🐈 KittyProtocol Hub listening on", addr) + logInfo("🐈 KittyProtocol Hub listening on %s", addr) setupSignalHandler(listener) - // Accept loop for { conn, err := listener.Accept(context.Background()) if err != nil { - fmt.Println("[Hub] Accept error:", err) + logError("Accept error: %v", err) return } @@ -72,19 +61,21 @@ func main() { } } -// loadEnv loads environment variables from .env if present. func loadEnv() { _ = godotenv.Load() } -// setupSignalHandler gracefully shuts down the listener on OS signals. func setupSignalHandler(listener *quic.Listener) { sigCh := make(chan os.Signal, 1) signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM, syscall.SIGQUIT) go func() { sig := <-sigCh - fmt.Println("\n[Hub] Caught signal:", sig) + logWarn("Caught signal: %v", sig) + + globalSessions.Stop() _ = listener.Close() + + logInfo("Graceful shutdown complete.") }() } diff --git a/hub/router.go b/hub/router.go index 5cb0617..07382c6 100644 --- a/hub/router.go +++ b/hub/router.go @@ -1,65 +1,53 @@ -// hub/router.go -// Routing logic for DATA frames between active user sessions. - package main import ( - "encoding/json" - "fmt" + "encoding/json" + "fmt" + "time" - "github.com/gabbla05/KittyProtocol/internal/protection" - "github.com/gabbla05/KittyProtocol/protocol" - "github.com/quic-go/quic-go" + "github.com/gabbla05/KittyProtocol/internal/protection" + "github.com/gabbla05/KittyProtocol/protocol" + "github.com/quic-go/quic-go" ) -// routeData forwards a DATA frame from the sender to the target session. -// -// This function performs only transport‑level validation: -// - verifies that the target session exists, -// - verifies that the target stream is available, -// - forwards the DATA frame unchanged except for the Sender field. -// -// The Hub does NOT interpret application‑level payloads and does NOT -// enforce chat‑level rules (e.g., active conversation state). All -// application logic is handled by clients. The Hub acts strictly as -// a message router. func routeData(frame protocol.DataFrame, sender *protection.Session, senderStream *quic.Stream) bool { - targetSess, ok := globalSessions.Get(frame.Target) - if !ok { - sendError(senderStream, "ERR_15", "Receiver offline") - return false - } - - if targetSess.Stream == nil { - sendError(senderStream, "ERR_10", "Receiver stream not available") - return false - } - - // Construct the forwarded DATA frame. The Hub does not modify - // application payloads or metadata beyond setting the Sender field. - forward := protocol.DataFrame{ - BaseFrame: protocol.BaseFrame{ - Type: "DATA", - MsgID: frame.MsgID, - }, - Sender: sender.ID, - Target: frame.Target, - Payload: frame.Payload, - MAC: frame.MAC, - } - - fb, err := json.Marshal(forward) - if err != nil { - fmt.Println("[Hub: Router] Failed to marshal forwarded DATA:", err) - sendError(senderStream, "ERR_02", "Failed to marshal forwarded DATA") - return false - } - - if _, err := targetSess.Stream.Write(fb); err != nil { - fmt.Println("[Hub: Router] Failed to deliver DATA to receiver:", err) - sendError(senderStream, "ERR_10", "Failed to deliver to receiver") - return false - } - - return true + targetSess, ok := globalSessions.Get(frame.Target) + if !ok { + sendError(senderStream, "ERR_15", "Receiver offline") + return false + } + + if targetSess.Stream == nil { + sendError(senderStream, "ERR_10", "Receiver stream not available") + return false + } + + sender.LastActive = time.Now() + targetSess.LastActive = time.Now() + + forward := protocol.DataFrame{ + BaseFrame: protocol.BaseFrame{ + Type: "DATA", + MsgID: frame.MsgID, + }, + Sender: sender.ID, + Target: frame.Target, + Payload: frame.Payload, + MAC: frame.MAC, + } + + fb, err := json.Marshal(forward) + if err != nil { + fmt.Println("[Hub: Router] Failed to marshal forwarded DATA:", err) + sendError(senderStream, "ERR_02", "Failed to marshal forwarded DATA") + return false + } + + if _, err := targetSess.Stream.Write(fb); err != nil { + fmt.Println("[Hub: Router] Failed to deliver DATA:", err) + sendError(senderStream, "ERR_10", "Failed to deliver to receiver") + return false + } + + return true } diff --git a/main.go b/main.go deleted file mode 100644 index dc79ce7..0000000 --- a/main.go +++ /dev/null @@ -1,18 +0,0 @@ -package main - -import ( - "fmt" - "os" - "path/filepath" - - "github.com/gabbla05/KittyProtocol/client/app" -) - -func main() { - dir := filepath.Dir(app.DefaultSecretStorePath()) - if _, err := os.Stat(dir); os.IsNotExist(err) { - fmt.Println("Katalog nie istnieje:", dir) - } else { - fmt.Println("Katalog istnieje:", dir) - } -} diff --git a/protocol/frames.go b/protocol/frames.go index db5a49c..d74071a 100644 --- a/protocol/frames.go +++ b/protocol/frames.go @@ -29,63 +29,59 @@ const ( // BaseFrame contains fields common to every frame. type BaseFrame struct { - Type string `json:"type"` // e.g. "HELLO", "AUTH", "DATA" - MsgID int64 `json:"msg_id"` // Timestamp used as a unique ID + Type string `json:"type"` + MsgID int64 `json:"msg_id"` } -// 1. HELLO – initial greeting frame. +// Frame definitions type HelloFrame struct { BaseFrame - Version string `json:"version"` // e.g. "1.0" + Version string `json:"version"` } -// 2. AUTH – authentication frame. type AuthFrame struct { BaseFrame - User string `json:"user"` // Username - Pass string `json:"pass"` // Password + User string `json:"user"` + Pass string `json:"pass"` } -// 3. DATA – E2EE payload transfer frame. type DataFrame struct { BaseFrame - Target string `json:"target,omitempty"` // Recipient (on sender side) - Sender string `json:"sender,omitempty"` // Sender (on receiver side – added by Hub) - Payload string `json:"payload"` // Encrypted Base64 payload - MAC string `json:"mac"` // HMAC for E2EE integrity + Target string `json:"target,omitempty"` + Sender string `json:"sender,omitempty"` + Payload string `json:"payload"` + MAC string `json:"mac"` } -// 4. MEOW_OK – application-level acknowledgment (ACK). type MeowOkFrame struct { BaseFrame - Status string `json:"status,omitempty"` // Optional status description + Status string `json:"status,omitempty"` } -// 5. ERROR – error frame. type ErrorFrame struct { BaseFrame - Code string `json:"code"` // Error code (e.g. ERR_02) - Desc string `json:"desc"` // Error description + Code string `json:"code"` + Desc string `json:"desc"` } -// 6. GET_STATUS – query for user status. type GetStatusFrame struct { BaseFrame - Target string `json:"target"` // User whose status is being queried + Target string `json:"target"` } -// 7. STATUS_RES – response with user status. type StatusResFrame struct { BaseFrame - Target string `json:"target"` // Queried user identifier - Status string `json:"status"` // "online", "offline", or "no_target" + Target string `json:"target"` + Status string `json:"status"` } -// 8. PING and 9. BYE – keep-alive and session termination. type PingFrame struct{ BaseFrame } type ByeFrame struct{ BaseFrame } -// IsValidType checks whether the given frame type is allowed by the protocol. +// ----------------------------------------------------------------------------- +// Helpers +// ----------------------------------------------------------------------------- + func IsValidType(t string) bool { switch t { case FrameTypeHello, @@ -102,25 +98,24 @@ func IsValidType(t string) bool { return false } -// GetFrameType performs a lightweight parse to extract frame type and msg_id, -// and strictly rejects malformed or incomplete frames. func GetFrameType(data []byte) (string, int64, error) { var base BaseFrame if err := json.Unmarshal(data, &base); err != nil { return "", 0, fmt.Errorf("%s: JSON parsing error", ErrCodeInvalidFrame) } - // Strict validation of required fields. if base.Type == "" || base.MsgID <= 0 { return "", 0, fmt.Errorf("%s: missing or invalid fields (type/msg_id)", ErrCodeInvalidFrame) } - // Verify that the type is one of the supported protocol types. if !IsValidType(base.Type) { return "", 0, fmt.Errorf("%s: unknown or invalid frame type", ErrCodeInvalidFrame) } return base.Type, base.MsgID, nil } -// ParseHelloFrame validates the initial HELLO frame. +// ----------------------------------------------------------------------------- +// Parsers +// ----------------------------------------------------------------------------- + func ParseHelloFrame(data []byte) (*HelloFrame, error) { var f HelloFrame if err := json.Unmarshal(data, &f); err != nil { @@ -141,7 +136,6 @@ func ParseHelloFrame(data []byte) (*HelloFrame, error) { return &f, nil } -// ParseAuthFrame strictly validates the AUTH frame. func ParseAuthFrame(data []byte) (*AuthFrame, error) { var f AuthFrame if err := json.Unmarshal(data, &f); err != nil { @@ -159,7 +153,6 @@ func ParseAuthFrame(data []byte) (*AuthFrame, error) { return &f, nil } -// ParseDataFrame validates the DATA frame and checks required E2EE fields. func ParseDataFrame(data []byte) (*DataFrame, error) { var f DataFrame if err := json.Unmarshal(data, &f); err != nil { @@ -171,10 +164,8 @@ func ParseDataFrame(data []byte) (*DataFrame, error) { if f.MsgID <= 0 { return nil, fmt.Errorf("%s: Invalid msg_id in DATA frame", ErrCodeInvalidFrame) } - // Sender must be empty on client‑side DATA frames; it is set by the Hub. - // Hub-side validation can allow non-empty Sender when forwarding. if f.Sender != "" { - return nil, fmt.Errorf("%s: Sender field must be empty in client DATA frame", ErrCodeInvalidFrame) + return nil, fmt.Errorf("%s: Sender must be empty in client DATA frame", ErrCodeInvalidFrame) } if f.Target == "" { return nil, fmt.Errorf("%s: Missing target in DATA frame", ErrCodeInvalidFrame) @@ -185,7 +176,6 @@ func ParseDataFrame(data []byte) (*DataFrame, error) { return &f, nil } -// ParseErrorFrame validates the ERROR frame. func ParseErrorFrame(data []byte) (*ErrorFrame, error) { var f ErrorFrame if err := json.Unmarshal(data, &f); err != nil { @@ -203,7 +193,6 @@ func ParseErrorFrame(data []byte) (*ErrorFrame, error) { return &f, nil } -// ParseGetStatusFrame validates the GET_STATUS frame. func ParseGetStatusFrame(data []byte) (*GetStatusFrame, error) { var f GetStatusFrame if err := json.Unmarshal(data, &f); err != nil { @@ -215,14 +204,12 @@ func ParseGetStatusFrame(data []byte) (*GetStatusFrame, error) { if f.MsgID <= 0 { return nil, fmt.Errorf("%s: Invalid msg_id in GET_STATUS frame", ErrCodeInvalidFrame) } - // Empty target is no longer allowed at protocol level. if f.Target == "" { return nil, fmt.Errorf("%s: Missing target in GET_STATUS frame", ErrCodeInvalidFrame) } return &f, nil } -// ParseStatusResFrame validates the STATUS_RES frame. func ParseStatusResFrame(data []byte) (*StatusResFrame, error) { var f StatusResFrame if err := json.Unmarshal(data, &f); err != nil { @@ -239,3 +226,31 @@ func ParseStatusResFrame(data []byte) (*StatusResFrame, error) { } return &f, nil } + +func ParsePingFrame(data []byte) (*PingFrame, error) { + var f PingFrame + if err := json.Unmarshal(data, &f); err != nil { + return nil, fmt.Errorf("%s: Invalid JSON format", ErrCodeInvalidFrame) + } + if f.Type != FrameTypePing { + return nil, fmt.Errorf("%s: Invalid type for PING frame", ErrCodeInvalidFrame) + } + if f.MsgID <= 0 { + return nil, fmt.Errorf("%s: Invalid msg_id in PING frame", ErrCodeInvalidFrame) + } + return &f, nil +} + +func ParseByeFrame(data []byte) (*ByeFrame, error) { + var f ByeFrame + if err := json.Unmarshal(data, &f); err != nil { + return nil, fmt.Errorf("%s: Invalid JSON format", ErrCodeInvalidFrame) + } + if f.Type != FrameTypeBye { + return nil, fmt.Errorf("%s: Invalid type for BYE frame", ErrCodeInvalidFrame) + } + if f.MsgID <= 0 { + return nil, fmt.Errorf("%s: Invalid msg_id in BYE frame", ErrCodeInvalidFrame) + } + return &f, nil +} From d3aa01a14a639f751b466b77f116e44b980054c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Brzezi=C5=84ski?= Date: Sat, 23 May 2026 13:29:22 +0000 Subject: [PATCH 07/44] corrected client package to make it mor eprofessional and relevant --- client/api/auth.go | 14 ++- client/api/connect.go | 17 +-- client/api/hello.go | 8 +- client/api/ping.go | 4 +- client/api/receive.go | 23 +--- client/api/send.go | 41 +++---- client/app/app.go | 12 +++ client/app/chat_logic.go | 4 + client/app/menu.go | 228 --------------------------------------- client/main.go | 2 +- client/ui_cli/menu.go | 158 +++++++++++++++++++++++++++ 11 files changed, 220 insertions(+), 291 deletions(-) delete mode 100644 client/app/menu.go create mode 100644 client/ui_cli/menu.go diff --git a/client/api/auth.go b/client/api/auth.go index 49da983..4b30e00 100644 --- a/client/api/auth.go +++ b/client/api/auth.go @@ -16,9 +16,9 @@ import ( // // PROTOCOL ORDER: // 1. SendHello() -// 2. WaitForHelloOK() +// 2. waitHelloOK() // 3. SendAuth() -// 4. WaitForAuthOK() +// 4. waitAuthOK() func (c *KittyClient) SendAuth(user, pass string) error { c.mu.Lock() stream := c.stream @@ -30,7 +30,7 @@ func (c *KittyClient) SendAuth(user, pass string) error { frame := protocol.AuthFrame{ BaseFrame: protocol.BaseFrame{ - Type: "AUTH", + Type: protocol.FrameTypeAuth, MsgID: time.Now().UnixMilli(), }, User: user, @@ -63,6 +63,10 @@ func (c *KittyClient) waitAuthOK() (bool, string) { stream := c.stream c.mu.Unlock() + if stream == nil { + return false, "NO_STREAM" + } + buf := make([]byte, 4096) n, err := stream.Read(buf) if err != nil { @@ -75,14 +79,14 @@ func (c *KittyClient) waitAuthOK() (bool, string) { } switch typeName { - case "ERROR": + case protocol.FrameTypeError: var errFrame protocol.ErrorFrame if json.Unmarshal(buf[:n], &errFrame) == nil { return false, errFrame.Code } return false, "PARSE_ERROR" - case "MEOW_OK": + case protocol.FrameTypeMeowOK: var okFrame protocol.MeowOkFrame if json.Unmarshal(buf[:n], &okFrame) != nil { return false, "PARSE_ERROR" diff --git a/client/api/connect.go b/client/api/connect.go index c79ff94..f4d0d66 100644 --- a/client/api/connect.go +++ b/client/api/connect.go @@ -37,29 +37,16 @@ func (c *KittyClient) Connect(hubAddr string) error { tlsConf := buildTLSConfig() - // QUIC Dial + // QUIC Dial (TLS 1.3 + TOFU via VerifyConnection in tlsConf) conn, err := quic.DialAddr(context.Background(), hubAddr, tlsConf, nil) if err != nil { return err } - // TOFU certificate verification - state := conn.ConnectionState() - if len(state.TLS.PeerCertificates) == 0 { - conn.CloseWithError(0, "no server certificate") - return errors.New("no server certificate") - } - - serverCert := state.TLS.PeerCertificates[0] - if err := verifyOrStoreServerCert(serverCert); err != nil { - conn.CloseWithError(0, "certificate verification failed") - return err - } - // Open QUIC stream stream, err := conn.OpenStreamSync(context.Background()) if err != nil { - conn.CloseWithError(0, "stream open failed") + _ = conn.CloseWithError(0, "stream open failed") return err } diff --git a/client/api/hello.go b/client/api/hello.go index d1475a7..1796f7b 100644 --- a/client/api/hello.go +++ b/client/api/hello.go @@ -45,6 +45,10 @@ func (c *KittyClient) waitHelloOK() (bool, string) { stream := c.stream c.mu.Unlock() + if stream == nil { + return false, "NO_STREAM" + } + buf := make([]byte, 4096) n, err := stream.Read(buf) if err != nil { @@ -57,14 +61,14 @@ func (c *KittyClient) waitHelloOK() (bool, string) { } switch typeName { - case "ERROR": + case protocol.FrameTypeError: var errFrame protocol.ErrorFrame if json.Unmarshal(buf[:n], &errFrame) == nil { return false, errFrame.Code } return false, "PARSE_ERROR" - case "MEOW_OK": + case protocol.FrameTypeMeowOK: var okFrame protocol.MeowOkFrame if json.Unmarshal(buf[:n], &okFrame) != nil { return false, "PARSE_ERROR" diff --git a/client/api/ping.go b/client/api/ping.go index ad25edd..6d18f36 100644 --- a/client/api/ping.go +++ b/client/api/ping.go @@ -23,7 +23,7 @@ func (c *KittyClient) StartPingLoop() { stop := c.stopPing c.mu.Unlock() - if stream == nil { + if stream == nil || stop == nil { return } @@ -47,7 +47,7 @@ func (c *KittyClient) StartPingLoop() { frame := protocol.PingFrame{ BaseFrame: protocol.BaseFrame{ - Type: "PING", + Type: protocol.FrameTypePing, MsgID: time.Now().UnixMilli(), }, } diff --git a/client/api/receive.go b/client/api/receive.go index fabfea7..1b40644 100644 --- a/client/api/receive.go +++ b/client/api/receive.go @@ -59,12 +59,12 @@ func (c *KittyClient) StartReceiverLoop(disconnected chan struct{}) { switch typeName { - case "MEOW_OK": + case protocol.FrameTypeMeowOK: if ackMgr != nil { ackMgr.NotifyDelivered(msgID) } - case "ERROR": + case protocol.FrameTypeError: var errFrame protocol.ErrorFrame if json.Unmarshal(buf[:n], &errFrame) == nil { fmt.Printf("\n[Client: Receive] Server ERROR %s: %s\n> ", @@ -73,7 +73,7 @@ func (c *KittyClient) StartReceiverLoop(disconnected chan struct{}) { fmt.Println("\n[Client: Receive] Failed to parse ERROR frame\n> ") } - case "DATA": + case protocol.FrameTypeData: var df protocol.DataFrame if json.Unmarshal(buf[:n], &df) != nil { fmt.Println("\n[Client: Receive] Failed to parse DATA frame\n> ") @@ -88,20 +88,7 @@ func (c *KittyClient) StartReceiverLoop(disconnected chan struct{}) { // Retrieve handler + keys c.mu.Lock() handler := c.appHandler - - var ( - kEnc []byte - kMac []byte - ok bool - ) - if c.peerKeys != nil { - pk, exists := c.peerKeys[df.Sender] - if exists { - kEnc = pk.kEnc - kMac = pk.kMac - ok = true - } - } + kEnc, kMac, ok := c.getKeysForPeer(df.Sender) c.mu.Unlock() if !ok { @@ -130,7 +117,7 @@ func (c *KittyClient) StartReceiverLoop(disconnected chan struct{}) { df.Sender, string(plaintext)) } - case "STATUS_RES": + case protocol.FrameTypeStatusRes: var sf protocol.StatusResFrame if json.Unmarshal(buf[:n], &sf) != nil { fmt.Println("\n[Client: Receive] Failed to parse STATUS_RES frame\n> ") diff --git a/client/api/send.go b/client/api/send.go index 33be15e..52ae822 100644 --- a/client/api/send.go +++ b/client/api/send.go @@ -3,32 +3,26 @@ package api import ( "encoding/json" "errors" + "strings" "time" "github.com/gabbla05/KittyProtocol/internal/cryptoee" "github.com/gabbla05/KittyProtocol/protocol" ) +// canonicalTarget normalizes the target username to a stable form. +// This must match the Hub's canonicalization logic. +func canonicalTarget(t string) string { + return strings.ToLower(strings.TrimSpace(t)) +} + // SendAppFrameEncrypted sends an application-level frame (chat control, text, etc.) // encrypted as a DATA frame. The Hub requires MAC for all DATA frames. func (c *KittyClient) SendAppFrameEncrypted(target string, payload []byte) error { c.mu.Lock() stream := c.stream ackMgr := c.ackMgr - - var ( - kEnc []byte - kMac []byte - ok bool - ) - if c.peerKeys != nil { - pk, exists := c.peerKeys[target] - if exists { - kEnc = pk.kEnc - kMac = pk.kMac - ok = true - } - } + kEnc, kMac, ok := c.getKeysForPeer(target) c.mu.Unlock() if !ok { @@ -47,9 +41,11 @@ func (c *KittyClient) SendAppFrameEncrypted(target string, payload []byte) error ackMgr.AddPending(msgID) } + canonTarget := canonicalTarget(target) + payloadB64, macB64, err := cryptoee.EncryptAndMACWithKeys( msgID, - target, + canonTarget, string(payload), kEnc, kMac, @@ -60,10 +56,10 @@ func (c *KittyClient) SendAppFrameEncrypted(target string, payload []byte) error frame := protocol.DataFrame{ BaseFrame: protocol.BaseFrame{ - Type: "DATA", + Type: protocol.FrameTypeData, MsgID: msgID, }, - Target: target, + Target: canonTarget, Payload: payloadB64, MAC: macB64, } @@ -73,6 +69,11 @@ func (c *KittyClient) SendAppFrameEncrypted(target string, payload []byte) error return err } + // Store last raw frame for replay testing (dev-only helper). + c.mu.Lock() + c.lastFrame = b + c.mu.Unlock() + _, err = stream.Write(b) return err } @@ -91,10 +92,10 @@ func (c *KittyClient) SendGetStatus(target string) error { frame := protocol.GetStatusFrame{ BaseFrame: protocol.BaseFrame{ - Type: "GET_STATUS", + Type: protocol.FrameTypeGetStatus, MsgID: msgID, }, - Target: target, + Target: canonicalTarget(target), } b, err := json.Marshal(frame) @@ -117,7 +118,7 @@ func (c *KittyClient) SendBye() error { } frame := protocol.BaseFrame{ - Type: "BYE", + Type: protocol.FrameTypeBye, MsgID: time.Now().UnixMilli(), } diff --git a/client/app/app.go b/client/app/app.go index a8a4149..c0a5238 100644 --- a/client/app/app.go +++ b/client/app/app.go @@ -39,3 +39,15 @@ func (a *App) InitSecretStoreForUser(username string) { _ = a.client.SetSharedSecretForPeer(peer, secret) } } + +func (a *App) Client() *api.KittyClient { + return a.client +} + +func (a *App) Secrets() *SecretStore { + return a.secrets +} + +func (a *App) Disconnected() <-chan struct{} { + return a.disconnected +} diff --git a/client/app/chat_logic.go b/client/app/chat_logic.go index 9d11bef..52de312 100644 --- a/client/app/chat_logic.go +++ b/client/app/chat_logic.go @@ -4,6 +4,7 @@ import ( "encoding/json" "errors" "fmt" + "strings" ) func (a *App) StartChatRequest(target string) error { @@ -57,6 +58,9 @@ func (a *App) SendTextMessage(text string) error { } func (a *App) sendAppFrame(frame ChatFrame) error { + // Canonicalize target to match transport‑layer expectations + frame.To = strings.ToLower(strings.TrimSpace(frame.To)) + data, err := json.Marshal(frame) if err != nil { return fmt.Errorf("marshal chat frame: %w", err) diff --git a/client/app/menu.go b/client/app/menu.go deleted file mode 100644 index 04eb199..0000000 --- a/client/app/menu.go +++ /dev/null @@ -1,228 +0,0 @@ -package app - -import ( - "bytes" - "os" - "strings" -) - -// RunMainMenu displays the main command loop. -func (a *App) RunMainMenu() { - for { - select { - case <-a.disconnected: - a.ui.Println("[Client] Rozłączono z serwerem. Zamykanie aplikacji.") - return - default: - } - - a.printMenu() - line := strings.TrimSpace(a.ui.ReadLine()) - if line == "" { - continue - } - - switch { - - // Exit - case line == "/quit": - _ = a.client.SendBye() - return - - // Presence status - case strings.HasPrefix(line, "/status "): - user := strings.TrimSpace(strings.TrimPrefix(line, "/status ")) - if user == "" { - a.ui.Println("Usage: /status ") - continue - } - _ = a.client.SendGetStatus(user) - - // Configure shared secret for a peer (E2EE) - case strings.HasPrefix(line, "/secret "): - args := strings.Fields(line) - if len(args) < 2 { - a.ui.Println("Usage: /secret [file:]") - continue - } - - user := args[1] - - var secret []byte - if len(args) == 3 && strings.HasPrefix(args[2], "file:") { - path := strings.TrimPrefix(args[2], "file:") - data, err := os.ReadFile(path) - if err != nil { - a.ui.Printf("[E2EE] Failed to read secret file: %v\n", err) - continue - } - secret = bytes.TrimSpace(data) - } else { - secret = a.ui.ReadSharedSecret() - } - - if err := a.client.SetSharedSecretForPeer(user, secret); err != nil { - a.ui.Println("[E2EE] Error deriving keys:", err) - continue - } - if err := a.secrets.Set(user, secret); err != nil { - a.ui.Println("[E2EE] Error saving secret:", err) - continue - } - - a.ui.Printf("[E2EE] Shared secret configured for %s.\n", user) - - // Start chat - case strings.HasPrefix(line, "/chat "): - user := strings.TrimSpace(strings.TrimPrefix(line, "/chat ")) - if user == "" { - a.ui.Println("Usage: /chat ") - continue - } - - if a.chatState.Active { - a.ui.Println("[CHAT] Masz już aktywny czat. Użyj /end aby zakończyć.") - continue - } - if a.chatState.PendingRequestFrom != "" { - a.ui.Printf("[CHAT] Masz oczekujący request od %s. Użyj /accept lub /refuse.\n", - a.chatState.PendingRequestFrom) - continue - } - - // Ensure E2EE secret for this peer. - if secret, ok := a.secrets.Get(user); ok { - if err := a.client.SetSharedSecretForPeer(user, secret); err != nil { - a.ui.Println("[E2EE] Error deriving keys from stored secret:", err) - continue - } - a.ui.Printf("[E2EE] Loaded stored shared secret for %s.\n", user) - } else { - secret := a.ui.ReadSharedSecret() - if err := a.client.SetSharedSecretForPeer(user, secret); err != nil { - a.ui.Println("[E2EE] Error deriving keys:", err) - continue - } - if err := a.secrets.Set(user, secret); err != nil { - a.ui.Println("[E2EE] Error saving secret:", err) - continue - } - } - - if err := a.StartChatRequest(user); err != nil { - a.ui.Println("Błąd:", err) - } else { - a.ui.Printf("[CHAT] Wysłano CHAT_REQUEST do %s.\n", user) - } - - // Accept chat request - case strings.HasPrefix(line, "/accept "): - user := strings.TrimSpace(strings.TrimPrefix(line, "/accept ")) - if user == "" { - a.ui.Println("Usage: /accept ") - continue - } - - if a.chatState.Active { - a.ui.Println("[CHAT] Już jesteś w czacie — nie możesz zaakceptować nowego.") - continue - } - if a.chatState.PendingRequestFrom != user { - a.ui.Printf("[CHAT] Nie masz oczekującego requestu od %s.\n", user) - continue - } - - // Ensure E2EE secret for this peer before accepting. - if secret, ok := a.secrets.Get(user); ok { - if err := a.client.SetSharedSecretForPeer(user, secret); err != nil { - a.ui.Println("[E2EE] Error deriving keys from stored secret:", err) - continue - } - a.ui.Printf("[E2EE] Loaded stored shared secret for %s.\n", user) - } else { - secret := a.ui.ReadSharedSecret() - if err := a.client.SetSharedSecretForPeer(user, secret); err != nil { - a.ui.Println("[E2EE] Error deriving keys:", err) - continue - } - if err := a.secrets.Set(user, secret); err != nil { - a.ui.Println("[E2EE] Error saving secret:", err) - continue - } - } - - if err := a.AcceptChat(user); err != nil { - a.ui.Println("Błąd:", err) - } else { - a.ui.Printf("[CHAT] Zaakceptowano czat z %s.\n", user) - } - - // Refuse chat request - case strings.HasPrefix(line, "/refuse "): - user := strings.TrimSpace(strings.TrimPrefix(line, "/refuse ")) - if user == "" { - a.ui.Println("Usage: /refuse ") - continue - } - - if a.chatState.Active { - a.ui.Println("[CHAT] Jesteś w czacie — nie możesz odrzucać requestów.") - continue - } - if a.chatState.PendingRequestFrom != user { - a.ui.Printf("[CHAT] Nie masz oczekującego requestu od %s.\n", user) - continue - } - - if err := a.RefuseChat(user, "user refused"); err != nil { - a.ui.Println("Błąd:", err) - } else { - a.ui.Printf("[CHAT] Odrzucono czat z %s.\n", user) - } - - // Send message in active chat - case strings.HasPrefix(line, "/msg "): - if !a.chatState.Active { - a.ui.Println("[CHAT] Nie jesteś w czacie. Użyj /chat .") - continue - } - - text := strings.TrimSpace(strings.TrimPrefix(line, "/msg ")) - if text == "" { - continue - } - - if err := a.SendTextMessage(text); err != nil { - a.ui.Println("Błąd:", err) - } - - // End chat - case line == "/end": - if !a.chatState.Active { - a.ui.Println("[CHAT] Nie jesteś w czacie.") - continue - } - - if err := a.EndChat("user ended chat"); err != nil { - a.ui.Println("Błąd:", err) - } else { - a.ui.Println("[CHAT] Zakończono czat.") - } - - default: - a.ui.Println("Nieznana komenda.") - } - } -} - -func (a *App) printMenu() { - a.ui.Println("Dostępne komendy:") - a.ui.Println(" /status ") - a.ui.Println(" /secret # configure shared secret for peer") - a.ui.Println(" /chat ") - a.ui.Println(" /accept ") - a.ui.Println(" /refuse ") - a.ui.Println(" /msg ") - a.ui.Println(" /end") - a.ui.Println(" /quit") -} diff --git a/client/main.go b/client/main.go index 64b0360..11cedf9 100644 --- a/client/main.go +++ b/client/main.go @@ -76,7 +76,7 @@ func main() { client.StartPingLoop() // 9. Main workflow - application.RunMainMenu() + ui.RunMainMenu(application) // 10. Cleanup client.Close() diff --git a/client/ui_cli/menu.go b/client/ui_cli/menu.go new file mode 100644 index 0000000..9498dde --- /dev/null +++ b/client/ui_cli/menu.go @@ -0,0 +1,158 @@ +package ui_cli + +import ( + "bytes" + "os" + "strings" + + "github.com/gabbla05/KittyProtocol/client/app" +) + +// RunMainMenu displays the main command loop for CLI. +func (ui *CliUI) RunMainMenu(a *app.App) { + for { + select { + case <-a.Disconnected(): + ui.Println("[Client] Rozłączono z serwerem. Zamykanie aplikacji.") + return + default: + } + + ui.printMenu() + line := strings.TrimSpace(ui.ReadLine()) + if line == "" { + continue + } + + switch { + + // Exit + case line == "/quit": + _ = a.Client().SendBye() + return + + // Presence status + case strings.HasPrefix(line, "/status "): + user := strings.TrimSpace(strings.TrimPrefix(line, "/status ")) + user = strings.ToLower(user) + if user == "" { + ui.Println("Usage: /status ") + continue + } + _ = a.Client().SendGetStatus(user) + + // Configure shared secret for a peer (E2EE) + case strings.HasPrefix(line, "/secret "): + args := strings.Fields(line) + if len(args) < 2 { + ui.Println("Usage: /secret [file:]") + continue + } + + user := strings.ToLower(args[1]) + + var secret []byte + if len(args) == 3 && strings.HasPrefix(args[2], "file:") { + path := strings.TrimPrefix(args[2], "file:") + data, err := os.ReadFile(path) + if err != nil { + ui.Printf("[E2EE] Failed to read secret file: %v\n", err) + continue + } + secret = bytes.TrimSpace(data) + } else { + secret = ui.ReadSharedSecret() + } + + if err := a.Client().SetSharedSecretForPeer(user, secret); err != nil { + ui.Println("[E2EE] Error deriving keys:", err) + continue + } + if err := a.Secrets().Set(user, secret); err != nil { + ui.Println("[E2EE] Error saving secret:", err) + continue + } + + ui.Printf("[E2EE] Shared secret configured for %s.\n", user) + + // Start chat + case strings.HasPrefix(line, "/chat "): + user := strings.TrimSpace(strings.TrimPrefix(line, "/chat ")) + user = strings.ToLower(user) + if user == "" { + ui.Println("Usage: /chat ") + continue + } + + if err := a.StartChatRequest(user); err != nil { + ui.Println("Błąd:", err) + } else { + ui.Printf("[CHAT] Wysłano CHAT_REQUEST do %s.\n", user) + } + + // Accept chat request + case strings.HasPrefix(line, "/accept "): + user := strings.TrimSpace(strings.TrimPrefix(line, "/accept ")) + user = strings.ToLower(user) + if user == "" { + ui.Println("Usage: /accept ") + continue + } + + if err := a.AcceptChat(user); err != nil { + ui.Println("Błąd:", err) + } else { + ui.Printf("[CHAT] Zaakceptowano czat z %s.\n", user) + } + + // Refuse chat request + case strings.HasPrefix(line, "/refuse "): + user := strings.TrimSpace(strings.TrimPrefix(line, "/refuse ")) + user = strings.ToLower(user) + if user == "" { + ui.Println("Usage: /refuse ") + continue + } + + if err := a.RefuseChat(user, "user refused"); err != nil { + ui.Println("Błąd:", err) + } else { + ui.Printf("[CHAT] Odrzucono czat z %s.\n", user) + } + + // Send message in active chat + case strings.HasPrefix(line, "/msg "): + text := strings.TrimSpace(strings.TrimPrefix(line, "/msg ")) + if text == "" { + continue + } + + if err := a.SendTextMessage(text); err != nil { + ui.Println("Błąd:", err) + } + + // End chat + case line == "/end": + if err := a.EndChat("user ended chat"); err != nil { + ui.Println("Błąd:", err) + } else { + ui.Println("[CHAT] Zakończono czat.") + } + + default: + ui.Println("Nieznana komenda.") + } + } +} + +func (ui *CliUI) printMenu() { + ui.Println("Dostępne komendy:") + ui.Println(" /status ") + ui.Println(" /secret # configure shared secret for peer") + ui.Println(" /chat ") + ui.Println(" /accept ") + ui.Println(" /refuse ") + ui.Println(" /msg ") + ui.Println(" /end") + ui.Println(" /quit") +} From 868a704707edc66dc46e154f8380084efd645c35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Brzezi=C5=84ski?= Date: Sat, 23 May 2026 19:31:15 +0000 Subject: [PATCH 08/44] working app prototype!!! time to making it better --- .env.example | 24 +++++++-- TODO | 17 ++++--- ciekawe_info.md | 2 - client/api/auth.go | 86 ++++++++++++++++++++++--------- client/api/connect.go | 27 +++++----- client/main.go | 42 ++++----------- client/ui_cli/auth_flow.go | 64 +++++++++++++++++++++++ client/ui_cli/ui_cli.go | 6 ++- cmd/auth_test/main.go | 40 +++++++++++++++ docker-compose.yaml | 35 +++++++++++++ go.mod | 3 +- go.sum | 2 + hub/auth_flow.go | 79 ++++++++++++++++------------- hub/handler_auth.go | 39 +++++++++++++- hub/handler_dispatcher.go | 3 ++ hub/handler_hello.go | 25 ++++++++- hub/main.go | 11 ++++ init.sql | 6 +++ internal/auth/auth.go | 69 +++++++++++++++++++++++++ internal/auth/db_auth.go | 93 ++++++++++++++++++++++++++++++++++ internal/protection/limiter.go | 6 ++- markdowns/developers_stuff.md | 3 ++ protocol/frames.go | 31 ++++++++---- 23 files changed, 580 insertions(+), 133 deletions(-) delete mode 100644 ciekawe_info.md create mode 100644 client/ui_cli/auth_flow.go create mode 100644 cmd/auth_test/main.go create mode 100644 docker-compose.yaml create mode 100644 init.sql create mode 100644 internal/auth/db_auth.go diff --git a/.env.example b/.env.example index d7034dd..644a91a 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,20 @@ -# KittyProtocol Hub address conf - adjust to situtation (given addres is private vm on Azure) -# 20.82.139.232:9999 -KITTY_HUB_ADDR=127.0.0.1:9999 +# ----------------------------- +# PostgreSQL configuration +# ----------------------------- +POSTGRES_USER=kitty +POSTGRES_PASSWORD=kittypass +POSTGRES_DB=kittyhub +POSTGRES_PORT=5432 + +# ----------------------------- +# pgAdmin configuration +# ----------------------------- +PGADMIN_EMAIL=admin@example.com +PGADMIN_PASSWORD=adminpass +PGADMIN_PORT=5050 -# KittyProtocol Intercept address conf - adjust to situtation (better not to change) -KITTY_INTERCEPT_ADDR=0.0.0.0:9999 \ No newline at end of file +# ----------------------------- +# KittyProtocol configuration +# ----------------------------- +KITTY_HUB_ADDR=127.0.0.1:9999 +KITTY_INTERCEPT_ADDR=0.0.0.0:9999 diff --git a/TODO b/TODO index dc3e6a6..51f7a01 100644 --- a/TODO +++ b/TODO @@ -1,10 +1,13 @@ -- nawiązanie połączenia pomiędzy użytkownikami już jest po stronie aplikacji !! - przy prezentowaniu gotowego projektu można pokazać że na warstwie protokołu działa operacja /replay która symuluje ten atak -- TESTY DO NOWEGO CRYPTOEE_TEST -- TESTY OGÓLNIE +- TESTY OGÓLNIE - LEPIEJ POMYŚLANE, MOŻĘ JAKOŚ LEPIEJ USTRUKTURYZOWAĆ - ŁADOWANIE KLUCZY KONWERSACJI Z PLIKU! -- merge -- rozwiązanie problemu z 3 rozmówcami podającymi ten sam sekret - status busy + pojedynczy czat naraz - logika aplikacyjna - brak mockowania!! zrobic w końcu normalne połączenie z bazą danych i tam przechowywanie, a nie tak jak teraz `internal/auth/auth.go` -- jeżeli ramka PROTOKOŁU pełni obecnie więcej niż jedną funkcję logiczną (jak np. w przypadku operacji /quit i GET_STATUS) to gdy zaimpplementuje się logikę aplikacji to zmienić koniecznie -- sprawdzić czy kody błędów się zgadzają z dokumentacją \ No newline at end of file +- sprawdzić czy kody błędów się zgadzają z dokumentacją +- info dodatkowe: + jak nie ma usera to gdy pytamy o jego status, to mimo to pokazuje offline gdy pytamy czy jest - brak zdradzania czy user istnieje +- CURRENT_PROJECT do zmiany +- dać kroki dla dewelopera któ©y ma stestować działanie bazy - jakie komendy, co usunąć itp. +- jaki jest sens registerFrame zamiast po prostu zrobić parse registerframe na authframe skoro to dosłownie ta sama ramka +- forma przechowywania sekretóœ lokalnie - słabe szyfrowanie chyba bo widać że sekrety są te same. Zamiast tego zastanowić się nad hashowaniem + +- !!!!WAŻNE!!! plik ze stałymi (W tym z dokumentacji) aby w kodzie nie używać magicznych liczb, ale konkretnych stałych \ No newline at end of file diff --git a/ciekawe_info.md b/ciekawe_info.md deleted file mode 100644 index cdce24b..0000000 --- a/ciekawe_info.md +++ /dev/null @@ -1,2 +0,0 @@ -- jak nie ma usera to gdy pytamy o jego status, to mim oto pokazuje offline gdy pytamy czy jest - brak zdradzania czy user istnieje -- coś innego \ No newline at end of file diff --git a/client/api/auth.go b/client/api/auth.go index 4b30e00..198084f 100644 --- a/client/api/auth.go +++ b/client/api/auth.go @@ -3,22 +3,16 @@ package api import ( "encoding/json" "errors" + "fmt" "time" "github.com/gabbla05/KittyProtocol/protocol" ) -// SendAuth sends an AUTH frame with username and password to the Hub. -// -// SECURITY: -// - Credentials are transmitted inside a TLS 1.3 encrypted QUIC stream. -// - The Hub validates credentials and responds with MEOW_OK or ERROR. -// -// PROTOCOL ORDER: -// 1. SendHello() -// 2. waitHelloOK() -// 3. SendAuth() -// 4. waitAuthOK() +// ----------------------------------------------------------------------------- +// AUTH +// ----------------------------------------------------------------------------- + func (c *KittyClient) SendAuth(user, pass string) error { c.mu.Lock() stream := c.stream @@ -42,8 +36,7 @@ func (c *KittyClient) SendAuth(user, pass string) error { return err } - // Persist authenticated username in client state. - // This is used by the App layer (chat frames, UI, etc.). + // Save username c.mu.Lock() c.user = user c.mu.Unlock() @@ -52,13 +45,58 @@ func (c *KittyClient) SendAuth(user, pass string) error { return err } -// waitAuthOK waits for MEOW_OK or ERROR after AUTH. -// Returns (success, errorCode). -// -// BLOCKING BEHAVIOR: -// - This call blocks until the Hub responds or the stream errors. -// - QUIC idle timeout ensures this does not block indefinitely. -func (c *KittyClient) waitAuthOK() (bool, string) { +func (c *KittyClient) WaitForAuthOK() error { + ok, code := c.waitOkOrError() + if !ok { + return errors.New(code) + } + return nil +} + +// ----------------------------------------------------------------------------- +// REGISTER +// ----------------------------------------------------------------------------- + +func (c *KittyClient) SendRegister(user, pass string) error { + c.mu.Lock() + stream := c.stream + c.mu.Unlock() + + if stream == nil { + return errors.New("stream is nil") + } + + frame := protocol.AuthFrame{ + BaseFrame: protocol.BaseFrame{ + Type: protocol.FrameTypeRegister, + MsgID: time.Now().UnixMilli(), + }, + User: user, + Pass: pass, + } + + b, err := json.Marshal(frame) + if err != nil { + return err + } + + _, err = stream.Write(b) + return err +} + +func (c *KittyClient) WaitForRegisterOK() error { + ok, code := c.waitOkOrError() + if !ok { + return errors.New(code) + } + return nil +} + +// ----------------------------------------------------------------------------- +// Shared helper +// ----------------------------------------------------------------------------- + +func (c *KittyClient) waitOkOrError() (bool, string) { c.mu.Lock() stream := c.stream c.mu.Unlock() @@ -79,18 +117,18 @@ func (c *KittyClient) waitAuthOK() (bool, string) { } switch typeName { + case protocol.FrameTypeError: var errFrame protocol.ErrorFrame if json.Unmarshal(buf[:n], &errFrame) == nil { + if errFrame.Desc != "" { + return false, fmt.Sprintf("%s: %s", errFrame.Code, errFrame.Desc) + } return false, errFrame.Code } return false, "PARSE_ERROR" case protocol.FrameTypeMeowOK: - var okFrame protocol.MeowOkFrame - if json.Unmarshal(buf[:n], &okFrame) != nil { - return false, "PARSE_ERROR" - } return true, "" } diff --git a/client/api/connect.go b/client/api/connect.go index f4d0d66..7440249 100644 --- a/client/api/connect.go +++ b/client/api/connect.go @@ -37,16 +37,29 @@ func (c *KittyClient) Connect(hubAddr string) error { tlsConf := buildTLSConfig() - // QUIC Dial (TLS 1.3 + TOFU via VerifyConnection in tlsConf) + // QUIC Dial conn, err := quic.DialAddr(context.Background(), hubAddr, tlsConf, nil) if err != nil { return err } + // TOFU certificate verification + state := conn.ConnectionState() + if len(state.TLS.PeerCertificates) == 0 { + conn.CloseWithError(0, "no server certificate") + return errors.New("no server certificate") + } + + serverCert := state.TLS.PeerCertificates[0] + if err := verifyOrStoreServerCert(serverCert); err != nil { + conn.CloseWithError(0, "certificate verification failed") + return err + } + // Open QUIC stream stream, err := conn.OpenStreamSync(context.Background()) if err != nil { - _ = conn.CloseWithError(0, "stream open failed") + conn.CloseWithError(0, "stream open failed") return err } @@ -86,13 +99,3 @@ func (c *KittyClient) WaitForHelloOK() error { } return errors.New(code) } - -// WaitForAuthOK waits for MEOW_OK after AUTH and transitions to StateSelectingTarget. -func (c *KittyClient) WaitForAuthOK() error { - ok, code := c.waitAuthOK() - if ok { - c.setState(StateSelectingTarget) - return nil - } - return errors.New(code) -} diff --git a/client/main.go b/client/main.go index 11cedf9..781878c 100644 --- a/client/main.go +++ b/client/main.go @@ -1,11 +1,6 @@ -// main.go -// Entry point for the CLI version of KittyClient. -// This file wires together the API, UI and App layers. - package main import ( - "fmt" "os" "os/signal" "syscall" @@ -19,71 +14,55 @@ func main() { client := api.NewKittyClient() ui := ui_cli.NewCliUI(client) - // Shared disconnection channel for App and background loops. disconnected := make(chan struct{}) - // Resolve Hub address. hubAddr := os.Getenv("KITTY_HUB_ADDR") if hubAddr == "" { hubAddr = "127.0.0.1:9999" } - fmt.Println("[Client] Connecting to Hub:", hubAddr) + ui.Println("[Client] Connecting to Hub:", hubAddr) - // 1. QUIC connection + stream + HELLO (Connect() does all of this) if err := client.Connect(hubAddr); err != nil { - fmt.Println("[Client] Connection error:", err) + ui.Println("[Client] Connection error:", err) return } - // 2. Wait for MEOW_OK after HELLO if err := client.WaitForHelloOK(); err != nil { - fmt.Println("[Client] HELLO failed:", err) + ui.Println("[Client] HELLO failed:", err) client.Close() return } - // 3. AUTH - user, pass := ui.ReadCredentials() - - if err := client.SendAuth(user, pass); err != nil { - fmt.Println("[Client] AUTH send error:", err) + // ------------------------------- + // AUTH FLOW (CLI-specific) + // ------------------------------- + if err := ui.RunAuthFlow(client); err == ui_cli.ErrQuitRequested { client.Close() return } - if err := client.WaitForAuthOK(); err != nil { - fmt.Println("[Client] AUTH failed:", err) - client.Close() - return - } + // ------------------------------- + // AUTH SUCCESS → start application + // ------------------------------- - // 4. Create App layer application := app.NewApp(client, ui, disconnected) - // 5. Initialize per-user SecretStore application.InitSecretStoreForUser(client.User()) - // 6. Register handlers client.RegisterAckHandler(ui) client.RegisterAppPayloadHandler(application.HandleIncomingPayload) - // 7. OS signal handling setupSignalHandler(client) - // 8. Start background loops client.StartReceiverLoop(disconnected) client.StartPingLoop() - // 9. Main workflow ui.RunMainMenu(application) - // 10. Cleanup client.Close() } -// setupSignalHandler installs a handler for OS termination signals. -// On signal, the client sends BYE, closes the session and exits. func setupSignalHandler(client *api.KittyClient) { sigCh := make(chan os.Signal, 1) signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM, syscall.SIGQUIT) @@ -92,7 +71,6 @@ func setupSignalHandler(client *api.KittyClient) { <-sigCh _ = client.SendBye() client.Close() - fmt.Println("\n[Client] Session closed due to signal.") os.Exit(0) }() } diff --git a/client/ui_cli/auth_flow.go b/client/ui_cli/auth_flow.go new file mode 100644 index 0000000..4869ee4 --- /dev/null +++ b/client/ui_cli/auth_flow.go @@ -0,0 +1,64 @@ +package ui_cli + +import ( + "errors" + "strings" + + "github.com/gabbla05/KittyProtocol/client/api" +) + +var ErrQuitRequested = errors.New("quit requested") + +// RunAuthFlow handles /login and /register before entering main menu. +// This is CLI-specific and will be replaced by GUI in the future. +func (ui *CliUI) RunAuthFlow(client *api.KittyClient) error { + for { + ui.Println("Wybierz opcję:") + ui.Println(" /login") + ui.Println(" /register") + ui.Println(" /quit") + + cmd := strings.TrimSpace(ui.ReadLine()) + + switch cmd { + + case "/quit": + client.Close() + return ErrQuitRequested + + case "/register": + user, pass := ui.ReadCredentials() + + if err := client.SendRegister(user, pass); err != nil { + ui.Println("[Client] REGISTER send error:", err) + continue + } + + if err := client.WaitForRegisterOK(); err != nil { + ui.Println("[Client] REGISTER failed:", err) + continue + } + + ui.Println("[Client] REGISTER OK — możesz się teraz zalogować.") + + case "/login": + user, pass := ui.ReadCredentials() + + if err := client.SendAuth(user, pass); err != nil { + ui.Println("[Client] AUTH send error:", err) + continue + } + + if err := client.WaitForAuthOK(); err != nil { + ui.Println("[Client] AUTH failed:", err) + continue + } + + ui.Println("[Client] AUTH OK — zalogowano.") + return nil + + default: + ui.Println("Nieznana komenda.") + } + } +} diff --git a/client/ui_cli/ui_cli.go b/client/ui_cli/ui_cli.go index 1a24e0b..2c5e08c 100644 --- a/client/ui_cli/ui_cli.go +++ b/client/ui_cli/ui_cli.go @@ -33,7 +33,6 @@ func NewCliUI(c *api.KittyClient) *CliUI { // ReadLine reads a single line from stdin, trimming whitespace. func (ui *CliUI) ReadLine() string { - fmt.Print("> ") line, _ := ui.reader.ReadString('\n') return strings.TrimSpace(line) } @@ -43,6 +42,11 @@ func (ui *CliUI) Println(v ...any) { fmt.Println(v...) } +// Print prints a line to stdout withou \n at the end. +func (ui *CliUI) Print(v ...any) { + fmt.Print(v...) +} + // Printf prints a formatted line to stdout. func (ui *CliUI) Printf(format string, v ...any) { fmt.Printf(format, v...) diff --git a/cmd/auth_test/main.go b/cmd/auth_test/main.go new file mode 100644 index 0000000..89ecd78 --- /dev/null +++ b/cmd/auth_test/main.go @@ -0,0 +1,40 @@ +package main + +import ( + "database/sql" + "fmt" + + "github.com/gabbla05/KittyProtocol/internal/auth" + _ "github.com/lib/pq" +) + +func main() { + db, err := sql.Open("postgres", "postgres://kitty:kittypass@localhost:5432/kittyhub?sslmode=disable") + if err != nil { + panic(err) + } + defer db.Close() + + a := auth.NewDBAuth(db) + + // Register + if err := a.Register("testuser", "supersecret"); err != nil { + fmt.Println("Register error:", err) + } else { + fmt.Println("Register OK") + } + + // CheckCredentials (OK) + if a.CheckCredentials("testuser", "supersecret") { + fmt.Println("Login OK") + } else { + fmt.Println("Login FAILED") + } + + // CheckCredentials (bad password) + if a.CheckCredentials("testuser", "wrongpass") { + fmt.Println("Login should NOT succeed with wrong password") + } else { + fmt.Println("Login failed as expected (wrong password)") + } +} diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..08930e6 --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,35 @@ +version: "3.9" + +services: + db: + image: postgres:latest + restart: always + env_file: + - .env + environment: + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_DB: ${POSTGRES_DB} + ports: + - "${POSTGRES_PORT}:5432" + volumes: + - dbdata:/var/lib/postgresql + + pgadmin: + image: dpage/pgadmin4:latest + restart: always + env_file: + - .env + environment: + PGADMIN_DEFAULT_EMAIL: ${PGADMIN_EMAIL} + PGADMIN_DEFAULT_PASSWORD: ${PGADMIN_PASSWORD} + ports: + - "${PGADMIN_PORT}:80" + depends_on: + - db + volumes: + - pgadmindata:/var/lib/pgadmin + +volumes: + dbdata: + pgadmindata: diff --git a/go.mod b/go.mod index d2b03c1..905d090 100644 --- a/go.mod +++ b/go.mod @@ -3,12 +3,13 @@ module github.com/gabbla05/KittyProtocol go 1.24 require ( + github.com/joho/godotenv v1.5.1 + github.com/lib/pq v1.12.3 github.com/quic-go/quic-go v0.59.0 golang.org/x/crypto v0.41.0 ) require ( - github.com/joho/godotenv v1.5.1 // indirect golang.org/x/net v0.43.0 // indirect golang.org/x/sys v0.35.0 // indirect ) diff --git a/go.sum b/go.sum index 11e1b6a..01b30f5 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ= +github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= diff --git a/hub/auth_flow.go b/hub/auth_flow.go index ecd24cc..8f3c4aa 100644 --- a/hub/auth_flow.go +++ b/hub/auth_flow.go @@ -3,39 +3,46 @@ package main -import ( - "encoding/json" - "fmt" - "time" - - "github.com/gabbla05/KittyProtocol/internal/protection" - "github.com/gabbla05/KittyProtocol/protocol" - "github.com/quic-go/quic-go" -) - -// handleHELLO processes the initial HELLO frame and starts the AUTH timeout timer. -// It responds with MEOW_OK(status="Ready for auth"). -func handleHELLO(stream *quic.Stream, conn *quic.Conn) *protection.AuthTimer { - ok := protocol.MeowOkFrame{ - BaseFrame: protocol.BaseFrame{ - Type: "MEOW_OK", - MsgID: time.Now().UnixMilli(), - }, - Status: "Ready for auth", - } - - if b, err := json.Marshal(ok); err == nil { - if _, err := stream.Write(b); err != nil { - fmt.Println("[Hub: AuthFlow] Failed to send MEOW_OK:", err) - } - } else { - fmt.Println("[Hub: AuthFlow] Failed to marshal MEOW_OK:", err) - } - - // Start 20-second AUTH timeout. - return protection.StartAuthTimer(func() { - // On timeout, send ERR_03 and close the connection. - sendError(stream, "ERR_03", "Authorization timeout reached") - _ = conn.CloseWithError(0x03, "ERR_03: Auth Timeout") - }) -} +// import ( +// "encoding/json" +// "fmt" + +// "github.com/gabbla05/KittyProtocol/internal/protection" +// "github.com/gabbla05/KittyProtocol/protocol" +// ) + +// // handleHello processes the initial HELLO frame and starts the AUTH timeout timer. +// // It responds with MEOW_OK(status="Ready for auth"). +// func (c *clientContext) handleHello(raw []byte) { +// // Parse HELLO +// frame, err := protocol.ParseHelloFrame(raw) +// if err != nil { +// sendError(c.stream, "ERR_02", err.Error()) +// return +// } + +// // Set state +// c.state = stateHelloReceived + +// // Send MEOW_OK +// ok := protocol.MeowOkFrame{ +// BaseFrame: protocol.BaseFrame{ +// Type: protocol.FrameTypeMeowOK, +// MsgID: frame.MsgID, +// }, +// Status: "Ready for auth", +// } + +// b, err := json.Marshal(ok) +// if err == nil { +// _, _ = c.stream.Write(b) +// } else { +// fmt.Println("[Hub: HELLO] Failed to marshal MEOW_OK:", err) +// } + +// // Start AUTH timeout +// c.authTimer = protection.StartAuthTimer(func() { +// sendError(c.stream, "ERR_03", "Authorization timeout reached") +// _ = c.conn.CloseWithError(0x03, "ERR_03: Auth Timeout") +// }) +// } diff --git a/hub/handler_auth.go b/hub/handler_auth.go index e0f53d0..1635b65 100644 --- a/hub/handler_auth.go +++ b/hub/handler_auth.go @@ -42,7 +42,7 @@ func (c *clientContext) handleAuth(raw []byte) { ok := protocol.MeowOkFrame{ BaseFrame: protocol.BaseFrame{ - Type: "MEOW_OK", + Type: protocol.FrameTypeMeowOK, MsgID: frame.MsgID, }, Status: "Logged in", @@ -50,8 +50,43 @@ func (c *clientContext) handleAuth(raw []byte) { b, err := json.Marshal(ok) if err == nil { - c.stream.Write(b) + _, _ = c.stream.Write(b) } else { fmt.Println("[Hub: Auth] Failed to marshal MEOW_OK:", err) } } + +func (c *clientContext) handleRegister(raw []byte) { + if c.state != stateHelloReceived { + sendError(c.stream, "ERR_02", "REGISTER not allowed before HELLO") + return + } + + frame, err := protocol.ParseRegisterFrame(raw) + if err != nil { + sendError(c.stream, "ERR_02", err.Error()) + return + } + + // Rejestracja NIE tworzy sesji i NIE loguje użytkownika. + // Klient po udanej rejestracji powinien wykonać osobne AUTH. + if err := globalAuth.Register(frame.User, frame.Pass); err != nil { + sendError(c.stream, "ERR_06", err.Error()) + return + } + + ok := protocol.MeowOkFrame{ + BaseFrame: protocol.BaseFrame{ + Type: protocol.FrameTypeMeowOK, + MsgID: frame.MsgID, + }, + Status: "Registered", + } + + b, err := json.Marshal(ok) + if err == nil { + _, _ = c.stream.Write(b) + } else { + fmt.Println("[Hub: Register] Failed to marshal MEOW_OK:", err) + } +} diff --git a/hub/handler_dispatcher.go b/hub/handler_dispatcher.go index 1bee050..96f08dd 100644 --- a/hub/handler_dispatcher.go +++ b/hub/handler_dispatcher.go @@ -49,6 +49,9 @@ func handleClient(conn *quic.Conn) { case protocol.FrameTypeAuth: ctx.handleAuth(raw) + case protocol.FrameTypeRegister: + ctx.handleRegister(raw) + case protocol.FrameTypePing: ctx.handlePing(raw) diff --git a/hub/handler_hello.go b/hub/handler_hello.go index 2bc4632..81c5986 100644 --- a/hub/handler_hello.go +++ b/hub/handler_hello.go @@ -1,8 +1,10 @@ package main import ( + "encoding/json" "fmt" + "github.com/gabbla05/KittyProtocol/internal/protection" "github.com/gabbla05/KittyProtocol/protocol" ) @@ -20,6 +22,27 @@ func (c *clientContext) handleHello(raw []byte) { fmt.Println("[Hub] HELLO from client, version:", hello.Version) - c.authTimer = handleHELLO(c.stream, c.conn) + // Set state c.state = stateHelloReceived + + // Send MEOW_OK + ok := protocol.MeowOkFrame{ + BaseFrame: protocol.BaseFrame{ + Type: protocol.FrameTypeMeowOK, + MsgID: hello.MsgID, + }, + Status: "Ready for auth", + } + + if b, err := json.Marshal(ok); err == nil { + _, _ = c.stream.Write(b) + } else { + fmt.Println("[Hub: HELLO] Failed to marshal MEOW_OK:", err) + } + + // Start AUTH timeout + c.authTimer = protection.StartAuthTimer(func() { + sendError(c.stream, "ERR_03", "Authorization timeout reached") + _ = c.conn.CloseWithError(0x03, "ERR_03: Auth Timeout") + }) } diff --git a/hub/main.go b/hub/main.go index 6a0f4c4..fffb484 100644 --- a/hub/main.go +++ b/hub/main.go @@ -2,6 +2,7 @@ package main import ( "context" + "database/sql" "os" "os/signal" "syscall" @@ -11,6 +12,7 @@ import ( "github.com/gabbla05/KittyProtocol/internal/certmanager" "github.com/gabbla05/KittyProtocol/internal/protection" "github.com/joho/godotenv" + _ "github.com/lib/pq" "github.com/quic-go/quic-go" ) @@ -28,6 +30,15 @@ func main() { return } + dsn := "postgres://kitty:kittypass@localhost:5432/kittyhub?sslmode=disable" + db, err := sql.Open("postgres", dsn) + if err != nil { + logError("DB connection failed: %v", err) + return + } + + globalAuth = auth.NewDBAuth(db) + quicConf := &quic.Config{ MaxIdleTimeout: 60 * time.Second, KeepAlivePeriod: 30 * time.Second, diff --git a/init.sql b/init.sql new file mode 100644 index 0000000..e44b3a1 --- /dev/null +++ b/init.sql @@ -0,0 +1,6 @@ +CREATE TABLE IF NOT EXISTS users ( + id SERIAL PRIMARY KEY, + username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT NOW() +); diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 2a00f0c..8589f82 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -2,6 +2,7 @@ package auth import ( "fmt" + "regexp" "golang.org/x/crypto/bcrypt" ) @@ -9,9 +10,25 @@ import ( // AuthProvider defines the interface for authentication backends. // This allows swapping mock auth for a real database implementation. type AuthProvider interface { + // CheckCredentials verifies username and password. + // Returns true if credentials are valid, false otherwise. CheckCredentials(user, pass string) bool + + // Register creates a new user with the given credentials. + // Implementations MUST: + // - validate username and password, + // - return an error if the user already exists, + // - never log the password. + Register(user, pass string) error + + // UserExists returns true if the user already exists. + UserExists(user string) (bool, error) } +// ----------------------------------------------------------------------------- +// MockAuth – in‑memory implementation for development/testing +// ----------------------------------------------------------------------------- + // MockAuth is a simple in-memory authentication provider. // Intended ONLY for development and testing. type MockAuth struct { @@ -49,3 +66,55 @@ func (m *MockAuth) CheckCredentials(user, pass string) bool { fmt.Println("[AUTH] success") return true } + +// Register adds a new user to the in-memory store. +// This is only for development/testing; no persistence is performed. +func (m *MockAuth) Register(user, pass string) error { + if err := validateUsername(user); err != nil { + return err + } + if err := validatePassword(pass); err != nil { + return err + } + + if _, exists := m.users[user]; exists { + return fmt.Errorf("username already exists") + } + + hash, err := bcrypt.GenerateFromPassword([]byte(pass), bcrypt.DefaultCost) + if err != nil { + return fmt.Errorf("failed to hash password: %w", err) + } + + m.users[user] = string(hash) + return nil +} + +// UserExists checks if the user is present in the in-memory store. +func (m *MockAuth) UserExists(user string) (bool, error) { + _, exists := m.users[user] + return exists, nil +} + +// ----------------------------------------------------------------------------- +// Shared validation helpers +// ----------------------------------------------------------------------------- + +var usernameRe = regexp.MustCompile(`^[a-z0-9_]{3,32}$`) + +// validateUsername enforces a simple, predictable username policy. +func validateUsername(user string) error { + if !usernameRe.MatchString(user) { + return fmt.Errorf("invalid username: must be 3–32 chars, [a-z0-9_]") + } + return nil +} + +// validatePassword enforces a minimal password policy. +// You can tighten this later (e.g. require digits/symbols). +func validatePassword(pass string) error { + if len(pass) < 8 { + return fmt.Errorf("password too short: minimum 8 characters") + } + return nil +} diff --git a/internal/auth/db_auth.go b/internal/auth/db_auth.go new file mode 100644 index 0000000..fe17d69 --- /dev/null +++ b/internal/auth/db_auth.go @@ -0,0 +1,93 @@ +package auth + +import ( + "database/sql" + "fmt" + + "golang.org/x/crypto/bcrypt" +) + +// DBAuth is a PostgreSQL-backed authentication provider. +// It assumes a table: +// +// CREATE TABLE users ( +// id SERIAL PRIMARY KEY, +// username TEXT UNIQUE NOT NULL, +// password_hash TEXT NOT NULL, +// created_at TIMESTAMP NOT NULL DEFAULT NOW() +// ); +type DBAuth struct { + db *sql.DB +} + +func NewDBAuth(db *sql.DB) *DBAuth { + return &DBAuth{db: db} +} + +// CheckCredentials verifies username and password against the database. +func (a *DBAuth) CheckCredentials(user, pass string) bool { + hash, err := a.lookupPasswordHash(user) + if err != nil { + return false + } + return bcrypt.CompareHashAndPassword([]byte(hash), []byte(pass)) == nil +} + +// Register creates a new user in the database with a bcrypt-hashed password. +func (a *DBAuth) Register(user, pass string) error { + if err := validateUsername(user); err != nil { + return err + } + if err := validatePassword(pass); err != nil { + return err + } + + exists, err := a.UserExists(user) + if err != nil { + return fmt.Errorf("failed to check user existence: %w", err) + } + if exists { + return fmt.Errorf("username already exists") + } + + hash, err := bcrypt.GenerateFromPassword([]byte(pass), bcrypt.DefaultCost) + if err != nil { + return fmt.Errorf("failed to hash password: %w", err) + } + + _, err = a.db.Exec( + "INSERT INTO users (username, password_hash) VALUES ($1, $2)", + user, string(hash), + ) + if err != nil { + return fmt.Errorf("failed to insert user: %w", err) + } + + return nil +} + +// UserExists checks if a user with the given username exists. +func (a *DBAuth) UserExists(user string) (bool, error) { + var exists bool + err := a.db.QueryRow( + "SELECT EXISTS(SELECT 1 FROM users WHERE username=$1)", + user, + ).Scan(&exists) + if err != nil { + return false, err + } + return exists, nil +} + +// lookupPasswordHash returns the stored bcrypt hash for a user. +func (a *DBAuth) lookupPasswordHash(user string) (string, error) { + var hash string + err := a.db.QueryRow( + "SELECT password_hash FROM users WHERE username=$1", + user, + ).Scan(&hash) + if err != nil { + return "", err + } + return hash, nil +} diff --git a/internal/protection/limiter.go b/internal/protection/limiter.go index cec1afc..7af6541 100644 --- a/internal/protection/limiter.go +++ b/internal/protection/limiter.go @@ -32,6 +32,10 @@ func (rl *RateLimiter) Allow() bool { now := time.Now() elapsed := now.Sub(rl.lastUpdate) refill := int(elapsed.Seconds() * float64(rl.maxTokens)) + if refill > 0 { + rl.tokens = min(rl.maxTokens, rl.tokens+refill) + rl.lastUpdate = now + } if refill > 0 { rl.tokens += refill @@ -55,7 +59,7 @@ type AuthTimer struct { // DefaultAuthTimeout defines how long the client has to complete AUTH // before the Hub closes the connection. -const DefaultAuthTimeout = 20 * time.Second +const DefaultAuthTimeout = 2 * time.Minute // 2 minutes is a reasonable default, but can be adjusted as needed. // StartAuthTimer starts an AUTH timeout timer that calls onTimeout when it fires. func StartAuthTimer(onTimeout func()) *AuthTimer { diff --git a/markdowns/developers_stuff.md b/markdowns/developers_stuff.md index b67590c..ab9fe55 100644 --- a/markdowns/developers_stuff.md +++ b/markdowns/developers_stuff.md @@ -486,3 +486,6 @@ KittyProtocol is now: - configurable and deployment-ready, - fully tested, benchmarked, and scalable, - aligned with security best practices. + + +# WAŻNE: ZMIANA W PROTOKOLE - DODANIE RAMKI REGISTER ABY BYŁA MOŻLIWOŚĆ REJESTRACJI DO SYSTEMU! \ No newline at end of file diff --git a/protocol/frames.go b/protocol/frames.go index d74071a..49eb563 100644 --- a/protocol/frames.go +++ b/protocol/frames.go @@ -6,13 +6,13 @@ import ( ) // CurrentProtocolVersion defines the version of the KittyProtocol -// that both Hub and clients are expected to speak. const CurrentProtocolVersion = "1.0" -// Frame type constants – single source of truth for all frame type strings. +// Frame type constants const ( FrameTypeHello = "HELLO" FrameTypeAuth = "AUTH" + FrameTypeRegister = "REGISTER" FrameTypeData = "DATA" FrameTypeMeowOK = "MEOW_OK" FrameTypeError = "ERROR" @@ -22,9 +22,9 @@ const ( FrameTypeBye = "BYE" ) -// Common error code constants used in protocol-level validation. +// Common error codes const ( - ErrCodeInvalidFrame = "ERR_02" // generic "invalid frame" / "bad format" error + ErrCodeInvalidFrame = "ERR_02" ) // BaseFrame contains fields common to every frame. @@ -33,7 +33,10 @@ type BaseFrame struct { MsgID int64 `json:"msg_id"` } +// ----------------------------------------------------------------------------- // Frame definitions +// ----------------------------------------------------------------------------- + type HelloFrame struct { BaseFrame Version string `json:"version"` @@ -86,6 +89,7 @@ func IsValidType(t string) bool { switch t { case FrameTypeHello, FrameTypeAuth, + FrameTypeRegister, FrameTypeData, FrameTypeMeowOK, FrameTypeError, @@ -136,23 +140,32 @@ func ParseHelloFrame(data []byte) (*HelloFrame, error) { return &f, nil } -func ParseAuthFrame(data []byte) (*AuthFrame, error) { +// Shared parser for AUTH and REGISTER +func parseAuthLikeFrame(data []byte, expectedType string) (*AuthFrame, error) { var f AuthFrame if err := json.Unmarshal(data, &f); err != nil { return nil, fmt.Errorf("%s: Invalid JSON format", ErrCodeInvalidFrame) } - if f.Type != FrameTypeAuth { - return nil, fmt.Errorf("%s: Invalid type for AUTH frame", ErrCodeInvalidFrame) + if f.Type != expectedType { + return nil, fmt.Errorf("%s: Invalid type for %s frame", ErrCodeInvalidFrame, expectedType) } if f.MsgID <= 0 { - return nil, fmt.Errorf("%s: Invalid msg_id in AUTH frame", ErrCodeInvalidFrame) + return nil, fmt.Errorf("%s: Invalid msg_id in %s frame", ErrCodeInvalidFrame, expectedType) } if f.User == "" || f.Pass == "" { - return nil, fmt.Errorf("%s: Missing user or pass in AUTH frame", ErrCodeInvalidFrame) + return nil, fmt.Errorf("%s: Missing user or pass in %s frame", ErrCodeInvalidFrame, expectedType) } return &f, nil } +func ParseAuthFrame(data []byte) (*AuthFrame, error) { + return parseAuthLikeFrame(data, FrameTypeAuth) +} + +func ParseRegisterFrame(data []byte) (*AuthFrame, error) { + return parseAuthLikeFrame(data, FrameTypeRegister) +} + func ParseDataFrame(data []byte) (*DataFrame, error) { var f DataFrame if err := json.Unmarshal(data, &f); err != nil { From b1671e764b7e8cd01bdc6d88bc82587292ce9061 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Brzezi=C5=84ski?= Date: Sat, 23 May 2026 22:00:49 +0000 Subject: [PATCH 09/44] ultimate protocol refactor --- protocol/frame_auth.go | 39 +++++++ protocol/frame_bye.go | 22 ++++ protocol/frame_data.go | 35 ++++++ protocol/frame_errors.go | 37 +++++++ protocol/frame_hello.go | 34 ++++++ protocol/frame_ok.go | 8 ++ protocol/frame_ping.go | 22 ++++ protocol/frame_status.go | 51 +++++++++ protocol/frame_types.go | 20 ++++ protocol/frames.go | 232 +-------------------------------------- protocol/frames_test.go | 25 ++++- 11 files changed, 295 insertions(+), 230 deletions(-) create mode 100644 protocol/frame_auth.go create mode 100644 protocol/frame_bye.go create mode 100644 protocol/frame_data.go create mode 100644 protocol/frame_errors.go create mode 100644 protocol/frame_hello.go create mode 100644 protocol/frame_ok.go create mode 100644 protocol/frame_ping.go create mode 100644 protocol/frame_status.go create mode 100644 protocol/frame_types.go diff --git a/protocol/frame_auth.go b/protocol/frame_auth.go new file mode 100644 index 0000000..696a77f --- /dev/null +++ b/protocol/frame_auth.go @@ -0,0 +1,39 @@ +package protocol + +import ( + "encoding/json" + "fmt" +) + +// AuthFrame is used for both AUTH and REGISTER operations. +type AuthFrame struct { + BaseFrame + User string `json:"user"` + Pass string `json:"pass"` +} + +// parseAuthLikeFrame is a shared validator for AUTH and REGISTER frames. +func parseAuthLikeFrame(data []byte, expectedType string) (*AuthFrame, error) { + var f AuthFrame + if err := json.Unmarshal(data, &f); err != nil { + return nil, fmt.Errorf("%s: invalid JSON format", ErrCodeInvalidFrame) + } + if f.Type != expectedType { + return nil, fmt.Errorf("%s: invalid type for %s frame", ErrCodeInvalidFrame, expectedType) + } + if f.MsgID <= 0 { + return nil, fmt.Errorf("%s: invalid msg_id in %s frame", ErrCodeInvalidFrame, expectedType) + } + if f.User == "" || f.Pass == "" { + return nil, fmt.Errorf("%s: missing user or pass in %s frame", ErrCodeInvalidFrame, expectedType) + } + return &f, nil +} + +func ParseAuthFrame(data []byte) (*AuthFrame, error) { + return parseAuthLikeFrame(data, FrameTypeAuth) +} + +func ParseRegisterFrame(data []byte) (*AuthFrame, error) { + return parseAuthLikeFrame(data, FrameTypeRegister) +} diff --git a/protocol/frame_bye.go b/protocol/frame_bye.go new file mode 100644 index 0000000..2e1c14d --- /dev/null +++ b/protocol/frame_bye.go @@ -0,0 +1,22 @@ +package protocol + +import ( + "encoding/json" + "fmt" +) + +type ByeFrame struct{ BaseFrame } + +func ParseByeFrame(data []byte) (*ByeFrame, error) { + var f ByeFrame + if err := json.Unmarshal(data, &f); err != nil { + return nil, fmt.Errorf("%s: invalid JSON format", ErrCodeInvalidFrame) + } + if f.Type != FrameTypeBye { + return nil, fmt.Errorf("%s: invalid type for BYE frame", ErrCodeInvalidFrame) + } + if f.MsgID <= 0 { + return nil, fmt.Errorf("%s: invalid msg_id in BYE frame", ErrCodeInvalidFrame) + } + return &f, nil +} diff --git a/protocol/frame_data.go b/protocol/frame_data.go new file mode 100644 index 0000000..6aa6321 --- /dev/null +++ b/protocol/frame_data.go @@ -0,0 +1,35 @@ +package protocol + +import ( + "encoding/json" + "fmt" +) + +// DataFrame carries encrypted application payloads between clients. +type DataFrame struct { + BaseFrame + Target string `json:"target"` + Sender string `json:"sender,omitempty"` + Payload string `json:"payload"` + MAC string `json:"mac"` +} + +func ParseDataFrame(data []byte) (*DataFrame, error) { + var f DataFrame + if err := json.Unmarshal(data, &f); err != nil { + return nil, fmt.Errorf("%s: invalid JSON format", ErrCodeInvalidFrame) + } + if f.Type != FrameTypeData { + return nil, fmt.Errorf("%s: invalid type for DATA frame", ErrCodeInvalidFrame) + } + if f.MsgID <= 0 { + return nil, fmt.Errorf("%s: invalid msg_id in DATA frame", ErrCodeInvalidFrame) + } + if f.Target == "" { + return nil, fmt.Errorf("%s: missing target in DATA frame", ErrCodeInvalidFrame) + } + if f.Payload == "" || f.MAC == "" { + return nil, fmt.Errorf("%s: missing payload or MAC in DATA frame", ErrCodeInvalidFrame) + } + return &f, nil +} diff --git a/protocol/frame_errors.go b/protocol/frame_errors.go new file mode 100644 index 0000000..04788a1 --- /dev/null +++ b/protocol/frame_errors.go @@ -0,0 +1,37 @@ +package protocol + +import ( + "encoding/json" + "fmt" +) + +// Standardized error codes used in ERROR frames. +// These codes are intentionally short and stable to avoid breaking clients. +const ( + ErrCodeInvalidFrame = "ERR_02" +) + +// ErrorFrame represents a transport-level error returned by the Hub. +// It is used for protocol violations, malformed frames, or invalid state transitions. +type ErrorFrame struct { + BaseFrame + Code string `json:"code"` + Desc string `json:"desc"` +} + +func ParseErrorFrame(data []byte) (*ErrorFrame, error) { + var f ErrorFrame + if err := json.Unmarshal(data, &f); err != nil { + return nil, fmt.Errorf("%s: Invalid JSON format", ErrCodeInvalidFrame) + } + if f.Type != FrameTypeError { + return nil, fmt.Errorf("%s: Invalid type for ERROR frame", ErrCodeInvalidFrame) + } + if f.MsgID <= 0 { + return nil, fmt.Errorf("%s: Invalid msg_id in ERROR frame", ErrCodeInvalidFrame) + } + if f.Code == "" { + return nil, fmt.Errorf("%s: Missing error code in ERROR frame", ErrCodeInvalidFrame) + } + return &f, nil +} diff --git a/protocol/frame_hello.go b/protocol/frame_hello.go new file mode 100644 index 0000000..17195eb --- /dev/null +++ b/protocol/frame_hello.go @@ -0,0 +1,34 @@ +package protocol + +import ( + "encoding/json" + "fmt" +) + +// HelloFrame is the first frame sent by the client. +// It announces the protocol version and initiates the handshake. +type HelloFrame struct { + BaseFrame + Version string `json:"version"` +} + +// ParseHelloFrame validates and parses a HELLO frame. +func ParseHelloFrame(data []byte) (*HelloFrame, error) { + var f HelloFrame + if err := json.Unmarshal(data, &f); err != nil { + return nil, fmt.Errorf("%s: invalid JSON format", ErrCodeInvalidFrame) + } + if f.Type != FrameTypeHello { + return nil, fmt.Errorf("%s: invalid type for HELLO frame", ErrCodeInvalidFrame) + } + if f.MsgID <= 0 { + return nil, fmt.Errorf("%s: invalid msg_id in HELLO frame", ErrCodeInvalidFrame) + } + if f.Version == "" { + return nil, fmt.Errorf("%s: missing version in HELLO frame", ErrCodeInvalidFrame) + } + if f.Version != CurrentProtocolVersion { + return nil, fmt.Errorf("%s: unsupported protocol version %q", ErrCodeInvalidFrame, f.Version) + } + return &f, nil +} diff --git a/protocol/frame_ok.go b/protocol/frame_ok.go new file mode 100644 index 0000000..05a0a6c --- /dev/null +++ b/protocol/frame_ok.go @@ -0,0 +1,8 @@ +package protocol + +// MeowOkFrame is sent by the Hub to acknowledge successful operations +// such as HELLO, AUTH, REGISTER, or other protocol-level actions. +type MeowOkFrame struct { + BaseFrame + Status string `json:"status,omitempty"` +} diff --git a/protocol/frame_ping.go b/protocol/frame_ping.go new file mode 100644 index 0000000..2e0e2ee --- /dev/null +++ b/protocol/frame_ping.go @@ -0,0 +1,22 @@ +package protocol + +import ( + "encoding/json" + "fmt" +) + +type PingFrame struct{ BaseFrame } + +func ParsePingFrame(data []byte) (*PingFrame, error) { + var f PingFrame + if err := json.Unmarshal(data, &f); err != nil { + return nil, fmt.Errorf("%s: invalid JSON format", ErrCodeInvalidFrame) + } + if f.Type != FrameTypePing { + return nil, fmt.Errorf("%s: invalid type for PING frame", ErrCodeInvalidFrame) + } + if f.MsgID <= 0 { + return nil, fmt.Errorf("%s: invalid msg_id in PING frame", ErrCodeInvalidFrame) + } + return &f, nil +} diff --git a/protocol/frame_status.go b/protocol/frame_status.go new file mode 100644 index 0000000..c938985 --- /dev/null +++ b/protocol/frame_status.go @@ -0,0 +1,51 @@ +package protocol + +import ( + "encoding/json" + "fmt" +) + +type GetStatusFrame struct { + BaseFrame + Target string `json:"target"` +} + +type StatusResFrame struct { + BaseFrame + Target string `json:"target"` + Status string `json:"status"` +} + +func ParseGetStatusFrame(data []byte) (*GetStatusFrame, error) { + var f GetStatusFrame + if err := json.Unmarshal(data, &f); err != nil { + return nil, fmt.Errorf("%s: invalid JSON format", ErrCodeInvalidFrame) + } + if f.Type != FrameTypeGetStatus { + return nil, fmt.Errorf("%s: invalid type for GET_STATUS frame", ErrCodeInvalidFrame) + } + if f.MsgID <= 0 { + return nil, fmt.Errorf("%s: invalid msg_id in GET_STATUS frame", ErrCodeInvalidFrame) + } + if f.Target == "" { + return nil, fmt.Errorf("%s: missing target in GET_STATUS frame", ErrCodeInvalidFrame) + } + return &f, nil +} + +func ParseStatusResFrame(data []byte) (*StatusResFrame, error) { + var f StatusResFrame + if err := json.Unmarshal(data, &f); err != nil { + return nil, fmt.Errorf("%s: invalid JSON format", ErrCodeInvalidFrame) + } + if f.Type != FrameTypeStatusRes { + return nil, fmt.Errorf("%s: invalid type for STATUS_RES frame", ErrCodeInvalidFrame) + } + if f.MsgID <= 0 { + return nil, fmt.Errorf("%s: invalid msg_id in STATUS_RES frame", ErrCodeInvalidFrame) + } + if f.Target == "" || f.Status == "" { + return nil, fmt.Errorf("%s: missing target or status in STATUS_RES frame", ErrCodeInvalidFrame) + } + return &f, nil +} diff --git a/protocol/frame_types.go b/protocol/frame_types.go new file mode 100644 index 0000000..717c8d0 --- /dev/null +++ b/protocol/frame_types.go @@ -0,0 +1,20 @@ +package protocol + +// Frame type identifiers used across the KittyProtocol transport layer. +// These values appear in the "type" field of every JSON frame. +const ( + FrameTypeHello = "HELLO" + FrameTypeAuth = "AUTH" + FrameTypeRegister = "REGISTER" + FrameTypeData = "DATA" + FrameTypeMeowOK = "MEOW_OK" + FrameTypeError = "ERROR" + FrameTypeGetStatus = "GET_STATUS" + FrameTypeStatusRes = "STATUS_RES" + FrameTypePing = "PING" + FrameTypeBye = "BYE" +) + +// CurrentProtocolVersion defines the version of the KittyProtocol. +// Clients must send this value in the HELLO frame. +const CurrentProtocolVersion = "1.0" diff --git a/protocol/frames.go b/protocol/frames.go index 49eb563..fe34a53 100644 --- a/protocol/frames.go +++ b/protocol/frames.go @@ -5,86 +5,14 @@ import ( "fmt" ) -// CurrentProtocolVersion defines the version of the KittyProtocol -const CurrentProtocolVersion = "1.0" - -// Frame type constants -const ( - FrameTypeHello = "HELLO" - FrameTypeAuth = "AUTH" - FrameTypeRegister = "REGISTER" - FrameTypeData = "DATA" - FrameTypeMeowOK = "MEOW_OK" - FrameTypeError = "ERROR" - FrameTypeGetStatus = "GET_STATUS" - FrameTypeStatusRes = "STATUS_RES" - FrameTypePing = "PING" - FrameTypeBye = "BYE" -) - -// Common error codes -const ( - ErrCodeInvalidFrame = "ERR_02" -) - -// BaseFrame contains fields common to every frame. +// BaseFrame contains fields common to every frame exchanged in the protocol. +// All frames must embed BaseFrame as their first field. type BaseFrame struct { Type string `json:"type"` MsgID int64 `json:"msg_id"` } -// ----------------------------------------------------------------------------- -// Frame definitions -// ----------------------------------------------------------------------------- - -type HelloFrame struct { - BaseFrame - Version string `json:"version"` -} - -type AuthFrame struct { - BaseFrame - User string `json:"user"` - Pass string `json:"pass"` -} - -type DataFrame struct { - BaseFrame - Target string `json:"target,omitempty"` - Sender string `json:"sender,omitempty"` - Payload string `json:"payload"` - MAC string `json:"mac"` -} - -type MeowOkFrame struct { - BaseFrame - Status string `json:"status,omitempty"` -} - -type ErrorFrame struct { - BaseFrame - Code string `json:"code"` - Desc string `json:"desc"` -} - -type GetStatusFrame struct { - BaseFrame - Target string `json:"target"` -} - -type StatusResFrame struct { - BaseFrame - Target string `json:"target"` - Status string `json:"status"` -} - -type PingFrame struct{ BaseFrame } -type ByeFrame struct{ BaseFrame } - -// ----------------------------------------------------------------------------- -// Helpers -// ----------------------------------------------------------------------------- - +// IsValidType returns true if the provided frame type is recognized by the protocol. func IsValidType(t string) bool { switch t { case FrameTypeHello, @@ -102,6 +30,8 @@ func IsValidType(t string) bool { return false } +// GetFrameType extracts the "type" and "msg_id" fields from raw JSON. +// It performs minimal validation and is used by dispatchers before full parsing. func GetFrameType(data []byte) (string, int64, error) { var base BaseFrame if err := json.Unmarshal(data, &base); err != nil { @@ -115,155 +45,3 @@ func GetFrameType(data []byte) (string, int64, error) { } return base.Type, base.MsgID, nil } - -// ----------------------------------------------------------------------------- -// Parsers -// ----------------------------------------------------------------------------- - -func ParseHelloFrame(data []byte) (*HelloFrame, error) { - var f HelloFrame - if err := json.Unmarshal(data, &f); err != nil { - return nil, fmt.Errorf("%s: Invalid JSON format", ErrCodeInvalidFrame) - } - if f.Type != FrameTypeHello { - return nil, fmt.Errorf("%s: Invalid type for HELLO frame", ErrCodeInvalidFrame) - } - if f.MsgID <= 0 { - return nil, fmt.Errorf("%s: Invalid msg_id in HELLO frame", ErrCodeInvalidFrame) - } - if f.Version == "" { - return nil, fmt.Errorf("%s: Missing version in HELLO frame", ErrCodeInvalidFrame) - } - if f.Version != CurrentProtocolVersion { - return nil, fmt.Errorf("%s: Unsupported protocol version %q", ErrCodeInvalidFrame, f.Version) - } - return &f, nil -} - -// Shared parser for AUTH and REGISTER -func parseAuthLikeFrame(data []byte, expectedType string) (*AuthFrame, error) { - var f AuthFrame - if err := json.Unmarshal(data, &f); err != nil { - return nil, fmt.Errorf("%s: Invalid JSON format", ErrCodeInvalidFrame) - } - if f.Type != expectedType { - return nil, fmt.Errorf("%s: Invalid type for %s frame", ErrCodeInvalidFrame, expectedType) - } - if f.MsgID <= 0 { - return nil, fmt.Errorf("%s: Invalid msg_id in %s frame", ErrCodeInvalidFrame, expectedType) - } - if f.User == "" || f.Pass == "" { - return nil, fmt.Errorf("%s: Missing user or pass in %s frame", ErrCodeInvalidFrame, expectedType) - } - return &f, nil -} - -func ParseAuthFrame(data []byte) (*AuthFrame, error) { - return parseAuthLikeFrame(data, FrameTypeAuth) -} - -func ParseRegisterFrame(data []byte) (*AuthFrame, error) { - return parseAuthLikeFrame(data, FrameTypeRegister) -} - -func ParseDataFrame(data []byte) (*DataFrame, error) { - var f DataFrame - if err := json.Unmarshal(data, &f); err != nil { - return nil, fmt.Errorf("%s: Invalid JSON format", ErrCodeInvalidFrame) - } - if f.Type != FrameTypeData { - return nil, fmt.Errorf("%s: Invalid type for DATA frame", ErrCodeInvalidFrame) - } - if f.MsgID <= 0 { - return nil, fmt.Errorf("%s: Invalid msg_id in DATA frame", ErrCodeInvalidFrame) - } - if f.Sender != "" { - return nil, fmt.Errorf("%s: Sender must be empty in client DATA frame", ErrCodeInvalidFrame) - } - if f.Target == "" { - return nil, fmt.Errorf("%s: Missing target in DATA frame", ErrCodeInvalidFrame) - } - if f.Payload == "" || f.MAC == "" { - return nil, fmt.Errorf("%s: Missing payload or MAC in DATA frame", ErrCodeInvalidFrame) - } - return &f, nil -} - -func ParseErrorFrame(data []byte) (*ErrorFrame, error) { - var f ErrorFrame - if err := json.Unmarshal(data, &f); err != nil { - return nil, fmt.Errorf("%s: Invalid JSON format", ErrCodeInvalidFrame) - } - if f.Type != FrameTypeError { - return nil, fmt.Errorf("%s: Invalid type for ERROR frame", ErrCodeInvalidFrame) - } - if f.MsgID <= 0 { - return nil, fmt.Errorf("%s: Invalid msg_id in ERROR frame", ErrCodeInvalidFrame) - } - if f.Code == "" { - return nil, fmt.Errorf("%s: Missing error code in ERROR frame", ErrCodeInvalidFrame) - } - return &f, nil -} - -func ParseGetStatusFrame(data []byte) (*GetStatusFrame, error) { - var f GetStatusFrame - if err := json.Unmarshal(data, &f); err != nil { - return nil, fmt.Errorf("%s: Invalid JSON format", ErrCodeInvalidFrame) - } - if f.Type != FrameTypeGetStatus { - return nil, fmt.Errorf("%s: Invalid type for GET_STATUS frame", ErrCodeInvalidFrame) - } - if f.MsgID <= 0 { - return nil, fmt.Errorf("%s: Invalid msg_id in GET_STATUS frame", ErrCodeInvalidFrame) - } - if f.Target == "" { - return nil, fmt.Errorf("%s: Missing target in GET_STATUS frame", ErrCodeInvalidFrame) - } - return &f, nil -} - -func ParseStatusResFrame(data []byte) (*StatusResFrame, error) { - var f StatusResFrame - if err := json.Unmarshal(data, &f); err != nil { - return nil, fmt.Errorf("%s: Invalid JSON format", ErrCodeInvalidFrame) - } - if f.Type != FrameTypeStatusRes { - return nil, fmt.Errorf("%s: Invalid type for STATUS_RES frame", ErrCodeInvalidFrame) - } - if f.MsgID <= 0 { - return nil, fmt.Errorf("%s: Invalid msg_id in STATUS_RES frame", ErrCodeInvalidFrame) - } - if f.Target == "" || f.Status == "" { - return nil, fmt.Errorf("%s: Missing target or status in STATUS_RES frame", ErrCodeInvalidFrame) - } - return &f, nil -} - -func ParsePingFrame(data []byte) (*PingFrame, error) { - var f PingFrame - if err := json.Unmarshal(data, &f); err != nil { - return nil, fmt.Errorf("%s: Invalid JSON format", ErrCodeInvalidFrame) - } - if f.Type != FrameTypePing { - return nil, fmt.Errorf("%s: Invalid type for PING frame", ErrCodeInvalidFrame) - } - if f.MsgID <= 0 { - return nil, fmt.Errorf("%s: Invalid msg_id in PING frame", ErrCodeInvalidFrame) - } - return &f, nil -} - -func ParseByeFrame(data []byte) (*ByeFrame, error) { - var f ByeFrame - if err := json.Unmarshal(data, &f); err != nil { - return nil, fmt.Errorf("%s: Invalid JSON format", ErrCodeInvalidFrame) - } - if f.Type != FrameTypeBye { - return nil, fmt.Errorf("%s: Invalid type for BYE frame", ErrCodeInvalidFrame) - } - if f.MsgID <= 0 { - return nil, fmt.Errorf("%s: Invalid msg_id in BYE frame", ErrCodeInvalidFrame) - } - return &f, nil -} diff --git a/protocol/frames_test.go b/protocol/frames_test.go index eee9a51..034ea85 100644 --- a/protocol/frames_test.go +++ b/protocol/frames_test.go @@ -7,6 +7,8 @@ import ( // --- GetFrameType tests --- +// TestGetFrameTypeValid verifies that GetFrameType correctly extracts +// the frame type and message ID from a valid JSON frame. func TestGetFrameTypeValid(t *testing.T) { jsonInput := []byte(`{"type":"DATA","msg_id":123}`) typeName, msgID, err := GetFrameType(jsonInput) @@ -18,6 +20,8 @@ func TestGetFrameTypeValid(t *testing.T) { } } +// TestGetFrameTypeMissingFields ensures that missing required fields +// (type or msg_id) result in an ERR_02 validation error. func TestGetFrameTypeMissingFields(t *testing.T) { jsonNoID := []byte(`{"type":"DATA"}`) _, _, err := GetFrameType(jsonNoID) @@ -32,6 +36,7 @@ func TestGetFrameTypeMissingFields(t *testing.T) { } } +// TestGetFrameTypeInvalidJSON checks that malformed JSON is rejected. func TestGetFrameTypeInvalidJSON(t *testing.T) { jsonInvalid := []byte(`{invalid json}`) _, _, err := GetFrameType(jsonInvalid) @@ -40,6 +45,8 @@ func TestGetFrameTypeInvalidJSON(t *testing.T) { } } +// TestGetFrameTypeUnknownType verifies that unknown frame types +// are rejected with ERR_02. func TestGetFrameTypeUnknownType(t *testing.T) { jsonUnknown := []byte(`{"type":"HACK","msg_id":123}`) _, _, err := GetFrameType(jsonUnknown) @@ -50,6 +57,8 @@ func TestGetFrameTypeUnknownType(t *testing.T) { // --- AUTH tests --- +// TestParseAuthFrameValidation ensures that missing required fields +// in AUTH frames (e.g., pass) produce an ERR_02 error. func TestParseAuthFrameValidation(t *testing.T) { jsonMissingPass := []byte(`{"type":"AUTH","msg_id":123,"user":"alice"}`) _, err := ParseAuthFrame(jsonMissingPass) @@ -60,6 +69,8 @@ func TestParseAuthFrameValidation(t *testing.T) { // --- DATA tests --- +// TestDataFrameValidation verifies correct parsing of DATA frames, +// including validation of required fields and rejection of invalid ones. func TestDataFrameValidation(t *testing.T) { valid := []byte(`{"type":"DATA","msg_id":123,"target":"bob","payload":"SGVsbG8=","mac":"hash"}`) f, err := ParseDataFrame(valid) @@ -70,11 +81,11 @@ func TestDataFrameValidation(t *testing.T) { t.Errorf("wrong target") } - // Sender must be empty + // Sender is allowed (Hub sets it when forwarding) withSender := []byte(`{"type":"DATA","msg_id":123,"sender":"alice","target":"bob","payload":"x","mac":"y"}`) _, err = ParseDataFrame(withSender) - if err == nil { - t.Fatalf("expected error for non-empty sender") + if err != nil { + t.Fatalf("sender should be allowed in forwarded DATA frames: %v", err) } // Missing MAC @@ -87,6 +98,8 @@ func TestDataFrameValidation(t *testing.T) { // --- STATUS_RES tests --- +// TestParseStatusResFrameValidation checks that STATUS_RES frames +// must include both target and status fields. func TestParseStatusResFrameValidation(t *testing.T) { jsonMissingStatus := []byte(`{"type":"STATUS_RES","msg_id":123,"target":"alice"}`) _, err := ParseStatusResFrame(jsonMissingStatus) @@ -97,6 +110,8 @@ func TestParseStatusResFrameValidation(t *testing.T) { // --- ERROR tests --- +// TestParseErrorFrameValidation ensures that ERROR frames must include +// an error code. func TestParseErrorFrameValidation(t *testing.T) { jsonMissingCode := []byte(`{"type":"ERROR","msg_id":123,"desc":"Something went wrong"}`) _, err := ParseErrorFrame(jsonMissingCode) @@ -107,6 +122,8 @@ func TestParseErrorFrameValidation(t *testing.T) { // --- GET_STATUS tests --- +// TestParseGetStatusFrameValidation verifies that GET_STATUS frames +// must include a target field. func TestParseGetStatusFrameValidation(t *testing.T) { jsonMissingTarget := []byte(`{"type":"GET_STATUS","msg_id":123}`) _, err := ParseGetStatusFrame(jsonMissingTarget) @@ -117,6 +134,8 @@ func TestParseGetStatusFrameValidation(t *testing.T) { // --- HELLO tests --- +// TestParseHelloFrameValidation checks HELLO frame validation, +// including JSON format, version presence, and version compatibility. func TestParseHelloFrameValidation(t *testing.T) { invalidJSON := []byte(`{"type":"HELLO"`) _, err := ParseHelloFrame(invalidJSON) From 2b4ee67c77e6f9416f584240618c6690505b5bf3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Brzezi=C5=84ski?= Date: Sat, 23 May 2026 23:18:00 +0000 Subject: [PATCH 10/44] db and env structure refactor --- db/.env.example | 14 ++++++++++++++ db/docker-compose.yaml | 33 +++++++++++++++++++++++++++++++++ db/init.sql | 6 ++++++ 3 files changed, 53 insertions(+) create mode 100644 db/.env.example create mode 100644 db/docker-compose.yaml create mode 100644 db/init.sql diff --git a/db/.env.example b/db/.env.example new file mode 100644 index 0000000..994b43c --- /dev/null +++ b/db/.env.example @@ -0,0 +1,14 @@ +# ----------------------------- +# PostgreSQL configuration +# ----------------------------- +POSTGRES_USER=kitty +POSTGRES_PASSWORD=kittypass +POSTGRES_DB=kittyhub +POSTGRES_PORT=5432 + +# ----------------------------- +# pgAdmin configuration +# ----------------------------- +PGADMIN_EMAIL=admin@example.com +PGADMIN_PASSWORD=adminpass +PGADMIN_PORT=5050 \ No newline at end of file diff --git a/db/docker-compose.yaml b/db/docker-compose.yaml new file mode 100644 index 0000000..842aca4 --- /dev/null +++ b/db/docker-compose.yaml @@ -0,0 +1,33 @@ +services: + db: + image: postgres:latest + restart: always + env_file: + - ../.env + environment: + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_DB: ${POSTGRES_DB} + ports: + - "${POSTGRES_PORT}:5432" + volumes: + - dbdata:/var/lib/postgresql + + pgadmin: + image: dpage/pgadmin4:latest + restart: always + env_file: + - ../.env + environment: + PGADMIN_DEFAULT_EMAIL: ${PGADMIN_EMAIL} + PGADMIN_DEFAULT_PASSWORD: ${PGADMIN_PASSWORD} + ports: + - "${PGADMIN_PORT}:80" + depends_on: + - db + volumes: + - pgadmindata:/var/lib/pgadmin + +volumes: + dbdata: + pgadmindata: diff --git a/db/init.sql b/db/init.sql new file mode 100644 index 0000000..e44b3a1 --- /dev/null +++ b/db/init.sql @@ -0,0 +1,6 @@ +CREATE TABLE IF NOT EXISTS users ( + id SERIAL PRIMARY KEY, + username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT NOW() +); From 265cfcac2abb180dadcba5ebd1dddc4e3eeb65c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Brzezi=C5=84ski?= Date: Sat, 23 May 2026 23:19:19 +0000 Subject: [PATCH 11/44] refactor of catalog structure, images moved, readmes edited --- .env.example | 15 ------- README.md | 2 +- README.pl.md | 2 +- cmd/auth_test/main.go | 40 ------------------ docker-compose.yaml | 35 --------------- init.sql | 6 --- .../img/kitty_logo.png | Bin tools/hashgen/main.go | 20 --------- 8 files changed, 2 insertions(+), 118 deletions(-) delete mode 100644 cmd/auth_test/main.go delete mode 100644 docker-compose.yaml delete mode 100644 init.sql rename kitty_logo.png => resources/img/kitty_logo.png (100%) delete mode 100644 tools/hashgen/main.go diff --git a/.env.example b/.env.example index 644a91a..0a30aff 100644 --- a/.env.example +++ b/.env.example @@ -1,18 +1,3 @@ -# ----------------------------- -# PostgreSQL configuration -# ----------------------------- -POSTGRES_USER=kitty -POSTGRES_PASSWORD=kittypass -POSTGRES_DB=kittyhub -POSTGRES_PORT=5432 - -# ----------------------------- -# pgAdmin configuration -# ----------------------------- -PGADMIN_EMAIL=admin@example.com -PGADMIN_PASSWORD=adminpass -PGADMIN_PORT=5050 - # ----------------------------- # KittyProtocol configuration # ----------------------------- diff --git a/README.md b/README.md index 6e18ade..cdaed0e 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ # Kitty Protocol -![image](kitty_logo.png) +![image](resources/img/kitty_logo.png) ## Project Overview diff --git a/README.pl.md b/README.pl.md index 95700cf..f4791a8 100644 --- a/README.pl.md +++ b/README.pl.md @@ -2,7 +2,7 @@ # Kitty Protocol -![image](kitty_logo.png) +![image](resources/img/kitty_logo.png) ## Opis projektu diff --git a/cmd/auth_test/main.go b/cmd/auth_test/main.go deleted file mode 100644 index 89ecd78..0000000 --- a/cmd/auth_test/main.go +++ /dev/null @@ -1,40 +0,0 @@ -package main - -import ( - "database/sql" - "fmt" - - "github.com/gabbla05/KittyProtocol/internal/auth" - _ "github.com/lib/pq" -) - -func main() { - db, err := sql.Open("postgres", "postgres://kitty:kittypass@localhost:5432/kittyhub?sslmode=disable") - if err != nil { - panic(err) - } - defer db.Close() - - a := auth.NewDBAuth(db) - - // Register - if err := a.Register("testuser", "supersecret"); err != nil { - fmt.Println("Register error:", err) - } else { - fmt.Println("Register OK") - } - - // CheckCredentials (OK) - if a.CheckCredentials("testuser", "supersecret") { - fmt.Println("Login OK") - } else { - fmt.Println("Login FAILED") - } - - // CheckCredentials (bad password) - if a.CheckCredentials("testuser", "wrongpass") { - fmt.Println("Login should NOT succeed with wrong password") - } else { - fmt.Println("Login failed as expected (wrong password)") - } -} diff --git a/docker-compose.yaml b/docker-compose.yaml deleted file mode 100644 index 08930e6..0000000 --- a/docker-compose.yaml +++ /dev/null @@ -1,35 +0,0 @@ -version: "3.9" - -services: - db: - image: postgres:latest - restart: always - env_file: - - .env - environment: - POSTGRES_USER: ${POSTGRES_USER} - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} - POSTGRES_DB: ${POSTGRES_DB} - ports: - - "${POSTGRES_PORT}:5432" - volumes: - - dbdata:/var/lib/postgresql - - pgadmin: - image: dpage/pgadmin4:latest - restart: always - env_file: - - .env - environment: - PGADMIN_DEFAULT_EMAIL: ${PGADMIN_EMAIL} - PGADMIN_DEFAULT_PASSWORD: ${PGADMIN_PASSWORD} - ports: - - "${PGADMIN_PORT}:80" - depends_on: - - db - volumes: - - pgadmindata:/var/lib/pgadmin - -volumes: - dbdata: - pgadmindata: diff --git a/init.sql b/init.sql deleted file mode 100644 index e44b3a1..0000000 --- a/init.sql +++ /dev/null @@ -1,6 +0,0 @@ -CREATE TABLE IF NOT EXISTS users ( - id SERIAL PRIMARY KEY, - username TEXT UNIQUE NOT NULL, - password_hash TEXT NOT NULL, - created_at TIMESTAMP NOT NULL DEFAULT NOW() -); diff --git a/kitty_logo.png b/resources/img/kitty_logo.png similarity index 100% rename from kitty_logo.png rename to resources/img/kitty_logo.png diff --git a/tools/hashgen/main.go b/tools/hashgen/main.go deleted file mode 100644 index ac1136e..0000000 --- a/tools/hashgen/main.go +++ /dev/null @@ -1,20 +0,0 @@ -package main - -import ( - "fmt" - - "golang.org/x/crypto/bcrypt" -) - -func main() { - // passes for users: alice, bob, charlie - passwords := []string{"secret", "password", "private"} - - for _, p := range passwords { - hash, err := bcrypt.GenerateFromPassword([]byte(p), bcrypt.DefaultCost) - if err != nil { - panic(err) - } - fmt.Printf("password=%q hash=%q\n", p, string(hash)) - } -} From 9728882a17754fd2452f5df11607e74932a8ecb2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Brzezi=C5=84ski?= Date: Sat, 23 May 2026 23:29:58 +0000 Subject: [PATCH 12/44] protection package refactor --- internal/protection/constants.go | 43 ++++++++++++++++ internal/protection/limiter.go | 37 +++---------- internal/protection/limiter_test.go | 12 +++-- internal/protection/replay.go | 29 ++++------- internal/protection/replay_test.go | 57 ++++++++++----------- internal/protection/session.go | 9 ---- internal/protection/session_manager.go | 12 ++--- internal/protection/session_manager_test.go | 13 +++-- 8 files changed, 103 insertions(+), 109 deletions(-) create mode 100644 internal/protection/constants.go diff --git a/internal/protection/constants.go b/internal/protection/constants.go new file mode 100644 index 0000000..57edd86 --- /dev/null +++ b/internal/protection/constants.go @@ -0,0 +1,43 @@ +package protection + +import "time" + +// ----------------------------------------------------------------------------- +// Global protection-related constants used across the Hub. +// Centralizing these values eliminates magic numbers and makes the system +// easier to configure, test, and maintain. +// ----------------------------------------------------------------------------- + +// DefaultSessionRateLimit defines how many DATA frames per second a single +// authenticated session is allowed to send. This protects the Hub from +// message flooding by malicious or buggy clients. +const DefaultSessionRateLimit = 10 + +// DefaultIdleCloseErrorCode is the QUIC application error code used when +// the Hub closes an idle connection. This is intentionally distinct from +// protocol-level error codes. +const DefaultIdleCloseErrorCode = 0x09 + +// DefaultSessionIdleTimeout defines how long a session may remain inactive +// before being automatically closed by the SessionManager. +const DefaultSessionIdleTimeout = 60 * time.Second + +// DefaultSessionCleanupInterval defines how often the SessionManager scans +// for idle sessions. +const DefaultSessionCleanupInterval = 10 * time.Second + +// DefaultAuthTimeout defines how long a client has to complete the +// HELLO → AUTH/REGISTER handshake before the Hub closes the connection. +const DefaultAuthTimeout = 2 * time.Minute + +// Replay protection parameters. +const ( + // Maximum number of tracked message IDs before forced cleanup. + MaxReplayEntries = 10_000 + + // TTL for replay entries. + ReplayTTL = 2 * time.Minute + + // Sweep interval for replay cleanup. + ReplaySweepInterval = 5 * time.Second +) diff --git a/internal/protection/limiter.go b/internal/protection/limiter.go index 7af6541..65e3396 100644 --- a/internal/protection/limiter.go +++ b/internal/protection/limiter.go @@ -6,12 +6,13 @@ import ( ) // RateLimiter implements a simple token bucket limiter. -// It allows up to maxTokens operations per second. +// It allows up to maxTokens operations per second for a given session. +// This protects the Hub from message flooding. type RateLimiter struct { + mu sync.Mutex tokens int maxTokens int lastUpdate time.Time - mu sync.Mutex } // NewRateLimiter creates a new RateLimiter with the given per-second limit. @@ -24,19 +25,16 @@ func NewRateLimiter(limit int) *RateLimiter { } // Allow returns true if the operation is allowed at this moment. -// It refills tokens proportionally to elapsed time since the last check. +// Tokens are refilled proportionally to elapsed time since the last check. func (rl *RateLimiter) Allow() bool { rl.mu.Lock() defer rl.mu.Unlock() now := time.Now() elapsed := now.Sub(rl.lastUpdate) - refill := int(elapsed.Seconds() * float64(rl.maxTokens)) - if refill > 0 { - rl.tokens = min(rl.maxTokens, rl.tokens+refill) - rl.lastUpdate = now - } + // Refill tokens based on elapsed time. + refill := int(elapsed.Seconds() * float64(rl.maxTokens)) if refill > 0 { rl.tokens += refill if rl.tokens > rl.maxTokens { @@ -51,26 +49,3 @@ func (rl *RateLimiter) Allow() bool { } return false } - -// AuthTimer wraps a time.Timer used for the AUTH timeout. -type AuthTimer struct { - timer *time.Timer -} - -// DefaultAuthTimeout defines how long the client has to complete AUTH -// before the Hub closes the connection. -const DefaultAuthTimeout = 2 * time.Minute // 2 minutes is a reasonable default, but can be adjusted as needed. - -// StartAuthTimer starts an AUTH timeout timer that calls onTimeout when it fires. -func StartAuthTimer(onTimeout func()) *AuthTimer { - return &AuthTimer{ - timer: time.AfterFunc(DefaultAuthTimeout, onTimeout), - } -} - -// Stop cancels the AUTH timer if it is still running. -func (at *AuthTimer) Stop() { - if at.timer != nil { - at.timer.Stop() - } -} diff --git a/internal/protection/limiter_test.go b/internal/protection/limiter_test.go index 0c8d39d..9fee0b9 100644 --- a/internal/protection/limiter_test.go +++ b/internal/protection/limiter_test.go @@ -5,7 +5,8 @@ import ( "time" ) -// Test that limiter allows up to N operations immediately. +// TestRateLimiterInitialTokens verifies that a new limiter starts with +// maxTokens available and allows exactly that many operations. func TestRateLimiterInitialTokens(t *testing.T) { rl := NewRateLimiter(10) @@ -16,11 +17,12 @@ func TestRateLimiterInitialTokens(t *testing.T) { } if rl.Allow() { - t.Fatalf("expected limiter to block after tokens exhausted") + t.Fatalf("expected limiter to block after tokens are exhausted") } } -// Test that tokens refill over time. +// TestRateLimiterRefill ensures that tokens are refilled proportionally +// to elapsed time since the last Allow() call. func TestRateLimiterRefill(t *testing.T) { rl := NewRateLimiter(10) @@ -33,10 +35,10 @@ func TestRateLimiterRefill(t *testing.T) { t.Fatalf("expected limiter to block after exhaustion") } - // Wait long enough for refill + // Wait long enough for at least one token to refill time.Sleep(150 * time.Millisecond) if !rl.Allow() { - t.Fatalf("expected limiter to refill tokens after time") + t.Fatalf("expected limiter to allow after refill") } } diff --git a/internal/protection/replay.go b/internal/protection/replay.go index e7ba146..611ef85 100644 --- a/internal/protection/replay.go +++ b/internal/protection/replay.go @@ -5,18 +5,8 @@ import ( "time" ) -const ( - // Maximum number of tracked message IDs before forced cleanup. - maxReplayEntries = 10_000 - - // TTL for replay entries. - replayTTL = 2 * time.Minute - - // Sweep interval (always performed, not only when map is large). - replaySweepInterval = 5 * time.Second -) - -// ReplayDetector tracks recently seen message IDs to detect replays. +// ReplayDetector tracks recently seen message IDs to detect replay attacks. +// It is used per-session to ensure that clients cannot resend old DATA frames. type ReplayDetector struct { mu sync.Mutex seen map[int64]time.Time @@ -40,15 +30,15 @@ func (r *ReplayDetector) MarkAndCheck(msgID int64) bool { // Replay check if ts, ok := r.seen[msgID]; ok { - if now.Sub(ts) <= replayTTL { + if now.Sub(ts) <= ReplayTTL { return true } } - // Always sweep periodically - if now.Sub(r.lastSweep) >= replaySweepInterval { + // Periodic sweep + if now.Sub(r.lastSweep) >= ReplaySweepInterval { for id, ts := range r.seen { - if now.Sub(ts) > replayTTL { + if now.Sub(ts) > ReplayTTL { delete(r.seen, id) } } @@ -56,14 +46,13 @@ func (r *ReplayDetector) MarkAndCheck(msgID int64) bool { } // Enforce memory limit - if len(r.seen) >= maxReplayEntries { - // Remove oldest entries - cutoff := now.Add(-replayTTL) + if len(r.seen) >= MaxReplayEntries { + cutoff := now.Add(-ReplayTTL) for id, ts := range r.seen { if ts.Before(cutoff) { delete(r.seen, id) } - if len(r.seen) < maxReplayEntries { + if len(r.seen) < MaxReplayEntries { break } } diff --git a/internal/protection/replay_test.go b/internal/protection/replay_test.go index f70dd2a..da95598 100644 --- a/internal/protection/replay_test.go +++ b/internal/protection/replay_test.go @@ -5,68 +5,63 @@ import ( "time" ) +// TestReplayDetector_FirstSeenIsNotReplay verifies that the first occurrence +// of a message ID is never treated as a replay. func TestReplayDetector_FirstSeenIsNotReplay(t *testing.T) { r := NewReplayDetector() id := int64(123) if replay := r.MarkAndCheck(id); replay { - t.Fatalf("first time msgID=%d should NOT be replay", id) + t.Fatalf("first occurrence of msgID=%d should NOT be replay", id) } } +// TestReplayDetector_SecondSeenIsReplay ensures that re-sending the same +// message ID within the TTL window is detected as a replay. func TestReplayDetector_SecondSeenIsReplay(t *testing.T) { r := NewReplayDetector() id := int64(123) - if replay := r.MarkAndCheck(id); replay { - t.Fatalf("first time msgID=%d should NOT be replay", id) - } + r.MarkAndCheck(id) if replay := r.MarkAndCheck(id); !replay { - t.Fatalf("second time msgID=%d SHOULD be replay", id) + t.Fatalf("second occurrence of msgID=%d SHOULD be replay", id) } } +// TestReplayDetector_TTLExpires verifies that replay entries expire after TTL. func TestReplayDetector_TTLExpires(t *testing.T) { r := NewReplayDetector() id := int64(123) - // First time → not replay - if replay := r.MarkAndCheck(id); replay { - t.Fatalf("first time should NOT be replay") - } - - // Second time immediately → replay - if replay := r.MarkAndCheck(id); !replay { - t.Fatalf("second time SHOULD be replay") - } + r.MarkAndCheck(id) + r.MarkAndCheck(id) - // --- symulacja upływu czasu --- + // Simulate TTL expiration r.mu.Lock() - r.seen[id] = time.Now().Add(-replayTTL - time.Second) + r.seen[id] = time.Now().Add(-ReplayTTL - time.Second) r.mu.Unlock() - // Now TTL expired → should NOT be replay if replay := r.MarkAndCheck(id); replay { - t.Fatalf("after TTL msgID should NOT be replay") + t.Fatalf("msgID should NOT be replay after TTL expiration") } } +// TestReplayDetector_SweepRemovesOldEntries ensures that periodic sweeping +// removes expired entries. func TestReplayDetector_SweepRemovesOldEntries(t *testing.T) { r := NewReplayDetector() oldID := int64(1) newID := int64(2) - // Insert old entry r.MarkAndCheck(oldID) - // Cofamy czas starego wpisu + // Simulate old timestamp + force sweep r.mu.Lock() - r.seen[oldID] = time.Now().Add(-replayTTL - time.Second) - r.lastSweep = time.Now().Add(-replaySweepInterval - time.Second) + r.seen[oldID] = time.Now().Add(-ReplayTTL - time.Second) + r.lastSweep = time.Now().Add(-ReplaySweepInterval - time.Second) r.mu.Unlock() - // Trigger sweep r.MarkAndCheck(newID) r.mu.Lock() @@ -74,30 +69,30 @@ func TestReplayDetector_SweepRemovesOldEntries(t *testing.T) { r.mu.Unlock() if exists { - t.Fatalf("old entry should have been swept out") + t.Fatalf("expired entry should have been removed during sweep") } } +// TestReplayDetector_MaxEntriesLimit ensures that the detector never grows +// beyond MaxReplayEntries. func TestReplayDetector_MaxEntriesLimit(t *testing.T) { r := NewReplayDetector() - // Wypełniamy mapę do limitu - for i := 0; i < maxReplayEntries; i++ { + for i := 0; i < MaxReplayEntries; i++ { r.MarkAndCheck(int64(i)) } - // Cofamy czas części wpisów, aby mogły zostać usunięte + // Simulate all entries being old r.mu.Lock() - cutoff := time.Now().Add(-replayTTL - time.Second) + cutoff := time.Now().Add(-ReplayTTL - time.Second) for id := range r.seen { r.seen[id] = cutoff } r.mu.Unlock() - // Dodanie nowego wpisu powinno wywołać cleanup r.MarkAndCheck(999999) - if len(r.seen) > maxReplayEntries { - t.Fatalf("map should not exceed maxReplayEntries after cleanup") + if len(r.seen) > MaxReplayEntries { + t.Fatalf("ReplayDetector should not exceed MaxReplayEntries") } } diff --git a/internal/protection/session.go b/internal/protection/session.go index d48a243..d4624c1 100644 --- a/internal/protection/session.go +++ b/internal/protection/session.go @@ -6,14 +6,6 @@ import ( "github.com/quic-go/quic-go" ) -// DefaultSessionRateLimit defines how many messages per second -// a single user session is allowed to send. -const DefaultSessionRateLimit = 10 - -// DefaultIdleCloseErrorCode is the application error code used when -// closing idle connections from the Hub side. -const DefaultIdleCloseErrorCode = 0x09 - // Session represents a single authenticated user session on the Hub. // It tracks last activity time, rate limiting, replay protection and // provides a function to close the underlying QUIC connection. @@ -34,7 +26,6 @@ func NewSession(user string, conn *quic.Conn, stream *quic.Stream) *Session { LastActive: time.Now(), Limiter: NewRateLimiter(DefaultSessionRateLimit), CloseFunc: func() { - // Application error code 0x09 is used as "Idle Timeout" in this project. conn.CloseWithError(DefaultIdleCloseErrorCode, "Idle Timeout") }, Conn: conn, diff --git a/internal/protection/session_manager.go b/internal/protection/session_manager.go index faa513d..52f431b 100644 --- a/internal/protection/session_manager.go +++ b/internal/protection/session_manager.go @@ -6,17 +6,15 @@ import ( "time" ) -const ( - DefaultSessionIdleTimeout = 60 * time.Second - DefaultSessionCleanupInterval = 10 * time.Second -) - +// SessionManager manages all active sessions in memory. +// It periodically scans for idle sessions and closes them. type SessionManager struct { sessions map[string]*Session mu sync.RWMutex stopChan chan struct{} } +// NewSessionManager creates a new SessionManager and starts the idle cleaner. func NewSessionManager() *SessionManager { sm := &SessionManager{ sessions: make(map[string]*Session), @@ -26,6 +24,7 @@ func NewSessionManager() *SessionManager { return sm } +// Stop terminates the background cleaner goroutine. func (sm *SessionManager) Stop() { close(sm.stopChan) } @@ -63,7 +62,7 @@ func (sm *SessionManager) startCleaner(interval, idleTimeout time.Duration) { sm.mu.Lock() for user, sess := range sm.sessions { if time.Since(sess.LastActive) > idleTimeout { - fmt.Printf("[SessionManager: Protection] Idle Timeout: %s. Removing session.\n", user) + fmt.Printf("[SessionManager] Idle Timeout: %s. Removing session.\n", user) if sess.CloseFunc != nil { sess.CloseFunc() } @@ -75,6 +74,7 @@ func (sm *SessionManager) startCleaner(interval, idleTimeout time.Duration) { } } +// IsOnline returns true if the user currently has an active session. func (sm *SessionManager) IsOnline(user string) bool { sm.mu.RLock() defer sm.mu.RUnlock() diff --git a/internal/protection/session_manager_test.go b/internal/protection/session_manager_test.go index 1366a78..fa2c3ca 100644 --- a/internal/protection/session_manager_test.go +++ b/internal/protection/session_manager_test.go @@ -5,18 +5,18 @@ import ( "time" ) -// Test that idle sessions are removed by the cleaner goroutine. +// TestSessionManagerIdleCleanup verifies that idle sessions are removed +// by the background cleaner goroutine. func TestSessionManagerIdleCleanup(t *testing.T) { - // Create SessionManager with short intervals for testing. sm := &SessionManager{ sessions: make(map[string]*Session), stopChan: make(chan struct{}), } - // Start cleaner manually with short intervals. + // Start cleaner with very short intervals for testing. go sm.startCleaner(30*time.Millisecond, 50*time.Millisecond) - // Create idle session (already idle for >50ms) + // Create a session that is already idle for >50ms. sess := &Session{ ID: "alice", LastActive: time.Now().Add(-200 * time.Millisecond), @@ -25,13 +25,12 @@ func TestSessionManagerIdleCleanup(t *testing.T) { sm.Add("alice", sess) - // Wait long enough for cleaner to run + // Wait long enough for cleaner to run. time.Sleep(120 * time.Millisecond) - // Stop cleaner to avoid goroutine leak sm.Stop() if _, ok := sm.Get("alice"); ok { - t.Fatalf("expected idle session to be removed") + t.Fatalf("expected idle session to be removed by cleaner") } } From 42d304c337956936b8a61f08ec41fc3ac2e04c9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Brzezi=C5=84ski?= Date: Sat, 23 May 2026 23:35:30 +0000 Subject: [PATCH 13/44] protection package refactor issue fixed --- internal/protection/auth_timer.go | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 internal/protection/auth_timer.go diff --git a/internal/protection/auth_timer.go b/internal/protection/auth_timer.go new file mode 100644 index 0000000..418b446 --- /dev/null +++ b/internal/protection/auth_timer.go @@ -0,0 +1,24 @@ +package protection + +import "time" + +// AuthTimer wraps a time.Timer used for the AUTH timeout. +// It is started after a successful HELLO and stopped after AUTH/REGISTER. +type AuthTimer struct { + timer *time.Timer +} + +// StartAuthTimer starts an AUTH timeout timer that calls onTimeout when it fires. +// The timeout duration is controlled by DefaultAuthTimeout. +func StartAuthTimer(onTimeout func()) *AuthTimer { + return &AuthTimer{ + timer: time.AfterFunc(DefaultAuthTimeout, onTimeout), + } +} + +// Stop cancels the AUTH timer if it is still running. +func (at *AuthTimer) Stop() { + if at.timer != nil { + at.timer.Stop() + } +} From 0e1718babf8bf3b0c2e8a3492686837da9836b19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Brzezi=C5=84ski?= Date: Sat, 23 May 2026 23:45:06 +0000 Subject: [PATCH 14/44] cryptoee package refactor + new tests --- internal/cryptoee/constants.go | 9 ++ internal/cryptoee/cryptoee_test.go | 190 ++++++++++++++++++++--------- internal/cryptoee/decrypt.go | 7 +- internal/cryptoee/encrypt.go | 20 +-- internal/cryptoee/keys.go | 23 +--- internal/cryptoee/zeroize.go | 8 +- 6 files changed, 171 insertions(+), 86 deletions(-) create mode 100644 internal/cryptoee/constants.go diff --git a/internal/cryptoee/constants.go b/internal/cryptoee/constants.go new file mode 100644 index 0000000..c25c267 --- /dev/null +++ b/internal/cryptoee/constants.go @@ -0,0 +1,9 @@ +package cryptoee + +// KeySizeBytes defines the size (in bytes) of derived encryption and MAC keys. +// AES-256 + HMAC-SHA256 both use 32-byte keys. +const KeySizeBytes = 32 + +// aadFormatVersion is a protocol version tag embedded in the AAD string. +// Bumping this value invalidates old ciphertexts at the AAD level. +const aadFormatVersion = 1 diff --git a/internal/cryptoee/cryptoee_test.go b/internal/cryptoee/cryptoee_test.go index 5e653cf..016fcb0 100644 --- a/internal/cryptoee/cryptoee_test.go +++ b/internal/cryptoee/cryptoee_test.go @@ -6,9 +6,11 @@ import ( "testing" ) -// --- Helpers --- - +// mustKeys is a helper that derives deterministic test keys from a fixed secret. +// This ensures reproducible encryption/decryption results across test runs. func mustKeys(t *testing.T) ([]byte, []byte) { + t.Helper() + secret := []byte("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA") // 32 bytes kEnc, kMac, err := DeriveKeysFromSecret(secret) if err != nil { @@ -17,8 +19,14 @@ func mustKeys(t *testing.T) ([]byte, []byte) { return kEnc, kMac } -// --- Core encryption/decryption tests --- +// +// ──────────────────────────────────────────────────────────────────────────────── +// CORE ROUNDTRIP TESTS +// ──────────────────────────────────────────────────────────────────────────────── +// +// TestEncryptDecryptRoundtrip verifies that encryption followed by decryption +// returns the original plaintext without modification. func TestEncryptDecryptRoundtrip(t *testing.T) { kEnc, kMac := mustKeys(t) @@ -41,8 +49,61 @@ func TestEncryptDecryptRoundtrip(t *testing.T) { } } -// --- Tampering tests --- +// TestFullCryptoFlow acts as an integration test verifying the end-to-end +// encryption, signing, verification, and decryption process for a clean data flow. +func TestFullCryptoFlow(t *testing.T) { + kEnc, kMac := mustKeys(t) + + msgID := int64(999) + target := "charlie" + plaintext := "Integration test message" + + payload, mac, err := EncryptAndMACWithKeys(msgID, target, plaintext, kEnc, kMac) + if err != nil { + t.Fatalf("Encrypt failed: %v", err) + } + + out, err := DecryptAndVerifyWithKeys(msgID, target, payload, mac, kEnc, kMac) + if err != nil { + t.Fatalf("Decrypt failed: %v", err) + } + + if out != plaintext { + t.Fatalf("plaintext mismatch") + } +} + +// +// ──────────────────────────────────────────────────────────────────────────────── +// NONCE / RANDOMIZATION TESTS +// ──────────────────────────────────────────────────────────────────────────────── +// + +// TestDifferentNonceProducesDifferentCiphertext ensures that subsequent encryption +// operations of the same plaintext yield distinct ciphertexts and MACs due to unique nonces. +func TestDifferentNonceProducesDifferentCiphertext(t *testing.T) { + kEnc, kMac := mustKeys(t) + + msgID := int64(777) + target := "bob" + plaintext := "Hello" + + p1, m1, _ := EncryptAndMACWithKeys(msgID, target, plaintext, kEnc, kMac) + p2, m2, _ := EncryptAndMACWithKeys(msgID, target, plaintext, kEnc, kMac) + + if p1 == p2 || m1 == m2 { + t.Fatalf("encryption must be randomized (nonce must differ)") + } +} + +// +// ──────────────────────────────────────────────────────────────────────────────── +// TAMPERING TESTS +// ──────────────────────────────────────────────────────────────────────────────── +// +// TestTamperedCiphertext ensures that modifying even a single byte of the +// ciphertext results in decryption failure. func TestTamperedCiphertext(t *testing.T) { kEnc, kMac := mustKeys(t) @@ -55,17 +116,16 @@ func TestTamperedCiphertext(t *testing.T) { t.Fatalf("Encrypt failed: %v", err) } - // Tamper payload raw, _ := base64.StdEncoding.DecodeString(payload) - raw[len(raw)-1] ^= 0xFF + raw[len(raw)-1] ^= 0xFF // flip last byte tampered := base64.StdEncoding.EncodeToString(raw) - _, err = DecryptAndVerifyWithKeys(msgID, target, tampered, mac, kEnc, kMac) - if err == nil { - t.Fatalf("expected decryption failure after tampering") + if _, err := DecryptAndVerifyWithKeys(msgID, target, tampered, mac, kEnc, kMac); err == nil { + t.Fatalf("expected decryption failure after ciphertext tampering") } } +// TestTamperedMAC ensures that modifying the MAC results in HMAC verification failure. func TestTamperedMAC(t *testing.T) { kEnc, kMac := mustKeys(t) @@ -78,19 +138,22 @@ func TestTamperedMAC(t *testing.T) { t.Fatalf("Encrypt failed: %v", err) } - // Tamper MAC raw, _ := base64.StdEncoding.DecodeString(mac) - raw[0] ^= 0xAA + raw[0] ^= 0xAA // flip first byte tampered := base64.StdEncoding.EncodeToString(raw) - _, err = DecryptAndVerifyWithKeys(msgID, target, payload, tampered, kEnc, kMac) - if err == nil { + if _, err := DecryptAndVerifyWithKeys(msgID, target, payload, tampered, kEnc, kMac); err == nil { t.Fatalf("expected HMAC verification failure") } } -// --- Wrong msgID / wrong target --- +// +// ──────────────────────────────────────────────────────────────────────────────── +// WRONG PARAMETERS (msgID / target) +// ──────────────────────────────────────────────────────────────────────────────── +// +// TestWrongMsgID verifies that using a different msgID breaks HMAC verification. func TestWrongMsgID(t *testing.T) { kEnc, kMac := mustKeys(t) @@ -98,17 +161,14 @@ func TestWrongMsgID(t *testing.T) { target := "bob" plaintext := "Hello" - payload, mac, err := EncryptAndMACWithKeys(msgID, target, plaintext, kEnc, kMac) - if err != nil { - t.Fatalf("Encrypt failed: %v", err) - } + payload, mac, _ := EncryptAndMACWithKeys(msgID, target, plaintext, kEnc, kMac) - _, err = DecryptAndVerifyWithKeys(msgID+1, target, payload, mac, kEnc, kMac) - if err == nil { + if _, err := DecryptAndVerifyWithKeys(msgID+1, target, payload, mac, kEnc, kMac); err == nil { t.Fatalf("expected HMAC failure for wrong msgID") } } +// TestWrongTarget verifies that canonicalized target mismatch breaks HMAC verification. func TestWrongTarget(t *testing.T) { kEnc, kMac := mustKeys(t) @@ -116,19 +176,20 @@ func TestWrongTarget(t *testing.T) { target := "bob" plaintext := "Hello" - payload, mac, err := EncryptAndMACWithKeys(msgID, target, plaintext, kEnc, kMac) - if err != nil { - t.Fatalf("Encrypt failed: %v", err) - } + payload, mac, _ := EncryptAndMACWithKeys(msgID, target, plaintext, kEnc, kMac) - _, err = DecryptAndVerifyWithKeys(msgID, "alice", payload, mac, kEnc, kMac) - if err == nil { + if _, err := DecryptAndVerifyWithKeys(msgID, "alice", payload, mac, kEnc, kMac); err == nil { t.Fatalf("expected HMAC failure for wrong target") } } -// --- Base64 / payload errors --- +// +// ──────────────────────────────────────────────────────────────────────────────── +// BASE64 / PAYLOAD VALIDATION +// ──────────────────────────────────────────────────────────────────────────────── +// +// TestPayloadTooShort ensures that payloads shorter than nonce size are rejected. func TestPayloadTooShort(t *testing.T) { kEnc, kMac := mustKeys(t) @@ -137,79 +198,98 @@ func TestPayloadTooShort(t *testing.T) { shortPayload := base64.StdEncoding.EncodeToString([]byte{1, 2, 3}) - _, err := DecryptAndVerifyWithKeys(msgID, target, shortPayload, "AAAA", kEnc, kMac) - if err == nil { + if _, err := DecryptAndVerifyWithKeys(msgID, target, shortPayload, "AAAA", kEnc, kMac); err == nil { t.Fatalf("expected error for too short payload") } } +// TestInvalidBase64Payload ensures that invalid base64 payloads are rejected. func TestInvalidBase64Payload(t *testing.T) { kEnc, kMac := mustKeys(t) msgID := int64(400) target := "bob" - _, err := DecryptAndVerifyWithKeys(msgID, target, "!!!notbase64!!!", "AAAA", kEnc, kMac) - if err == nil { + if _, err := DecryptAndVerifyWithKeys(msgID, target, "!!!notbase64!!!", "AAAA", kEnc, kMac); err == nil { t.Fatalf("expected base64 decode error") } } +// TestInvalidBase64MAC ensures that invalid base64 MACs are rejected. func TestInvalidBase64MAC(t *testing.T) { kEnc, kMac := mustKeys(t) msgID := int64(500) target := "bob" - payload, _, err := EncryptAndMACWithKeys(msgID, target, "Hello", kEnc, kMac) - if err != nil { - t.Fatalf("Encrypt failed: %v", err) - } + payload, _, _ := EncryptAndMACWithKeys(msgID, target, "Hello", kEnc, kMac) - _, err = DecryptAndVerifyWithKeys(msgID, target, payload, "!!!notbase64!!!", kEnc, kMac) - if err == nil { + if _, err := DecryptAndVerifyWithKeys(msgID, target, payload, "!!!notbase64!!!", kEnc, kMac); err == nil { t.Fatalf("expected base64 decode error for MAC") } } -// --- HKDF tests --- +// +// ──────────────────────────────────────────────────────────────────────────────── +// HKDF TESTS +// ──────────────────────────────────────────────────────────────────────────────── +// +// TestDeriveKeysDeterministic verifies that HKDF produces deterministic output +// for the same input secret. func TestDeriveKeysDeterministic(t *testing.T) { secret := []byte("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA") - k1Enc, k1Mac, err := DeriveKeysFromSecret(secret) - if err != nil { - t.Fatalf("DeriveKeysFromSecret failed: %v", err) - } - - k2Enc, k2Mac, err := DeriveKeysFromSecret(secret) - if err != nil { - t.Fatalf("DeriveKeysFromSecret failed: %v", err) - } + k1Enc, k1Mac, _ := DeriveKeysFromSecret(secret) + k2Enc, k2Mac, _ := DeriveKeysFromSecret(secret) if !bytes.Equal(k1Enc, k2Enc) || !bytes.Equal(k1Mac, k2Mac) { t.Fatalf("HKDF must be deterministic for same secret") } } +// +// ──────────────────────────────────────────────────────────────────────────────── +// CANONICALIZATION TESTS +// ──────────────────────────────────────────────────────────────────────────────── +// + +// TestCanonicalization verifies that different textual forms of the same target +// decrypt correctly due to canonicalization. func TestCanonicalization(t *testing.T) { kEnc, kMac := mustKeys(t) msgID := int64(600) plaintext := "Hello" - // Different forms of same target payload1, mac1, _ := EncryptAndMACWithKeys(msgID, " Bob ", plaintext, kEnc, kMac) payload2, mac2, _ := EncryptAndMACWithKeys(msgID, "bob", plaintext, kEnc, kMac) - if payload1 == payload2 && mac1 == mac2 { - // This is OK — canonicalization makes them equivalent - return + // Decryption must succeed regardless of whitespace/case differences. + if _, err := DecryptAndVerifyWithKeys(msgID, "bob", payload1, mac1, kEnc, kMac); err != nil { + t.Fatalf("canonicalization failed for payload1: %v", err) } - // But decryption must work for both - _, err := DecryptAndVerifyWithKeys(msgID, "bob", payload1, mac1, kEnc, kMac) - if err != nil { - t.Fatalf("canonicalization failed: %v", err) + if _, err := DecryptAndVerifyWithKeys(msgID, " Bob ", payload2, mac2, kEnc, kMac); err != nil { + t.Fatalf("canonicalization failed for payload2: %v", err) + } +} + +// +// ──────────────────────────────────────────────────────────────────────────────── +// MEMORY MANAGEMENT TESTS +// ──────────────────────────────────────────────────────────────────────────────── +// + +// TestZeroize verifies that the Zeroize function securely overwrites the provided +// byte slice with zeroes to clear sensitive data from memory. +func TestZeroize(t *testing.T) { + b := []byte("sensitive-data") + Zeroize(b) + + for _, v := range b { + if v != 0 { + t.Fatalf("Zeroize failed: memory not overwritten") + } } } diff --git a/internal/cryptoee/decrypt.go b/internal/cryptoee/decrypt.go index 84ff857..de13ba2 100644 --- a/internal/cryptoee/decrypt.go +++ b/internal/cryptoee/decrypt.go @@ -9,6 +9,10 @@ import ( "fmt" ) +// DecryptAndVerifyWithKeys verifies HMAC and decrypts the payload using AES-GCM. +// It expects: +// - payloadB64: base64-encoded nonce || ciphertext +// - macB64: base64-encoded HMAC-SHA256 over cipher || msg_id || canonical_target. func DecryptAndVerifyWithKeys(msgID int64, target, payloadB64, macB64 string, kEnc, kMac []byte) (string, error) { raw, err := base64.StdEncoding.DecodeString(payloadB64) if err != nil { @@ -38,8 +42,7 @@ func DecryptAndVerifyWithKeys(msgID int64, target, payloadB64, macB64 string, kE nonce := raw[:nonceSize] ciphertext := raw[nonceSize:] - // --- NEW: AAD must match encryption --- - aad := fmt.Appendf(nil, "msgid=%d;target=%s;v=1", msgID, canonicalizeTarget(target)) + aad := []byte(fmt.Sprintf("msgid=%d;target=%s;v=%d", msgID, canonicalizeTarget(target), aadFormatVersion)) macInput := buildMACInput(ciphertext, msgID, target) h := hmac.New(sha256.New, kMac) diff --git a/internal/cryptoee/encrypt.go b/internal/cryptoee/encrypt.go index 8f94d5e..183071b 100644 --- a/internal/cryptoee/encrypt.go +++ b/internal/cryptoee/encrypt.go @@ -12,12 +12,13 @@ import ( "strings" ) -// buildMACInput = cipher || msg_id || canonical_target. +// buildMACInput builds the input to HMAC as: +// +// cipher || msg_id (big-endian uint64) || canonical_target func buildMACInput(cipher []byte, msgID int64, target string) []byte { msg := make([]byte, 8) binary.BigEndian.PutUint64(msg, uint64(msgID)) - // Canonicalize target canon := canonicalizeTarget(target) out := make([]byte, 0, len(cipher)+len(msg)+len(canon)) @@ -28,15 +29,19 @@ func buildMACInput(cipher []byte, msgID int64, target string) []byte { } // canonicalizeTarget normalizes the target string to a stable form -// to avoid MAC mismatches due to Unicode or case differences. +// to avoid MAC mismatches due to case or whitespace differences. func canonicalizeTarget(t string) string { - // Lowercase + trim is enough for our protocol. - // (If needed, we can add NFC normalization later.) + // Lowercase + trim is sufficient for this protocol. + // If needed, Unicode normalization (NFC) can be added later. return strings.ToLower(strings.TrimSpace(t)) } // EncryptAndMACWithKeys encrypts plaintext using AES-GCM and computes HMAC-SHA256 // over cipher || msg_id || canonical_target. +// +// The function returns: +// - payloadB64: base64-encoded nonce || ciphertext +// - macB64: base64-encoded HMAC-SHA256 func EncryptAndMACWithKeys(msgID int64, target, plaintext string, kEnc, kMac []byte) (string, string, error) { block, err := aes.NewCipher(kEnc) if err != nil { @@ -50,14 +55,13 @@ func EncryptAndMACWithKeys(msgID int64, target, plaintext string, kEnc, kMac []b nonceSize := aead.NonceSize() - // --- NEW: secure random nonce --- nonce := make([]byte, nonceSize) if _, err := rand.Read(nonce); err != nil { return "", "", fmt.Errorf("[Encrypt]: nonce generation error: %w", err) } - // --- NEW: AAD (associated data) --- - aad := []byte(fmt.Sprintf("msgid=%d;target=%s;v=1", msgID, canonicalizeTarget(target))) + // AAD binds msgID, canonical target and a format version to the ciphertext. + aad := []byte(fmt.Sprintf("msgid=%d;target=%s;v=%d", msgID, canonicalizeTarget(target), aadFormatVersion)) ciphertext := aead.Seal(nil, nonce, []byte(plaintext), aad) diff --git a/internal/cryptoee/keys.go b/internal/cryptoee/keys.go index 485b9fd..38e25a2 100644 --- a/internal/cryptoee/keys.go +++ b/internal/cryptoee/keys.go @@ -7,27 +7,16 @@ import ( "golang.org/x/crypto/hkdf" ) -// Key sizes used for both encryption and MAC keys. -const ( - KeySizeBytes = 32 -) - -// NOTE: -// We assume that Alice and Bob have agreed on a shared secret K_AB -// through some out-of-band mechanism (e.g., QR code, password, or prior exchange). -// This secret is then used to derive separate keys for encryption and authentication using HKDF. - // DeriveKeysFromSecret derives K_enc and K_mac from the provided shared secret // using HKDF-SHA256. // // IMPORTANT: -// - 'secret' MUST already be a high-entropy shared secret K_AB agreed OOB -// between the two parties (e.g. 32 random bytes). -// - This function does NOT perform password hashing. If you want to use -// human-memorable passphrases, you MUST first run them through a KDF -// suitable for passwords (e.g. Argon2, PBKDF2) and only then call this -// function with the resulting key material. - +// - 'secret' MUST already be a high-entropy shared secret K_AB agreed +// out-of-band between the two parties (e.g. 32 random bytes). +// - This function does NOT perform password hashing. If you want to use +// human-memorable passphrases, you MUST first run them through a KDF +// suitable for passwords (e.g. Argon2, PBKDF2) and only then call this +// function with the resulting key material. func DeriveKeysFromSecret(secret []byte) (kEnc, kMac []byte, err error) { hkdfEnc := hkdf.New(sha256.New, secret, nil, []byte("encryption")) hkdfMac := hkdf.New(sha256.New, secret, nil, []byte("authentication")) diff --git a/internal/cryptoee/zeroize.go b/internal/cryptoee/zeroize.go index 8385554..68268ed 100644 --- a/internal/cryptoee/zeroize.go +++ b/internal/cryptoee/zeroize.go @@ -1,9 +1,9 @@ package cryptoee -// Zeroize securely zeroes out the contents of the byte slice. -// This is a best-effort function to reduce the risk of sensitive data -// lingering in memory. Note that Go's garbage collector and memory management -// may still keep copies of the data, so this is not a guarantee. +// Zeroize attempts to securely overwrite the contents of the byte slice. +// This is a best-effort mitigation to reduce the lifetime of sensitive data +// in memory. Go's runtime and garbage collector may still keep copies, so +// this is not a hard security guarantee. func Zeroize(b []byte) { for i := range b { b[i] = 0 From db469f5ba5196843f330f85ba4c86f8bffc96481 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Brzezi=C5=84ski?= Date: Sat, 23 May 2026 23:52:03 +0000 Subject: [PATCH 15/44] certmanager package refactor --- internal/certmanager/cert_generate.go | 77 +++++++++++++++ internal/certmanager/cert_load.go | 31 ++++++ internal/certmanager/certmanager.go | 120 ----------------------- internal/certmanager/certmanager_test.go | 28 +++--- internal/certmanager/constants.go | 15 +++ internal/certmanager/pem.go | 17 ++++ 6 files changed, 154 insertions(+), 134 deletions(-) create mode 100644 internal/certmanager/cert_generate.go create mode 100644 internal/certmanager/cert_load.go delete mode 100644 internal/certmanager/certmanager.go create mode 100644 internal/certmanager/constants.go create mode 100644 internal/certmanager/pem.go diff --git a/internal/certmanager/cert_generate.go b/internal/certmanager/cert_generate.go new file mode 100644 index 0000000..6e154c3 --- /dev/null +++ b/internal/certmanager/cert_generate.go @@ -0,0 +1,77 @@ +package certmanager + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "fmt" + "math/big" + "net" + "os" + "path/filepath" + "time" +) + +// GenerateSelfSignedCert creates a fully valid self-signed ECDSA certificate. +// The certificate includes SAN entries for DNS and localhost IPs. +func GenerateSelfSignedCert(certPath, keyPath, dnsName string) error { + priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return fmt.Errorf("failed to generate ECDSA key: %w", err) + } + + notBefore := time.Now() + notAfter := notBefore.Add(DefaultCertValidity) + + serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) + if err != nil { + return fmt.Errorf("failed to generate serial number: %w", err) + } + + template := x509.Certificate{ + SerialNumber: serial, + Subject: pkix.Name{ + Organization: []string{DefaultOrgName}, + }, + NotBefore: notBefore, + NotAfter: notAfter, + + IsCA: true, + BasicConstraintsValid: true, + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign, + ExtKeyUsage: []x509.ExtKeyUsage{ + x509.ExtKeyUsageServerAuth, + x509.ExtKeyUsageClientAuth, + }, + + DNSNames: []string{dnsName}, + IPAddresses: []net.IP{ + net.ParseIP("127.0.0.1"), + net.ParseIP("::1"), + }, + + SignatureAlgorithm: x509.ECDSAWithSHA256, + } + + certDER, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv) + if err != nil { + return fmt.Errorf("failed to create certificate: %w", err) + } + + if err := os.MkdirAll(filepath.Dir(certPath), 0755); err != nil { + return fmt.Errorf("failed to create certificate directory: %w", err) + } + + if err := writePEM(certPath, "CERTIFICATE", certDER); err != nil { + return err + } + + privBytes, err := x509.MarshalECPrivateKey(priv) + if err != nil { + return fmt.Errorf("failed to marshal EC private key: %w", err) + } + + return writePEM(keyPath, "EC PRIVATE KEY", privBytes) +} diff --git a/internal/certmanager/cert_load.go b/internal/certmanager/cert_load.go new file mode 100644 index 0000000..fb0eb71 --- /dev/null +++ b/internal/certmanager/cert_load.go @@ -0,0 +1,31 @@ +package certmanager + +import ( + "crypto/tls" + "fmt" + "os" +) + +// SetupTLSConfig loads existing certificates or generates new self-signed ones. +// This function is used by the Hub during startup. +func SetupTLSConfig(certPath, keyPath string) (*tls.Config, error) { + // Generate certificates if missing + if _, err := os.Stat(certPath); os.IsNotExist(err) { + fmt.Println("[CertManager] No TLS certificates found. Generating new self-signed certificates...") + if err := GenerateSelfSignedCert(certPath, keyPath, DefaultServerDNSName); err != nil { + return nil, fmt.Errorf("failed to generate certificates: %w", err) + } + } + + // Load certificate pair + cert, err := tls.LoadX509KeyPair(certPath, keyPath) + if err != nil { + return nil, fmt.Errorf("failed to load certificate files: %w", err) + } + + return &tls.Config{ + Certificates: []tls.Certificate{cert}, + MinVersion: tls.VersionTLS13, + NextProtos: []string{DefaultALPNProtocol}, + }, nil +} diff --git a/internal/certmanager/certmanager.go b/internal/certmanager/certmanager.go deleted file mode 100644 index e91317c..0000000 --- a/internal/certmanager/certmanager.go +++ /dev/null @@ -1,120 +0,0 @@ -package certmanager - -import ( - "crypto/ecdsa" - "crypto/elliptic" - "crypto/rand" - "crypto/tls" - "crypto/x509" - "crypto/x509/pkix" - "encoding/pem" - "fmt" - "math/big" - "net" - "os" - "path/filepath" - "time" -) - -const ( - DefaultOrgName = "KittyProtocol Dev Environment" - DefaultCertValidity = 365 * 24 * time.Hour - DefaultServerDNSName = "kitty-hub" -) - -// SetupTLSConfig loads certificates from disk or generates new self-signed ones. -func SetupTLSConfig(certPath, keyPath string) (*tls.Config, error) { - if _, err := os.Stat(certPath); os.IsNotExist(err) { - fmt.Println("[CertManager] No TLS certificates found. Generating new self-signed certificates...") - if err := GenerateSelfSignedCert(certPath, keyPath, DefaultServerDNSName); err != nil { - return nil, fmt.Errorf("failed to generate certificates: %w", err) - } - } - - cert, err := tls.LoadX509KeyPair(certPath, keyPath) - if err != nil { - return nil, fmt.Errorf("failed to load certificate files: %w", err) - } - - return &tls.Config{ - Certificates: []tls.Certificate{cert}, - MinVersion: tls.VersionTLS13, - NextProtos: []string{"kitty-quic-v1"}, - }, nil -} - -// GenerateSelfSignedCert creates a fully valid self-signed ECDSA certificate. -func GenerateSelfSignedCert(certPath, keyPath, dnsName string) error { - priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - if err != nil { - return err - } - - notBefore := time.Now() - notAfter := notBefore.Add(DefaultCertValidity) - - serialNumber, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) - if err != nil { - return err - } - - template := x509.Certificate{ - SerialNumber: serialNumber, - Subject: pkix.Name{ - Organization: []string{DefaultOrgName}, - }, - NotBefore: notBefore, - NotAfter: notAfter, - - // Required for self-signed certs - IsCA: true, - BasicConstraintsValid: true, - KeyUsage: x509.KeyUsageDigitalSignature | - x509.KeyUsageCertSign, - - ExtKeyUsage: []x509.ExtKeyUsage{ - x509.ExtKeyUsageServerAuth, - x509.ExtKeyUsageClientAuth, - }, - - // SAN - DNSNames: []string{dnsName}, - IPAddresses: []net.IP{ - net.ParseIP("127.0.0.1"), - net.ParseIP("::1"), - }, - - // Recommended for compatibility - SignatureAlgorithm: x509.ECDSAWithSHA256, - } - - certDER, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv) - if err != nil { - return err - } - - if err := os.MkdirAll(filepath.Dir(certPath), 0755); err != nil { - return err - } - - if err := writePEM(certPath, "CERTIFICATE", certDER); err != nil { - return err - } - - privBytes, err := x509.MarshalECPrivateKey(priv) - if err != nil { - return err - } - - return writePEM(keyPath, "EC PRIVATE KEY", privBytes) -} - -func writePEM(path, pemType string, data []byte) error { - f, err := os.Create(path) - if err != nil { - return err - } - defer f.Close() - - return pem.Encode(f, &pem.Block{Type: pemType, Bytes: data}) -} diff --git a/internal/certmanager/certmanager_test.go b/internal/certmanager/certmanager_test.go index c790f59..16a50f0 100644 --- a/internal/certmanager/certmanager_test.go +++ b/internal/certmanager/certmanager_test.go @@ -6,37 +6,37 @@ import ( "testing" ) +// TestSetupTLSConfig verifies that certificates are generated when missing +// and correctly loaded when already present. func TestSetupTLSConfig(t *testing.T) { tempCert := "temp_cert.pem" tempKey := "temp_key.pem" - // Czyszczenie po teście + // Cleanup after test defer os.Remove(tempCert) defer os.Remove(tempKey) - // Etap 1: Wygenerowanie nowych certyfikatów - config, err := SetupTLSConfig(tempCert, tempKey) + // Step 1: Generate new certificates + cfg, err := SetupTLSConfig(tempCert, tempKey) if err != nil { - t.Fatalf("Oczekiwano sukcesu, otrzymano błąd: %v", err) + t.Fatalf("expected success, got error: %v", err) } - // Sprawdzenie, czy wymuszono TLS 1.3 - if config.MinVersion != tls.VersionTLS13 { - t.Errorf("Oczekiwano TLS 1.3, otrzymano: %x", config.MinVersion) + if cfg.MinVersion != tls.VersionTLS13 { + t.Errorf("expected TLS 1.3, got: %x", cfg.MinVersion) } - // Sprawdzenie, czy pliki faktycznie powstały if _, err := os.Stat(tempCert); os.IsNotExist(err) { - t.Errorf("Plik certyfikatu nie został wygenerowany") + t.Errorf("certificate file was not generated") } - // Etap 2: Wczytanie z już istniejących plików (bez generowania) - config2, err := SetupTLSConfig(tempCert, tempKey) + // Step 2: Load existing certificates + cfg2, err := SetupTLSConfig(tempCert, tempKey) if err != nil { - t.Fatalf("Oczekiwano sukcesu przy wczytywaniu istniejących plików, błąd: %v", err) + t.Fatalf("expected success when loading existing files, got: %v", err) } - if len(config2.Certificates) == 0 { - t.Errorf("Brak certyfikatów w konfiguracji") + if len(cfg2.Certificates) == 0 { + t.Errorf("expected loaded certificates, got none") } } diff --git a/internal/certmanager/constants.go b/internal/certmanager/constants.go new file mode 100644 index 0000000..9264f32 --- /dev/null +++ b/internal/certmanager/constants.go @@ -0,0 +1,15 @@ +package certmanager + +import "time" + +// DefaultOrgName is used as the Organization field in self-signed certificates. +const DefaultOrgName = "KittyProtocol Dev Environment" + +// DefaultCertValidity defines how long generated certificates remain valid. +const DefaultCertValidity = 365 * 24 * time.Hour + +// DefaultServerDNSName is the SAN DNS entry used for the Hub. +const DefaultServerDNSName = "kitty-hub" + +// DefaultALPNProtocol is the ALPN identifier used for QUIC connections. +const DefaultALPNProtocol = "kitty-quic-v1" diff --git a/internal/certmanager/pem.go b/internal/certmanager/pem.go new file mode 100644 index 0000000..a7d7488 --- /dev/null +++ b/internal/certmanager/pem.go @@ -0,0 +1,17 @@ +package certmanager + +import ( + "encoding/pem" + "os" +) + +// writePEM writes a PEM-encoded block to the specified file path. +func writePEM(path, pemType string, data []byte) error { + f, err := os.Create(path) + if err != nil { + return err + } + defer f.Close() + + return pem.Encode(f, &pem.Block{Type: pemType, Bytes: data}) +} From bd2ceee88b7db3b3243f5c6b6de6fe5bead73fd2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Brzezi=C5=84ski?= Date: Sun, 24 May 2026 01:10:20 +0000 Subject: [PATCH 16/44] Auth package refactor --- go.mod | 1 + go.sum | 3 ++ internal/auth/auth_mock_test.go | 46 +++++++++++++++++ internal/auth/auth_validation_test.go | 57 +++++++++++++++++++++ internal/auth/constants.go | 10 ++++ internal/auth/db_auth.go | 46 +---------------- internal/auth/db_auth_test.go | 67 +++++++++++++++++++++++++ internal/auth/db_queries.go | 38 ++++++++++++++ internal/auth/{auth.go => mock_auth.go} | 52 ++----------------- internal/auth/provider.go | 23 +++++++++ internal/auth/validation.go | 25 +++++++++ 11 files changed, 274 insertions(+), 94 deletions(-) create mode 100644 internal/auth/auth_mock_test.go create mode 100644 internal/auth/auth_validation_test.go create mode 100644 internal/auth/constants.go create mode 100644 internal/auth/db_auth_test.go create mode 100644 internal/auth/db_queries.go rename internal/auth/{auth.go => mock_auth.go} (50%) create mode 100644 internal/auth/provider.go create mode 100644 internal/auth/validation.go diff --git a/go.mod b/go.mod index 905d090..df58ec9 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/gabbla05/KittyProtocol go 1.24 require ( + github.com/DATA-DOG/go-sqlmock v1.5.2 github.com/joho/godotenv v1.5.1 github.com/lib/pq v1.12.3 github.com/quic-go/quic-go v0.59.0 diff --git a/go.sum b/go.sum index 01b30f5..e2d5b18 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,10 @@ +github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= +github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE= github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ= github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= diff --git a/internal/auth/auth_mock_test.go b/internal/auth/auth_mock_test.go new file mode 100644 index 0000000..dbc1483 --- /dev/null +++ b/internal/auth/auth_mock_test.go @@ -0,0 +1,46 @@ +package auth + +import "testing" + +// TestMockAuthRegisterAndLogin verifies that MockAuth can register and authenticate users. +func TestMockAuthRegisterAndLogin(t *testing.T) { + m := NewMockAuth() + + user := "testuser" + pass := "StrongPass123!" + + if err := m.Register(user, pass); err != nil { + t.Fatalf("Register failed: %v", err) + } + + ok := m.CheckCredentials(user, pass) + if !ok { + t.Fatalf("expected credentials to be valid after registration") + } + + ok = m.CheckCredentials(user, "wrongpass") + if ok { + t.Fatalf("expected invalid credentials for wrong password") + } +} + +// TestMockAuthUserExists verifies that UserExists reflects registration state. +func TestMockAuthUserExists(t *testing.T) { + m := NewMockAuth() + + exists, err := m.UserExists("alice") + if err != nil { + t.Fatalf("UserExists returned error: %v", err) + } + if !exists { + t.Fatalf("expected alice to exist in default mock users") + } + + exists, err = m.UserExists("nonexistent") + if err != nil { + t.Fatalf("UserExists returned error: %v", err) + } + if exists { + t.Fatalf("expected nonexistent user to not exist") + } +} diff --git a/internal/auth/auth_validation_test.go b/internal/auth/auth_validation_test.go new file mode 100644 index 0000000..ab2910e --- /dev/null +++ b/internal/auth/auth_validation_test.go @@ -0,0 +1,57 @@ +package auth + +import "testing" + +// TestValidateUsername ensures that username validation enforces the expected policy. +func TestValidateUsername(t *testing.T) { + tests := []struct { + name string + input string + wantErr bool + }{ + {"valid_simple", "alice", false}, + {"valid_with_digits", "user123", false}, + {"valid_with_underscore", "user_name", false}, + {"too_short", "ab", true}, + {"too_long", "thisusernameiswaytoolongtobevalid_123", true}, + {"invalid_chars_upper", "Alice", true}, + {"invalid_chars_dash", "user-name", true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := validateUsername(tc.input) + if tc.wantErr && err == nil { + t.Fatalf("expected error for username %q, got nil", tc.input) + } + if !tc.wantErr && err != nil { + t.Fatalf("unexpected error for username %q: %v", tc.input, err) + } + }) + } +} + +// TestValidatePassword ensures that the minimum length requirement is enforced. +func TestValidatePassword(t *testing.T) { + tests := []struct { + name string + input string + wantErr bool + }{ + {"too_short", "1234567", true}, + {"exact_min", "12345678", false}, + {"longer", "Password123!", false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := validatePassword(tc.input) + if tc.wantErr && err == nil { + t.Fatalf("expected error for password %q, got nil", tc.input) + } + if !tc.wantErr && err != nil { + t.Fatalf("unexpected error for password %q: %v", tc.input, err) + } + }) + } +} diff --git a/internal/auth/constants.go b/internal/auth/constants.go new file mode 100644 index 0000000..7b8e0e1 --- /dev/null +++ b/internal/auth/constants.go @@ -0,0 +1,10 @@ +package auth + +// MinPasswordLength defines the minimum allowed password length. +// This is enforced by validatePassword. +const MinPasswordLength = 8 + +// UsernamePattern defines the allowed username format: +// - 3–32 characters +// - lowercase letters, digits and underscore only. +const UsernamePattern = `^[a-z0-9_]{3,32}$` diff --git a/internal/auth/db_auth.go b/internal/auth/db_auth.go index fe17d69..a28ad76 100644 --- a/internal/auth/db_auth.go +++ b/internal/auth/db_auth.go @@ -8,14 +8,6 @@ import ( ) // DBAuth is a PostgreSQL-backed authentication provider. -// It assumes a table: -// -// CREATE TABLE users ( -// id SERIAL PRIMARY KEY, -// username TEXT UNIQUE NOT NULL, -// password_hash TEXT NOT NULL, -// created_at TIMESTAMP NOT NULL DEFAULT NOW() -// ); type DBAuth struct { db *sql.DB } @@ -24,7 +16,6 @@ func NewDBAuth(db *sql.DB) *DBAuth { return &DBAuth{db: db} } -// CheckCredentials verifies username and password against the database. func (a *DBAuth) CheckCredentials(user, pass string) bool { hash, err := a.lookupPasswordHash(user) if err != nil { @@ -33,7 +24,6 @@ func (a *DBAuth) CheckCredentials(user, pass string) bool { return bcrypt.CompareHashAndPassword([]byte(hash), []byte(pass)) == nil } -// Register creates a new user in the database with a bcrypt-hashed password. func (a *DBAuth) Register(user, pass string) error { if err := validateUsername(user); err != nil { return err @@ -55,39 +45,5 @@ func (a *DBAuth) Register(user, pass string) error { return fmt.Errorf("failed to hash password: %w", err) } - _, err = a.db.Exec( - "INSERT INTO users (username, password_hash) VALUES ($1, $2)", - user, string(hash), - ) - if err != nil { - return fmt.Errorf("failed to insert user: %w", err) - } - - return nil -} - -// UserExists checks if a user with the given username exists. -func (a *DBAuth) UserExists(user string) (bool, error) { - var exists bool - err := a.db.QueryRow( - "SELECT EXISTS(SELECT 1 FROM users WHERE username=$1)", - user, - ).Scan(&exists) - if err != nil { - return false, err - } - return exists, nil -} - -// lookupPasswordHash returns the stored bcrypt hash for a user. -func (a *DBAuth) lookupPasswordHash(user string) (string, error) { - var hash string - err := a.db.QueryRow( - "SELECT password_hash FROM users WHERE username=$1", - user, - ).Scan(&hash) - if err != nil { - return "", err - } - return hash, nil + return a.insertUser(user, string(hash)) } diff --git a/internal/auth/db_auth_test.go b/internal/auth/db_auth_test.go new file mode 100644 index 0000000..c562a25 --- /dev/null +++ b/internal/auth/db_auth_test.go @@ -0,0 +1,67 @@ +package auth + +import ( + "regexp" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + "golang.org/x/crypto/bcrypt" +) + +// TestDBAuthUserExists verifies that UserExists queries the database correctly. +func TestDBAuthUserExists(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("failed to create sqlmock: %v", err) + } + defer db.Close() + + a := NewDBAuth(db) + + mock.ExpectQuery(regexp.QuoteMeta( + "SELECT EXISTS(SELECT 1 FROM users WHERE username=$1)", + )). + WithArgs("alice"). + WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true)) + + exists, err := a.UserExists("alice") + if err != nil { + t.Fatalf("UserExists returned error: %v", err) + } + if !exists { + t.Fatalf("expected alice to exist") + } + + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("unmet expectations: %v", err) + } +} + +// TestDBAuthCheckCredentials verifies that CheckCredentials compares bcrypt hashes. +func TestDBAuthCheckCredentials(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("failed to create sqlmock: %v", err) + } + defer db.Close() + + a := NewDBAuth(db) + + pass := "Secret123!" + hash, _ := bcrypt.GenerateFromPassword([]byte(pass), bcrypt.DefaultCost) + + mock.ExpectQuery(regexp.QuoteMeta( + "SELECT password_hash FROM users WHERE username=$1", + )). + WithArgs("alice"). + WillReturnRows(sqlmock.NewRows([]string{"password_hash"}).AddRow(string(hash))) + + ok := a.CheckCredentials("alice", pass) + if !ok { + t.Fatalf("expected valid credentials to pass") + } + + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("unmet expectations: %v", err) + } +} diff --git a/internal/auth/db_queries.go b/internal/auth/db_queries.go new file mode 100644 index 0000000..009726f --- /dev/null +++ b/internal/auth/db_queries.go @@ -0,0 +1,38 @@ +package auth + +import "fmt" + +func (a *DBAuth) UserExists(user string) (bool, error) { + var exists bool + err := a.db.QueryRow( + "SELECT EXISTS(SELECT 1 FROM users WHERE username=$1)", + user, + ).Scan(&exists) + if err != nil { + return false, err + } + return exists, nil +} + +func (a *DBAuth) lookupPasswordHash(user string) (string, error) { + var hash string + err := a.db.QueryRow( + "SELECT password_hash FROM users WHERE username=$1", + user, + ).Scan(&hash) + if err != nil { + return "", err + } + return hash, nil +} + +func (a *DBAuth) insertUser(user, hash string) error { + _, err := a.db.Exec( + "INSERT INTO users (username, password_hash) VALUES ($1, $2)", + user, hash, + ) + if err != nil { + return fmt.Errorf("failed to insert user: %w", err) + } + return nil +} diff --git a/internal/auth/auth.go b/internal/auth/mock_auth.go similarity index 50% rename from internal/auth/auth.go rename to internal/auth/mock_auth.go index 8589f82..0374d41 100644 --- a/internal/auth/auth.go +++ b/internal/auth/mock_auth.go @@ -2,40 +2,18 @@ package auth import ( "fmt" - "regexp" "golang.org/x/crypto/bcrypt" ) -// AuthProvider defines the interface for authentication backends. -// This allows swapping mock auth for a real database implementation. -type AuthProvider interface { - // CheckCredentials verifies username and password. - // Returns true if credentials are valid, false otherwise. - CheckCredentials(user, pass string) bool - - // Register creates a new user with the given credentials. - // Implementations MUST: - // - validate username and password, - // - return an error if the user already exists, - // - never log the password. - Register(user, pass string) error - - // UserExists returns true if the user already exists. - UserExists(user string) (bool, error) -} - -// ----------------------------------------------------------------------------- -// MockAuth – in‑memory implementation for development/testing -// ----------------------------------------------------------------------------- - // MockAuth is a simple in-memory authentication provider. -// Intended ONLY for development and testing. +// Intended ONLY for development and testing. It is not persistent. type MockAuth struct { users map[string]string // username -> bcrypt hash } // NewMockAuth creates a mock authentication provider with predefined users. +// The initial users are intended for local development and manual testing. func NewMockAuth() *MockAuth { return &MockAuth{ users: map[string]string{ @@ -57,8 +35,7 @@ func (m *MockAuth) CheckCredentials(user, pass string) bool { return false } - err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(pass)) - if err != nil { + if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(pass)); err != nil { fmt.Println("[AUTH] invalid password") return false } @@ -95,26 +72,3 @@ func (m *MockAuth) UserExists(user string) (bool, error) { _, exists := m.users[user] return exists, nil } - -// ----------------------------------------------------------------------------- -// Shared validation helpers -// ----------------------------------------------------------------------------- - -var usernameRe = regexp.MustCompile(`^[a-z0-9_]{3,32}$`) - -// validateUsername enforces a simple, predictable username policy. -func validateUsername(user string) error { - if !usernameRe.MatchString(user) { - return fmt.Errorf("invalid username: must be 3–32 chars, [a-z0-9_]") - } - return nil -} - -// validatePassword enforces a minimal password policy. -// You can tighten this later (e.g. require digits/symbols). -func validatePassword(pass string) error { - if len(pass) < 8 { - return fmt.Errorf("password too short: minimum 8 characters") - } - return nil -} diff --git a/internal/auth/provider.go b/internal/auth/provider.go new file mode 100644 index 0000000..0a37e29 --- /dev/null +++ b/internal/auth/provider.go @@ -0,0 +1,23 @@ +package auth + +// AuthProvider defines the interface for authentication backends. +// Implementations include MockAuth (development) and DBAuth (production). +// +// This interface is ideal as a boundary for higher-level services or +// frontend bindings (e.g. Wails), which can depend on AuthProvider +// without knowing the underlying storage. +type AuthProvider interface { + // CheckCredentials verifies username and password. + // Returns true if credentials are valid, false otherwise. + CheckCredentials(user, pass string) bool + + // Register creates a new user with the given credentials. + // Implementations MUST: + // - validate username and password, + // - return an error if the user already exists, + // - never log the password. + Register(user, pass string) error + + // UserExists returns true if the user already exists. + UserExists(user string) (bool, error) +} diff --git a/internal/auth/validation.go b/internal/auth/validation.go new file mode 100644 index 0000000..880641e --- /dev/null +++ b/internal/auth/validation.go @@ -0,0 +1,25 @@ +package auth + +import ( + "fmt" + "regexp" +) + +var usernameRe = regexp.MustCompile(UsernamePattern) + +// validateUsername enforces a simple, predictable username policy. +func validateUsername(user string) error { + if !usernameRe.MatchString(user) { + return fmt.Errorf("invalid username: must be 3–32 chars, [a-z0-9_]") + } + return nil +} + +// validatePassword enforces a minimal password policy. +// You can tighten this later (e.g. require digits/symbols). +func validatePassword(pass string) error { + if len(pass) < MinPasswordLength { + return fmt.Errorf("password too short: minimum %d characters", MinPasswordLength) + } + return nil +} From 5bf165c8bf0a70655b48ef5f49d6800edc626d2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Brzezi=C5=84ski?= Date: Sun, 24 May 2026 14:13:45 +0000 Subject: [PATCH 17/44] refactor(hub): unify architecture, fix protocol errors, improve handlers and logging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move Hub entrypoint to cmd/hub/main.go - Convert hub/ into a proper Go package (no more package main) - Add start.go with clean server bootstrap and QUIC initialization - Add bootstrap.go with env loading and graceful shutdown - Add constants.go with all Hub-level tunables (payload, rate limits, QUIC config) - Add logger.go with structured RFC3339 logging - Add errors.go with centralized protocol error frame sender - Refactor dispatcher.go: - use protocol error constants - add format error counter - remove magic numbers - add production-grade comments - Refactor router.go: - fix incorrect ERR_15 → ERR_08 - fix incorrect ERR_10 → ERR_05 - add comments and logging - Refactor all handlers (HELLO, AUTH, REGISTER, DATA, PING, STATUS, BYE): - use protocol error constants - add comments - remove magic strings - unify logging - normalize target usernames - Improve context.go cleanup logic - Ensure globalSessions and globalAuth are initialized in Start() - Make Hub fully frontend-ready (stable API, stable error codes) --- TODO | 9 +- cmd/hub/main.go | 9 ++ hub/auth_flow.go | 48 ---------- hub/bootstrap.go | 45 ++++++++++ hub/constants.go | 49 +++++++++++ hub/context.go | 69 +++++++++++++++ hub/{handler_dispatcher.go => dispatcher.go} | 26 +++++- hub/errors.go | 13 +-- hub/handler_auth.go | 63 +++++-------- hub/handler_bye.go | 16 ++-- hub/handler_context.go | 47 ---------- hub/handler_data.go | 36 +++++--- hub/handler_hello.go | 31 ++++--- hub/handler_ping.go | 29 ++---- hub/handler_register.go | 49 +++++++++++ hub/handler_status.go | 17 ++-- hub/happy_path_test.go | 2 +- hub/logger.go | 52 +++++++++-- hub/main.go | 92 ------------------- hub/negative_test.go | 2 +- hub/performance_test.go | 2 +- hub/router.go | 32 ++++--- hub/security_test.go | 2 +- hub/start.go | 93 ++++++++++++++++++++ protocol/errors.go | 70 +++++++++++++++ 25 files changed, 577 insertions(+), 326 deletions(-) create mode 100644 cmd/hub/main.go delete mode 100644 hub/auth_flow.go create mode 100644 hub/bootstrap.go create mode 100644 hub/constants.go create mode 100644 hub/context.go rename hub/{handler_dispatcher.go => dispatcher.go} (60%) delete mode 100644 hub/handler_context.go create mode 100644 hub/handler_register.go delete mode 100644 hub/main.go create mode 100644 hub/start.go create mode 100644 protocol/errors.go diff --git a/TODO b/TODO index 51f7a01..cefb987 100644 --- a/TODO +++ b/TODO @@ -10,4 +10,11 @@ - jaki jest sens registerFrame zamiast po prostu zrobić parse registerframe na authframe skoro to dosłownie ta sama ramka - forma przechowywania sekretóœ lokalnie - słabe szyfrowanie chyba bo widać że sekrety są te same. Zamiast tego zastanowić się nad hashowaniem -- !!!!WAŻNE!!! plik ze stałymi (W tym z dokumentacji) aby w kodzie nie używać magicznych liczb, ale konkretnych stałych \ No newline at end of file +- !!!!WAŻNE!!! plik ze stałymi (W tym z dokumentacji) aby w kodzie nie używać magicznych liczb, ale konkretnych stałych + +- SENSOWNOŚĆ CERTYFIKATÓW! + +- Uważać czy wszędzie gdzie trzeba jest: + _ "github.com/lib/pq" !!!!! + +- \ No newline at end of file diff --git a/cmd/hub/main.go b/cmd/hub/main.go new file mode 100644 index 0000000..d6cf8ba --- /dev/null +++ b/cmd/hub/main.go @@ -0,0 +1,9 @@ +package main + +import ( + "github.com/gabbla05/KittyProtocol/hub" +) + +func main() { + hub.Start() +} diff --git a/hub/auth_flow.go b/hub/auth_flow.go deleted file mode 100644 index 8f3c4aa..0000000 --- a/hub/auth_flow.go +++ /dev/null @@ -1,48 +0,0 @@ -// hub/auth_flow.go -// Implements the initial HELLO → AUTH flow and authorization timeout handling. - -package main - -// import ( -// "encoding/json" -// "fmt" - -// "github.com/gabbla05/KittyProtocol/internal/protection" -// "github.com/gabbla05/KittyProtocol/protocol" -// ) - -// // handleHello processes the initial HELLO frame and starts the AUTH timeout timer. -// // It responds with MEOW_OK(status="Ready for auth"). -// func (c *clientContext) handleHello(raw []byte) { -// // Parse HELLO -// frame, err := protocol.ParseHelloFrame(raw) -// if err != nil { -// sendError(c.stream, "ERR_02", err.Error()) -// return -// } - -// // Set state -// c.state = stateHelloReceived - -// // Send MEOW_OK -// ok := protocol.MeowOkFrame{ -// BaseFrame: protocol.BaseFrame{ -// Type: protocol.FrameTypeMeowOK, -// MsgID: frame.MsgID, -// }, -// Status: "Ready for auth", -// } - -// b, err := json.Marshal(ok) -// if err == nil { -// _, _ = c.stream.Write(b) -// } else { -// fmt.Println("[Hub: HELLO] Failed to marshal MEOW_OK:", err) -// } - -// // Start AUTH timeout -// c.authTimer = protection.StartAuthTimer(func() { -// sendError(c.stream, "ERR_03", "Authorization timeout reached") -// _ = c.conn.CloseWithError(0x03, "ERR_03: Auth Timeout") -// }) -// } diff --git a/hub/bootstrap.go b/hub/bootstrap.go new file mode 100644 index 0000000..e29c02e --- /dev/null +++ b/hub/bootstrap.go @@ -0,0 +1,45 @@ +// bootstrap.go +// Contains environment loading and graceful shutdown logic for the Hub. +// This file has no protocol logic — only process-level lifecycle management. + +package hub + +import ( + "os" + "os/signal" + "syscall" + + "github.com/joho/godotenv" + "github.com/quic-go/quic-go" +) + +// loadEnv loads environment variables from a .env file if present. +// Missing .env is not treated as an error. +func loadEnv() { + _ = godotenv.Load() + + // Disable colors if KITTY_LOG_COLOR=0 + if os.Getenv("KITTY_LOG_COLOR") == "0" { + colorsEnabled = false + } +} + +// setupSignalHandler installs OS signal handlers for graceful shutdown. +// On SIGINT/SIGTERM, all sessions are terminated and the QUIC listener is closed. +func setupSignalHandler(listener *quic.Listener) { + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM, syscall.SIGQUIT) + + go func() { + sig := <-sigCh + logWarn("Caught signal: %v", sig) + + // Stop all active sessions + globalSessions.Stop() + + // Close QUIC listener + _ = listener.Close() + + logInfo("Graceful shutdown complete.") + }() +} diff --git a/hub/constants.go b/hub/constants.go new file mode 100644 index 0000000..6838677 --- /dev/null +++ b/hub/constants.go @@ -0,0 +1,49 @@ +package hub + +import "time" + +// Hub-level constants used across handlers, routing logic, and session management. +// These values complement protocol-level constants defined in protocol/error_codes.go. +// They MUST remain consistent with the protocol documentation and the protection package. + +// Maximum allowed payload size for DATA frames. +// If exceeded, the Hub must return ERR_13 (Payload Too Large). +const maxPayloadBytes = 2048 + +// Size of the read buffer for incoming QUIC stream data. +// Must be large enough to hold the largest expected frame. +const readBufferSize = 8192 + +// Idle timeout for authenticated sessions. +// If exceeded, the Hub should return ERR_09 (Session Timeout / Idle). +// NOTE: This is separate from the QUIC idle timeout. +const sessionIdleTimeout = 60 * time.Second + +// Maximum number of messages per second allowed by the Hub. +// Exceeding this should trigger ERR_07 (Rate Limit Exceeded). +const rateLimitPerSecond = 10 + +// Maximum number of messages per minute allowed by the Hub. +// Also part of ERR_07 enforcement. +const rateLimitPerMinute = 100 + +// Maximum number of consecutive format errors (ERR_02) before the Hub closes the connection. +// Helps prevent malformed spam or broken clients. +const maxFormatErrors = 5 + +// QUIC configuration defaults. +// These values define transport-level behavior and should remain aligned with +// performance and security expectations of the KittyProtocol Hub. +const ( + quicMaxIdleTimeout = 60 * time.Second + quicKeepAlivePeriod = 30 * time.Second + quicAllow0RTT = true + quicDisablePMTU = false +) + +// Default listening address used when KITTY_INTERCEPT_ADDR is not set. +const defaultHubAddress = "0.0.0.0:9999" + +// Default PostgreSQL DSN for Hub authentication backend. +// In production, this should be overridden via environment variables. +const defaultDSN = "postgres://kitty:kittypass@localhost:5432/kittyhub?sslmode=disable" diff --git a/hub/context.go b/hub/context.go new file mode 100644 index 0000000..5a82d9b --- /dev/null +++ b/hub/context.go @@ -0,0 +1,69 @@ +// hub/context.go +// Connection-scoped state for a single QUIC client. +// Each clientContext instance represents one connected client and tracks: +// - QUIC connection and stream +// - authentication state +// - associated session (after AUTH) +// - AUTH timeout timer +// - last activity timestamps +// +// This file contains no protocol logic — only state management. + +package hub + +import ( + "time" + + "github.com/gabbla05/KittyProtocol/internal/protection" + "github.com/quic-go/quic-go" +) + +// connectionState represents the current stage of the protocol handshake. +type connectionState int + +const ( + stateInit connectionState = iota // No HELLO yet + stateHelloReceived // HELLO received, waiting for AUTH/REGISTER + stateAuthenticated // AUTH successful, session active +) + +// clientContext holds all per-connection state. +// It is created in dispatcher.go when a new QUIC stream is accepted. +type clientContext struct { + conn *quic.Conn // Underlying QUIC connection + stream *quic.Stream // Primary bidirectional stream + session *protection.Session // Active session after AUTH + username string // Set only after successful AUTH + state connectionState // Protocol handshake state machine + authTimer *protection.AuthTimer // Timer to enforce AUTH timeout +} + +// cleanup releases all resources associated with this client. +// It is ALWAYS called via defer in dispatcher.go. +func (c *clientContext) cleanup() { + // Remove session if present + if c.session != nil { + logInfo("[Context] Cleaning up session for user: %s", c.username) + globalSessions.Remove(c.username) + + if c.session.CloseFunc != nil { + c.session.CloseFunc() + } + + c.session = nil + } + + // Stop AUTH timeout timer + if c.authTimer != nil { + c.authTimer.Stop() + c.authTimer = nil + } +} + +// touch updates the session's last activity timestamp. +// Called on every valid frame after AUTH. +func (c *clientContext) touch() { + if c.session != nil { + c.session.LastActive = time.Now() + } +} diff --git a/hub/handler_dispatcher.go b/hub/dispatcher.go similarity index 60% rename from hub/handler_dispatcher.go rename to hub/dispatcher.go index 96f08dd..8f69654 100644 --- a/hub/handler_dispatcher.go +++ b/hub/dispatcher.go @@ -1,4 +1,9 @@ -package main +// dispatcher.go +// Central QUIC stream dispatcher. Reads raw frames from the client stream, +// determines their type, and forwards them to the appropriate handler. +// This file contains no business logic — only frame routing and connection lifecycle. + +package hub import ( "context" @@ -23,7 +28,8 @@ func handleClient(conn *quic.Conn) { } defer ctx.cleanup() - buf := make([]byte, 8192) + buf := make([]byte, readBufferSize) + formatErrCount := 0 for { n, err := stream.Read(buf) @@ -36,13 +42,25 @@ func handleClient(conn *quic.Conn) { raw := buf[:n] + // Determine frame type typeName, msgID, perr := protocol.GetFrameType(raw) if perr != nil || msgID <= 0 { - sendError(stream, "ERR_02", "Invalid frame header") + formatErrCount++ + sendError(stream, protocol.ErrFormatError, "Invalid frame header") + + if formatErrCount >= maxFormatErrors { + logWarn("Too many malformed frames from client — closing connection") + return + } continue } + // Reset format error counter on valid frame + formatErrCount = 0 + + // Dispatch to handler switch typeName { + case protocol.FrameTypeHello: ctx.handleHello(raw) @@ -66,7 +84,7 @@ func handleClient(conn *quic.Conn) { return default: - sendError(stream, "ERR_02", "Unknown frame type") + sendError(stream, protocol.ErrFormatError, "Unknown frame type") } } } diff --git a/hub/errors.go b/hub/errors.go index 672db19..f2082d5 100644 --- a/hub/errors.go +++ b/hub/errors.go @@ -1,11 +1,11 @@ -// hub/errors.go +// errors.go // Centralized helpers for sending protocol-level ERROR frames from the Hub. +// All error codes MUST come from protocol/error_codes.go. -package main +package hub import ( "encoding/json" - "fmt" "time" "github.com/gabbla05/KittyProtocol/protocol" @@ -14,10 +14,11 @@ import ( // sendError sends a standardized ERROR frame to the client. // Serialization or write failures are logged but do not panic. +// This function MUST be used by all handlers to ensure consistent error reporting. func sendError(stream *quic.Stream, code, desc string) { errFrame := protocol.ErrorFrame{ BaseFrame: protocol.BaseFrame{ - Type: "ERROR", + Type: protocol.FrameTypeError, MsgID: time.Now().UnixMilli(), }, Code: code, @@ -26,11 +27,11 @@ func sendError(stream *quic.Stream, code, desc string) { b, err := json.Marshal(errFrame) if err != nil { - fmt.Println("[Hub: Errors] Failed to marshal ERROR frame:", err) + logError("[Errors] Failed to marshal ERROR frame: %v", err) return } if _, err := stream.Write(b); err != nil { - fmt.Println("[Hub: Errors] Failed to send ERROR frame:", err) + logError("[Errors] Failed to send ERROR frame: %v", err) } } diff --git a/hub/handler_auth.go b/hub/handler_auth.go index 1635b65..d915b07 100644 --- a/hub/handler_auth.go +++ b/hub/handler_auth.go @@ -1,45 +1,58 @@ -package main +// handler_auth.go +// Handles the AUTH frame — the second step of the KittyProtocol handshake. +// After successful authentication, a session is created and the client enters +// the stateAuthenticated state. + +package hub import ( "encoding/json" - "fmt" "github.com/gabbla05/KittyProtocol/internal/protection" "github.com/gabbla05/KittyProtocol/protocol" ) +// handleAuth processes an AUTH frame after a successful HELLO. +// Expected state: stateHelloReceived → stateAuthenticated. func (c *clientContext) handleAuth(raw []byte) { if c.state != stateHelloReceived { - sendError(c.stream, "ERR_02", "AUTH not allowed before HELLO") + sendError(c.stream, protocol.ErrProtocolViolation, "AUTH not allowed before HELLO") return } frame, err := protocol.ParseAuthFrame(raw) if err != nil { - sendError(c.stream, "ERR_02", err.Error()) + sendError(c.stream, protocol.ErrFormatError, err.Error()) return } + // Stop AUTH timeout if c.authTimer != nil { c.authTimer.Stop() c.authTimer = nil } + // Validate credentials if !globalAuth.CheckCredentials(frame.User, frame.Pass) { - sendError(c.stream, "ERR_04", "Authentication failed") + sendError(c.stream, protocol.ErrAuthenticationFailed, "Authentication failed") return } + // Prevent duplicate logins if globalSessions.IsOnline(frame.User) { - sendError(c.stream, "ERR_05", "User already logged in") + sendError(c.stream, protocol.ErrSessionError, "User already logged in") return } + // Create session c.session = protection.NewSession(frame.User, c.conn, c.stream) globalSessions.Add(frame.User, c.session) c.username = frame.User c.state = stateAuthenticated + logInfo("[AUTH] User '%s' authenticated successfully", frame.User) + + // Send MEOW_OK ok := protocol.MeowOkFrame{ BaseFrame: protocol.BaseFrame{ Type: protocol.FrameTypeMeowOK, @@ -49,44 +62,12 @@ func (c *clientContext) handleAuth(raw []byte) { } b, err := json.Marshal(ok) - if err == nil { - _, _ = c.stream.Write(b) - } else { - fmt.Println("[Hub: Auth] Failed to marshal MEOW_OK:", err) - } -} - -func (c *clientContext) handleRegister(raw []byte) { - if c.state != stateHelloReceived { - sendError(c.stream, "ERR_02", "REGISTER not allowed before HELLO") - return - } - - frame, err := protocol.ParseRegisterFrame(raw) if err != nil { - sendError(c.stream, "ERR_02", err.Error()) - return - } - - // Rejestracja NIE tworzy sesji i NIE loguje użytkownika. - // Klient po udanej rejestracji powinien wykonać osobne AUTH. - if err := globalAuth.Register(frame.User, frame.Pass); err != nil { - sendError(c.stream, "ERR_06", err.Error()) + logError("[AUTH] Failed to marshal MEOW_OK: %v", err) return } - ok := protocol.MeowOkFrame{ - BaseFrame: protocol.BaseFrame{ - Type: protocol.FrameTypeMeowOK, - MsgID: frame.MsgID, - }, - Status: "Registered", - } - - b, err := json.Marshal(ok) - if err == nil { - _, _ = c.stream.Write(b) - } else { - fmt.Println("[Hub: Register] Failed to marshal MEOW_OK:", err) + if _, err := c.stream.Write(b); err != nil { + logError("[AUTH] Failed to send MEOW_OK: %v", err) } } diff --git a/hub/handler_bye.go b/hub/handler_bye.go index 1694aee..09ea6a3 100644 --- a/hub/handler_bye.go +++ b/hub/handler_bye.go @@ -1,24 +1,24 @@ -package main +// handler_bye.go +// Handles the BYE frame — clean session termination requested by the client. -import ( - "fmt" +package hub +import ( "github.com/gabbla05/KittyProtocol/protocol" ) func (c *clientContext) handleBye(raw []byte) { if c.state != stateAuthenticated { - sendError(c.stream, "ERR_02", "BYE not allowed before AUTH") + sendError(c.stream, protocol.ErrProtocolViolation, "BYE not allowed before AUTH") return } - _, err := protocol.ParseByeFrame(raw) - if err != nil { - sendError(c.stream, "ERR_02", err.Error()) + if _, err := protocol.ParseByeFrame(raw); err != nil { + sendError(c.stream, protocol.ErrFormatError, err.Error()) return } - fmt.Println("[Handler: Bye] Cleaning up session for:", c.username) + logInfo("[BYE] Cleaning up session for user: %s", c.username) globalSessions.Remove(c.username) diff --git a/hub/handler_context.go b/hub/handler_context.go deleted file mode 100644 index fcbf0b8..0000000 --- a/hub/handler_context.go +++ /dev/null @@ -1,47 +0,0 @@ -package main - -import ( - "fmt" - "time" - - "github.com/gabbla05/KittyProtocol/internal/protection" - "github.com/quic-go/quic-go" -) - -type connectionState int - -const ( - stateInit connectionState = iota - stateHelloReceived - stateAuthenticated -) - -type clientContext struct { - conn *quic.Conn - stream *quic.Stream - session *protection.Session - username string - authTimer *protection.AuthTimer - state connectionState -} - -func (c *clientContext) cleanup() { - if c.session != nil { - fmt.Println("[Handler: Context] Cleaning up session for:", c.username) - globalSessions.Remove(c.username) - if c.session.CloseFunc != nil { - c.session.CloseFunc() - } - c.session = nil - } - if c.authTimer != nil { - c.authTimer.Stop() - c.authTimer = nil - } -} - -func (c *clientContext) touch() { - if c.session != nil { - c.session.LastActive = time.Now() - } -} diff --git a/hub/handler_data.go b/hub/handler_data.go index 4a39f8a..d42354e 100644 --- a/hub/handler_data.go +++ b/hub/handler_data.go @@ -1,8 +1,10 @@ -package main +// handler_data.go +// Handles DATA frames — encrypted message delivery between authenticated users. + +package hub import ( "encoding/json" - "fmt" "strings" "github.com/gabbla05/KittyProtocol/protocol" @@ -14,51 +16,61 @@ func canonicalTarget(t string) string { func (c *clientContext) handleData(raw []byte) { if c.state != stateAuthenticated { - sendError(c.stream, "ERR_02", "DATA not allowed before AUTH") + sendError(c.stream, protocol.ErrProtocolViolation, "DATA not allowed before AUTH") return } frame, err := protocol.ParseDataFrame(raw) if err != nil { - sendError(c.stream, "ERR_02", err.Error()) + sendError(c.stream, protocol.ErrFormatError, err.Error()) return } + // Normalize target username frame.Target = canonicalTarget(frame.Target) + // Sanity check if c.session == nil { - sendError(c.stream, "ERR_01", "DATA before AUTH") + sendError(c.stream, protocol.ErrProtocolViolation, "DATA before AUTH") return } + // Rate limit if !c.session.Limiter.Allow() { - sendError(c.stream, "ERR_07", "Rate limit exceeded") + sendError(c.stream, protocol.ErrRateLimitExceeded, "Rate limit exceeded") return } + // Replay protection if c.session.Replay.MarkAndCheck(frame.MsgID) { - sendError(c.stream, "ERR_06", "Replay detected") + sendError(c.stream, protocol.ErrReplayDetected, "Replay detected") return } + // Update activity c.touch() + // Route message if !routeData(*frame, c.session, c.stream) { return } + // Send ACK ack := protocol.MeowOkFrame{ BaseFrame: protocol.BaseFrame{ - Type: "MEOW_OK", + Type: protocol.FrameTypeMeowOK, MsgID: frame.MsgID, }, Status: "Delivered", } b, err := json.Marshal(ack) - if err == nil { - c.stream.Write(b) - } else { - fmt.Println("[Hub: Data] Failed to marshal ACK:", err) + if err != nil { + logError("[DATA] Failed to marshal ACK: %v", err) + return + } + + if _, err := c.stream.Write(b); err != nil { + logError("[DATA] Failed to send ACK: %v", err) } } diff --git a/hub/handler_hello.go b/hub/handler_hello.go index 81c5986..b9ff752 100644 --- a/hub/handler_hello.go +++ b/hub/handler_hello.go @@ -1,8 +1,10 @@ -package main +// handler_hello.go +// Handles the HELLO frame — the first step of the KittyProtocol handshake. + +package hub import ( "encoding/json" - "fmt" "github.com/gabbla05/KittyProtocol/internal/protection" "github.com/gabbla05/KittyProtocol/protocol" @@ -10,22 +12,21 @@ import ( func (c *clientContext) handleHello(raw []byte) { if c.state != stateInit { - sendError(c.stream, "ERR_02", "HELLO not allowed in current state") + sendError(c.stream, protocol.ErrProtocolViolation, "HELLO not allowed in current state") return } hello, err := protocol.ParseHelloFrame(raw) if err != nil { - sendError(c.stream, "ERR_02", err.Error()) + sendError(c.stream, protocol.ErrFormatError, err.Error()) return } - fmt.Println("[Hub] HELLO from client, version:", hello.Version) + logInfo("[HELLO] Client version: %s", hello.Version) - // Set state c.state = stateHelloReceived - // Send MEOW_OK + // Respond with MEOW_OK ok := protocol.MeowOkFrame{ BaseFrame: protocol.BaseFrame{ Type: protocol.FrameTypeMeowOK, @@ -34,15 +35,19 @@ func (c *clientContext) handleHello(raw []byte) { Status: "Ready for auth", } - if b, err := json.Marshal(ok); err == nil { - _, _ = c.stream.Write(b) - } else { - fmt.Println("[Hub: HELLO] Failed to marshal MEOW_OK:", err) + b, err := json.Marshal(ok) + if err != nil { + logError("[HELLO] Failed to marshal MEOW_OK: %v", err) + return + } + + if _, err := c.stream.Write(b); err != nil { + logError("[HELLO] Failed to send MEOW_OK: %v", err) } - // Start AUTH timeout + // Start AUTH timeout (20s from protection.DefaultAuthTimeout) c.authTimer = protection.StartAuthTimer(func() { - sendError(c.stream, "ERR_03", "Authorization timeout reached") + sendError(c.stream, protocol.ErrAuthorizationTimeout, "Authorization timeout reached") _ = c.conn.CloseWithError(0x03, "ERR_03: Auth Timeout") }) } diff --git a/hub/handler_ping.go b/hub/handler_ping.go index 19614bf..cc3181b 100644 --- a/hub/handler_ping.go +++ b/hub/handler_ping.go @@ -1,37 +1,22 @@ -package main +// handler_ping.go +// Handles PING frames — keeps the session alive. -import ( - "encoding/json" - "fmt" +package hub +import ( "github.com/gabbla05/KittyProtocol/protocol" ) func (c *clientContext) handlePing(raw []byte) { if c.state != stateAuthenticated { - sendError(c.stream, "ERR_02", "PING not allowed before AUTH") + sendError(c.stream, protocol.ErrProtocolViolation, "PING not allowed before AUTH") return } - _, err := protocol.ParsePingFrame(raw) - if err != nil { - sendError(c.stream, "ERR_02", err.Error()) + if _, err := protocol.ParsePingFrame(raw); err != nil { + sendError(c.stream, protocol.ErrFormatError, err.Error()) return } c.touch() } - -func ParsePingFrame(data []byte) (*protocol.PingFrame, error) { - var f protocol.PingFrame - if err := json.Unmarshal(data, &f); err != nil { - return nil, fmt.Errorf("%s: Invalid JSON format", protocol.ErrCodeInvalidFrame) - } - if f.Type != protocol.FrameTypePing { - return nil, fmt.Errorf("%s: Invalid type for PING frame", protocol.ErrCodeInvalidFrame) - } - if f.MsgID <= 0 { - return nil, fmt.Errorf("%s: Invalid msg_id in PING frame", protocol.ErrCodeInvalidFrame) - } - return &f, nil -} diff --git a/hub/handler_register.go b/hub/handler_register.go new file mode 100644 index 0000000..172612e --- /dev/null +++ b/hub/handler_register.go @@ -0,0 +1,49 @@ +// handler_register.go +// Handles the REGISTER frame — creates a new user account. +// REGISTER does NOT authenticate the user; AUTH must follow. + +package hub + +import ( + "encoding/json" + + "github.com/gabbla05/KittyProtocol/protocol" +) + +func (c *clientContext) handleRegister(raw []byte) { + if c.state != stateHelloReceived { + sendError(c.stream, protocol.ErrProtocolViolation, "REGISTER not allowed before HELLO") + return + } + + frame, err := protocol.ParseRegisterFrame(raw) + if err != nil { + sendError(c.stream, protocol.ErrFormatError, err.Error()) + return + } + + if err := globalAuth.Register(frame.User, frame.Pass); err != nil { + sendError(c.stream, protocol.ErrSessionError, err.Error()) + return + } + + logInfo("[REGISTER] User '%s' registered successfully", frame.User) + + ok := protocol.MeowOkFrame{ + BaseFrame: protocol.BaseFrame{ + Type: protocol.FrameTypeMeowOK, + MsgID: frame.MsgID, + }, + Status: "Registered", + } + + b, err := json.Marshal(ok) + if err != nil { + logError("[REGISTER] Failed to marshal MEOW_OK: %v", err) + return + } + + if _, err := c.stream.Write(b); err != nil { + logError("[REGISTER] Failed to send MEOW_OK: %v", err) + } +} diff --git a/hub/handler_status.go b/hub/handler_status.go index 875674b..d0683cb 100644 --- a/hub/handler_status.go +++ b/hub/handler_status.go @@ -1,8 +1,10 @@ -package main +// handler_status.go +// Handles GET_STATUS — checks whether a target user is online. + +package hub import ( "encoding/json" - "fmt" "strings" "github.com/gabbla05/KittyProtocol/protocol" @@ -10,21 +12,20 @@ import ( func (c *clientContext) handleGetStatus(raw []byte) { if c.state != stateAuthenticated { - sendError(c.stream, "ERR_02", "GET_STATUS not allowed before AUTH") + sendError(c.stream, protocol.ErrProtocolViolation, "GET_STATUS not allowed before AUTH") return } frame, err := protocol.ParseGetStatusFrame(raw) if err != nil { - sendError(c.stream, "ERR_02", err.Error()) + sendError(c.stream, protocol.ErrFormatError, err.Error()) return } target := strings.ToLower(strings.TrimSpace(frame.Target)) - online := globalSessions.IsOnline(target) status := "offline" - if online { + if globalSessions.IsOnline(target) { status = "online" } @@ -39,11 +40,11 @@ func (c *clientContext) handleGetStatus(raw []byte) { b, err := json.Marshal(res) if err != nil { - sendError(c.stream, "ERR_02", "Failed to marshal STATUS_RES") + sendError(c.stream, protocol.ErrFormatError, "Failed to marshal STATUS_RES") return } if _, err := c.stream.Write(b); err != nil { - fmt.Println("[Hub: Status] Failed to send STATUS_RES:", err) + logError("[STATUS] Failed to send STATUS_RES: %v", err) } } diff --git a/hub/happy_path_test.go b/hub/happy_path_test.go index 86ef084..26d89fb 100644 --- a/hub/happy_path_test.go +++ b/hub/happy_path_test.go @@ -1,4 +1,4 @@ -package main +package hub import ( "context" diff --git a/hub/logger.go b/hub/logger.go index 489a863..bcc3892 100644 --- a/hub/logger.go +++ b/hub/logger.go @@ -1,18 +1,52 @@ -package main +// logger.go +// Lightweight structured logger for the Hub. Uses stdout and RFC3339 timestamps. +// This logger is intentionally simple to avoid external dependencies. + +package hub import ( "fmt" "time" ) -func logInfo(msg string, args ...any) { - fmt.Printf("[INFO] %s: %s\n", time.Now().Format(time.RFC3339), fmt.Sprintf(msg, args...)) -} +const ( + colorReset = "\033[0m" + colorRed = "\033[31m" + colorYellow = "\033[33m" + colorBlue = "\033[34m" +) -func logWarn(msg string, args ...any) { - fmt.Printf("[WARN] %s: %s\n", time.Now().Format(time.RFC3339), fmt.Sprintf(msg, args...)) -} +var colorsEnabled = true -func logError(msg string, args ...any) { - fmt.Printf("[ERROR] %s: %s\n", time.Now().Format(time.RFC3339), fmt.Sprintf(msg, args...)) +func log(level, msg string, args ...any) { + timestamp := time.Now().Format(time.RFC3339) + + var color string + if colorsEnabled { + switch level { + case "INFO": + color = colorBlue + case "WARN": + color = colorYellow + case "ERROR": + color = colorRed + default: + color = colorReset + } + } + + // Without colors + if !colorsEnabled { + fmt.Printf("[%s] %s: %s\n", + level, timestamp, fmt.Sprintf(msg, args...)) + return + } + + // With colors + fmt.Printf("%s[%s] %s: %s%s\n", + color, level, timestamp, fmt.Sprintf(msg, args...), colorReset) } + +func logInfo(msg string, args ...any) { log("INFO", msg, args...) } +func logWarn(msg string, args ...any) { log("WARN", msg, args...) } +func logError(msg string, args ...any) { log("ERROR", msg, args...) } diff --git a/hub/main.go b/hub/main.go deleted file mode 100644 index fffb484..0000000 --- a/hub/main.go +++ /dev/null @@ -1,92 +0,0 @@ -package main - -import ( - "context" - "database/sql" - "os" - "os/signal" - "syscall" - "time" - - "github.com/gabbla05/KittyProtocol/internal/auth" - "github.com/gabbla05/KittyProtocol/internal/certmanager" - "github.com/gabbla05/KittyProtocol/internal/protection" - "github.com/joho/godotenv" - _ "github.com/lib/pq" - "github.com/quic-go/quic-go" -) - -var ( - globalSessions = protection.NewSessionManager() - globalAuth auth.AuthProvider = auth.NewMockAuth() -) - -func main() { - loadEnv() - - tlsConf, err := certmanager.SetupTLSConfig("certs/cert.pem", "certs/key.pem") - if err != nil { - logError("Failed to load TLS certificates: %v", err) - return - } - - dsn := "postgres://kitty:kittypass@localhost:5432/kittyhub?sslmode=disable" - db, err := sql.Open("postgres", dsn) - if err != nil { - logError("DB connection failed: %v", err) - return - } - - globalAuth = auth.NewDBAuth(db) - - quicConf := &quic.Config{ - MaxIdleTimeout: 60 * time.Second, - KeepAlivePeriod: 30 * time.Second, - Allow0RTT: true, - DisablePathMTUDiscovery: false, - } - - addr := os.Getenv("KITTY_INTERCEPT_ADDR") - if addr == "" { - addr = "0.0.0.0:9999" - } - - listener, err := quic.ListenAddr(addr, tlsConf, quicConf) - if err != nil { - logError("Failed to start listener: %v", err) - return - } - - logInfo("🐈 KittyProtocol Hub listening on %s", addr) - - setupSignalHandler(listener) - - for { - conn, err := listener.Accept(context.Background()) - if err != nil { - logError("Accept error: %v", err) - return - } - - go handleClient(conn) - } -} - -func loadEnv() { - _ = godotenv.Load() -} - -func setupSignalHandler(listener *quic.Listener) { - sigCh := make(chan os.Signal, 1) - signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM, syscall.SIGQUIT) - - go func() { - sig := <-sigCh - logWarn("Caught signal: %v", sig) - - globalSessions.Stop() - _ = listener.Close() - - logInfo("Graceful shutdown complete.") - }() -} diff --git a/hub/negative_test.go b/hub/negative_test.go index 9d5c7c2..75726b7 100644 --- a/hub/negative_test.go +++ b/hub/negative_test.go @@ -1,4 +1,4 @@ -package main +package hub import ( "context" diff --git a/hub/performance_test.go b/hub/performance_test.go index f2ee5a7..fe5e418 100644 --- a/hub/performance_test.go +++ b/hub/performance_test.go @@ -1,4 +1,4 @@ -package main +package hub import ( "context" diff --git a/hub/router.go b/hub/router.go index 07382c6..7cc029d 100644 --- a/hub/router.go +++ b/hub/router.go @@ -1,8 +1,11 @@ -package main +// router.go +// Implements DATA frame forwarding between authenticated sessions. +// This file contains no protocol parsing — only delivery logic. + +package hub import ( "encoding/json" - "fmt" "time" "github.com/gabbla05/KittyProtocol/internal/protection" @@ -10,24 +13,31 @@ import ( "github.com/quic-go/quic-go" ) +// routeData forwards a DATA frame from sender → receiver. +// Returns true on success, false if delivery failed. func routeData(frame protocol.DataFrame, sender *protection.Session, senderStream *quic.Stream) bool { targetSess, ok := globalSessions.Get(frame.Target) if !ok { - sendError(senderStream, "ERR_15", "Receiver offline") + // ERR_08 — Delivery Failed – Recipient Offline + sendError(senderStream, protocol.ErrDeliveryFailedOffline, "Receiver offline") return false } if targetSess.Stream == nil { - sendError(senderStream, "ERR_10", "Receiver stream not available") + // ERR_05 — Session Error (receiver session corrupted) + sendError(senderStream, protocol.ErrSessionError, "Receiver stream not available") return false } - sender.LastActive = time.Now() - targetSess.LastActive = time.Now() + // Update activity timestamps + now := time.Now() + sender.LastActive = now + targetSess.LastActive = now + // Build forwarded DATA frame forward := protocol.DataFrame{ BaseFrame: protocol.BaseFrame{ - Type: "DATA", + Type: protocol.FrameTypeData, MsgID: frame.MsgID, }, Sender: sender.ID, @@ -38,14 +48,14 @@ func routeData(frame protocol.DataFrame, sender *protection.Session, senderStrea fb, err := json.Marshal(forward) if err != nil { - fmt.Println("[Hub: Router] Failed to marshal forwarded DATA:", err) - sendError(senderStream, "ERR_02", "Failed to marshal forwarded DATA") + logError("[Router] Failed to marshal forwarded DATA: %v", err) + sendError(senderStream, protocol.ErrFormatError, "Failed to marshal forwarded DATA") return false } if _, err := targetSess.Stream.Write(fb); err != nil { - fmt.Println("[Hub: Router] Failed to deliver DATA:", err) - sendError(senderStream, "ERR_10", "Failed to deliver to receiver") + logError("[Router] Failed to deliver DATA: %v", err) + sendError(senderStream, protocol.ErrSessionError, "Failed to deliver to receiver") return false } diff --git a/hub/security_test.go b/hub/security_test.go index 164c9da..4cb7edc 100644 --- a/hub/security_test.go +++ b/hub/security_test.go @@ -1,4 +1,4 @@ -package main +package hub import ( "context" diff --git a/hub/start.go b/hub/start.go new file mode 100644 index 0000000..d910c4c --- /dev/null +++ b/hub/start.go @@ -0,0 +1,93 @@ +// start.go +// Entry point for the Hub server. Initializes TLS, QUIC, authentication backend, +// session manager, and begins accepting client connections. This function blocks +// until the server is shut down. + +package hub + +import ( + "context" + "database/sql" + "os" + + "github.com/gabbla05/KittyProtocol/internal/auth" + "github.com/gabbla05/KittyProtocol/internal/certmanager" + "github.com/gabbla05/KittyProtocol/internal/protection" + _ "github.com/lib/pq" + "github.com/quic-go/quic-go" +) + +// globalSessions manages all active Hub sessions. +// It is initialized once at startup and shared across handlers. +var globalSessions = protection.NewSessionManager() + +// globalAuth provides authentication backend for the Hub. +// It is replaced during startup depending on configuration (mock or DB). +var globalAuth auth.AuthProvider + +// Start initializes the KittyProtocol Hub server, configures TLS, QUIC, +// authentication backend, and begins accepting incoming client connections. +func Start() { + loadEnv() + + // Load TLS certificates for QUIC transport security. + tlsConf, err := certmanager.SetupTLSConfig("certs/cert.pem", "certs/key.pem") + if err != nil { + logError("Failed to load TLS certificates: %v", err) + return + } + + // Initialize authentication backend (PostgreSQL). + dsn := os.Getenv("KITTY_DB_DSN") + if dsn == "" { + dsn = defaultDSN + } + + db, err := sql.Open("postgres", dsn) + if err != nil { + logError("DB connection failed: %v", err) + return + } + defer db.Close() + + globalAuth = auth.NewDBAuth(db) + logInfo("Using authentication backend with DSN: %s", dsn) + + // Configure QUIC transport parameters. + quicConf := &quic.Config{ + MaxIdleTimeout: quicMaxIdleTimeout, + KeepAlivePeriod: quicKeepAlivePeriod, + Allow0RTT: quicAllow0RTT, + DisablePathMTUDiscovery: quicDisablePMTU, + } + + // Determine listening address. + addr := os.Getenv("KITTY_INTERCEPT_ADDR") + if addr == "" { + addr = defaultHubAddress + } + + // Start QUIC listener. + listener, err := quic.ListenAddr(addr, tlsConf, quicConf) + if err != nil { + logError("Failed to start listener: %v", err) + return + } + + logInfo("🐈 KittyProtocol Hub listening on %s", addr) + + // Handle SIGINT/SIGTERM for graceful shutdown. + setupSignalHandler(listener) + + // Accept incoming QUIC connections. + for { + conn, err := listener.Accept(context.Background()) + if err != nil { + logError("Accept error: %v", err) + return + } + + // Each client is handled in its own goroutine. + go handleClient(conn) + } +} diff --git a/protocol/errors.go b/protocol/errors.go new file mode 100644 index 0000000..8ba8726 --- /dev/null +++ b/protocol/errors.go @@ -0,0 +1,70 @@ +package protocol + +// Error codes defined by the KittyProtocol specification. +// These MUST remain stable across versions, as both Hub and Clients rely on them. +// +// Each error code corresponds to a transport‑level or protocol‑level failure. +// Application‑level errors SHOULD reuse these codes and place human‑readable +// details in the Desc field of ErrorFrame. + +const ( + // ERR_01 — Protocol Violation + // Example: sending DATA before AUTH. + ErrProtocolViolation = "ERR_01" + + // ERR_02 — Format Error + // Invalid JSON, missing type/msg_id, wrong types. + ErrFormatError = "ERR_02" + + // ERR_03 — Authorization Timeout + // No AUTH received within 20 seconds after HELLO. + ErrAuthorizationTimeout = "ERR_03" + + // ERR_04 — Authentication Failed + // Wrong username or password. + ErrAuthenticationFailed = "ERR_04" + + // ERR_05 — Session Error + // Undefined session error; client should re‑authenticate. + ErrSessionError = "ERR_05" + + // ERR_06 — Replay Detected + // Reuse of msg_id. + ErrReplayDetected = "ERR_06" + + // ERR_07 — Rate Limit Exceeded + // >10 messages/s or >100/min. + ErrRateLimitExceeded = "ERR_07" + + // ERR_08 — Delivery Failed – Recipient Offline + // Target user is not online. + ErrDeliveryFailedOffline = "ERR_08" + + // ERR_09 — Session Timeout (Idle) + // No activity for 60 seconds. + ErrSessionTimeoutIdle = "ERR_09" + + // ERR_10 — Resource Exhaustion + // Hub overloaded; retry with jitter. + ErrResourceExhaustion = "ERR_10" + + // ERR_11 — Internal Server Error + // Hub internal failure (e.g., DB offline). + ErrInternalServerError = "ERR_11" + + // ERR_12 — Version Mismatch + // Unsupported protocol version. + ErrVersionMismatch = "ERR_12" + + // ERR_13 — Payload Too Large + // Payload > 2048 bytes. + ErrPayloadTooLarge = "ERR_13" + + // ERR_14 — Not Authorized + // User lacks permission for the action. + ErrNotAuthorized = "ERR_14" + + // ERR_15 — Unknown Target + // Example: GET_KEY for non‑existent user. + ErrUnknownTarget = "ERR_15" +) From efab7f37045e1a2ad3b6ccfec2df41acc9c19053 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Brzezi=C5=84ski?= Date: Sun, 24 May 2026 15:13:12 +0000 Subject: [PATCH 18/44] Happy path and negative tests corrected. Router fix regarding documentation and project assumptions --- hub/happy_path_test.go | 146 ++++++++++++++++++++++++++--------------- hub/negative_test.go | 51 +++++++++----- hub/router.go | 5 +- 3 files changed, 131 insertions(+), 71 deletions(-) diff --git a/hub/happy_path_test.go b/hub/happy_path_test.go index 26d89fb..2d91d8f 100644 --- a/hub/happy_path_test.go +++ b/hub/happy_path_test.go @@ -8,34 +8,49 @@ import ( "testing" "time" + "github.com/gabbla05/KittyProtocol/internal/auth" "github.com/gabbla05/KittyProtocol/internal/certmanager" "github.com/gabbla05/KittyProtocol/internal/protection" "github.com/gabbla05/KittyProtocol/protocol" "github.com/quic-go/quic-go" ) +// TestHappyPathE2E verifies the full end‑to‑end flow of the KittyProtocol Hub: +// 1. HELLO → MEOW_OK +// 2. AUTH → MEOW_OK +// 3. DATA routing between authenticated users +// 4. ACK delivery confirmation +// +// This test uses a mock authentication backend and an in‑memory QUIC listener. +// It does NOT test TLS correctness — only QUIC transport and protocol logic. func TestHappyPathE2E(t *testing.T) { - // Inicjalizacja globalnej mapy sesji (Task 5 / Task 9) + + // Reset global state (Hub uses package‑level singletons) globalSessions = protection.NewSessionManager() + globalAuth = auth.NewMockAuth() // mock DB with alice/secret, bob/password - // Przygotowanie certyfikatów TLS (Task 32) + // Ensure certs directory exists (relative to project root) err := os.MkdirAll("../certs", 0755) if err != nil { - t.Fatalf("Nie udało się utworzyć folderu certs: %v", err) + t.Fatalf("Failed to create certs directory: %v", err) } + + // Load TLS certificates for QUIC tlsConf, err := certmanager.SetupTLSConfig("../certs/cert.pem", "../certs/key.pem") if err != nil { - t.Fatalf("Błąd konfiguracji TLS: %v", err) + t.Fatalf("TLS setup failed: %v", err) } - // Uruchomienie listenera Huba - listener, err := quic.ListenAddr("127.0.0.1:0", tlsConf, nil) + // Start Hub QUIC listener on random port + listener, err := quic.ListenAddr("127.0.0.1:0", tlsConf, &quic.Config{ + KeepAlivePeriod: 1 * time.Second, + }) if err != nil { - t.Fatalf("Błąd uruchamiania listenera: %v", err) + t.Fatalf("Failed to start listener: %v", err) } defer listener.Close() - // Nasłuchiwanie w tle (Task 4) + // Hub accept loop (simulates Start(), but without TLS/DB/env) go func() { for { conn, err := listener.Accept(context.Background()) @@ -46,112 +61,137 @@ func TestHappyPathE2E(t *testing.T) { } }() + // Client QUIC config clientTLS := &tls.Config{ - InsecureSkipVerify: true, // Akceptowalne dla lokalnego testu integracyjnego + InsecureSkipVerify: true, // acceptable for local integration test NextProtos: []string{"kitty-quic-v1"}, } - // ========================================== - // KROK 1: Podłączenie i logowanie Alice - // ========================================== + // ============================================================ + // 1. ALICE CONNECTS AND AUTHENTICATES + // ============================================================ + aliceConn, err := quic.DialAddr(context.Background(), listener.Addr().String(), clientTLS, nil) if err != nil { - t.Fatalf("Błąd połączenia Alice: %v", err) + t.Fatalf("Alice connection failed: %v", err) } defer aliceConn.CloseWithError(0, "") + aliceStream, err := aliceConn.OpenStreamSync(context.Background()) if err != nil { - t.Fatalf("Błąd strumienia Alice: %v", err) + t.Fatalf("Alice stream failed: %v", err) } - // Alice: HELLO + bufA := make([]byte, 4096) + + // HELLO aliceHello := protocol.HelloFrame{ - BaseFrame: protocol.BaseFrame{Type: "HELLO", MsgID: time.Now().UnixMilli()}, + BaseFrame: protocol.BaseFrame{Type: protocol.FrameTypeHello, MsgID: 1}, Version: "1.0", } b, _ := json.Marshal(aliceHello) aliceStream.Write(b) - bufA := make([]byte, 2048) - aliceStream.Read(bufA) // Odbiór MEOW_OK - // Alice: AUTH + n, _ := aliceStream.Read(bufA) + var helloAck protocol.MeowOkFrame + json.Unmarshal(bufA[:n], &helloAck) + + if helloAck.Type != protocol.FrameTypeMeowOK { + t.Fatalf("Alice expected MEOW_OK after HELLO, got: %s", helloAck.Type) + } + + // AUTH aliceAuth := protocol.AuthFrame{ - BaseFrame: protocol.BaseFrame{Type: "AUTH", MsgID: time.Now().UnixMilli()}, + BaseFrame: protocol.BaseFrame{Type: protocol.FrameTypeAuth, MsgID: 2}, User: "alice", - Pass: "secret", // Hasło z mock DB + Pass: "secret", } b, _ = json.Marshal(aliceAuth) aliceStream.Write(b) - aliceStream.Read(bufA) // Odbiór MEOW_OK("Logged in") - // ========================================== - // KROK 2: Podłączenie i logowanie Boba - // ========================================== + n, _ = aliceStream.Read(bufA) + var authAck protocol.MeowOkFrame + json.Unmarshal(bufA[:n], &authAck) + + if authAck.Status != "Logged in" { + t.Fatalf("Alice AUTH failed, status: %s", authAck.Status) + } + + // ============================================================ + // 2. BOB CONNECTS AND AUTHENTICATES + // ============================================================ + bobConn, err := quic.DialAddr(context.Background(), listener.Addr().String(), clientTLS, nil) if err != nil { - t.Fatalf("Błąd połączenia Boba: %v", err) + t.Fatalf("Bob connection failed: %v", err) } defer bobConn.CloseWithError(0, "") + bobStream, err := bobConn.OpenStreamSync(context.Background()) if err != nil { - t.Fatalf("Błąd strumienia Boba: %v", err) + t.Fatalf("Bob stream failed: %v", err) } - // Bob: HELLO + bufB := make([]byte, 4096) + + // HELLO bobHello := protocol.HelloFrame{ - BaseFrame: protocol.BaseFrame{Type: "HELLO", MsgID: time.Now().UnixMilli()}, + BaseFrame: protocol.BaseFrame{Type: protocol.FrameTypeHello, MsgID: 3}, Version: "1.0", } b, _ = json.Marshal(bobHello) bobStream.Write(b) - bufB := make([]byte, 2048) bobStream.Read(bufB) - // Bob: AUTH + // AUTH bobAuth := protocol.AuthFrame{ - BaseFrame: protocol.BaseFrame{Type: "AUTH", MsgID: time.Now().UnixMilli()}, + BaseFrame: protocol.BaseFrame{Type: protocol.FrameTypeAuth, MsgID: 4}, User: "bob", - Pass: "password", // Hasło z mock DB + Pass: "password", } b, _ = json.Marshal(bobAuth) bobStream.Write(b) - bobStream.Read(bufB) // Odbiór MEOW_OK("Logged in") + bobStream.Read(bufB) + + // ============================================================ + // 3. ALICE SENDS DATA → BOB + // ============================================================ - // ========================================== - // KROK 3: Alice wysyła DATA do Boba - // ========================================== msgID := time.Now().UnixMilli() dataFrame := protocol.DataFrame{ - BaseFrame: protocol.BaseFrame{Type: "DATA", MsgID: msgID}, + BaseFrame: protocol.BaseFrame{Type: protocol.FrameTypeData, MsgID: msgID}, Target: "bob", - Payload: "SGVsbG8gQm9iIQ==", // Zakodowane Base64 (np. z E2EE) + Payload: "SGVsbG8gQm9iIQ==", MAC: "dummy_mac_123", } b, _ = json.Marshal(dataFrame) aliceStream.Write(b) - // Alice powinna dostać MEOW_OK ("Delivered (mock)") od Huba - nA, _ := aliceStream.Read(bufA) + // Alice receives ACK + n, _ = aliceStream.Read(bufA) var aliceAck protocol.MeowOkFrame - json.Unmarshal(bufA[:nA], &aliceAck) + json.Unmarshal(bufA[:n], &aliceAck) - if aliceAck.Type != "MEOW_OK" { - t.Errorf("Alice oczekiwała MEOW_OK, otrzymała typ: %s", aliceAck.Type) + if aliceAck.Status != "Delivered" { + t.Fatalf("Alice expected Delivered ACK, got: %s", aliceAck.Status) } - // ========================================== - // KROK 4: Bob odbiera zroutowaną wiadomość - // ========================================== - nB, err := bobStream.Read(bufB) + // ============================================================ + // 4. BOB RECEIVES FORWARDED DATA + // ============================================================ + + n, err = bobStream.Read(bufB) if err != nil { - t.Fatalf("Bob nie odczytał zroutowanej wiadomości: %v", err) + t.Fatalf("Bob failed to read DATA: %v", err) } var bobRecv protocol.DataFrame - json.Unmarshal(bufB[:nB], &bobRecv) + json.Unmarshal(bufB[:n], &bobRecv) - // Hub podczas routingu powinien dokleić pole Sender (Task 7) - if bobRecv.Type != "DATA" || bobRecv.Sender != "alice" || bobRecv.Payload != "SGVsbG8gQm9iIQ==" { - t.Errorf("Bob otrzymał niepoprawną ramkę: %s", string(bufB[:nB])) + if bobRecv.Sender != "alice" { + t.Fatalf("Bob expected Sender=alice, got: %s", bobRecv.Sender) + } + if bobRecv.Payload != "SGVsbG8gQm9iIQ==" { + t.Fatalf("Bob received wrong payload") } } diff --git a/hub/negative_test.go b/hub/negative_test.go index 75726b7..441ef8c 100644 --- a/hub/negative_test.go +++ b/hub/negative_test.go @@ -7,36 +7,42 @@ import ( "io" "os" "testing" - "time" + "github.com/gabbla05/KittyProtocol/internal/auth" "github.com/gabbla05/KittyProtocol/internal/certmanager" "github.com/gabbla05/KittyProtocol/internal/protection" "github.com/gabbla05/KittyProtocol/protocol" "github.com/quic-go/quic-go" ) +// TestNegativeScenarios verifies that the Hub correctly returns protocol‑level +// error frames (ERR_XX) for invalid authentication and invalid DATA routing. +// This test uses a mock authentication backend and an in‑memory QUIC listener. func TestNegativeScenarios(t *testing.T) { - // Inicjalizacja globalnej mapy sesji + + // Reset global state globalSessions = protection.NewSessionManager() + globalAuth = auth.NewMockAuth() // mock DB: alice/secret, bob/password - // Przygotowanie certyfikatów + // Prepare TLS certs (relative to project root) err := os.MkdirAll("../certs", 0755) if err != nil { t.Fatalf("Nie udało się utworzyć folderu certs: %v", err) } + tlsConf, err := certmanager.SetupTLSConfig("../certs/cert.pem", "../certs/key.pem") if err != nil { t.Fatalf("Błąd konfiguracji TLS: %v", err) } - // Uruchomienie listenera + // Start Hub listener listener, err := quic.ListenAddr("127.0.0.1:0", tlsConf, nil) if err != nil { t.Fatalf("Błąd uruchamiania listenera: %v", err) } defer listener.Close() - // Nasłuchiwanie + // Accept loop go func() { for { conn, err := listener.Accept(context.Background()) @@ -52,7 +58,11 @@ func TestNegativeScenarios(t *testing.T) { NextProtos: []string{"kitty-quic-v1"}, } + // ============================================================ + // ERR_04 — Authentication Failed (wrong password) + // ============================================================ t.Run("ERR_04_BadPassword", func(t *testing.T) { + conn, err := quic.DialAddr(context.Background(), listener.Addr().String(), clientTLS, nil) if err != nil { t.Fatalf("Błąd połączenia (Dial): %v", err) @@ -64,25 +74,26 @@ func TestNegativeScenarios(t *testing.T) { t.Fatalf("Błąd otwarcia strumienia: %v", err) } + // HELLO hello := protocol.HelloFrame{ - BaseFrame: protocol.BaseFrame{Type: "HELLO", MsgID: time.Now().UnixMilli()}, + BaseFrame: protocol.BaseFrame{Type: protocol.FrameTypeHello, MsgID: 1}, Version: "1.0", } hb, _ := json.Marshal(hello) stream.Write(hb) buf := make([]byte, 1024) - stream.Read(buf) + stream.Read(buf) // MEOW_OK + // AUTH (wrong password) authFrame := protocol.AuthFrame{ - BaseFrame: protocol.BaseFrame{Type: "AUTH", MsgID: time.Now().UnixMilli()}, + BaseFrame: protocol.BaseFrame{Type: protocol.FrameTypeAuth, MsgID: 2}, User: "alice", Pass: "wrongpassword", } ab, _ := json.Marshal(authFrame) stream.Write(ab) - // Poprawka dla EOF n, err := stream.Read(buf) if err != nil && err != io.EOF { t.Fatalf("Błąd odczytu odpowiedzi: %v", err) @@ -94,12 +105,16 @@ func TestNegativeScenarios(t *testing.T) { var errResp protocol.ErrorFrame json.Unmarshal(buf[:n], &errResp) - if errResp.Code != "ERR_04" { + if errResp.Code != protocol.ErrAuthenticationFailed { t.Errorf("Oczekiwano ERR_04, otrzymano: %s", errResp.Code) } }) - t.Run("ERR_15_UserOffline", func(t *testing.T) { + // ============================================================ + // ERR_15 — Unknown Target (user does not exist) + // ============================================================ + t.Run("ERR_15_UnknownTarget", func(t *testing.T) { + conn, err := quic.DialAddr(context.Background(), listener.Addr().String(), clientTLS, nil) if err != nil { t.Fatalf("Błąd połączenia (Dial): %v", err) @@ -111,17 +126,20 @@ func TestNegativeScenarios(t *testing.T) { t.Fatalf("Błąd otwarcia strumienia: %v", err) } + buf := make([]byte, 1024) + + // HELLO hello := protocol.HelloFrame{ - BaseFrame: protocol.BaseFrame{Type: "HELLO", MsgID: time.Now().UnixMilli()}, + BaseFrame: protocol.BaseFrame{Type: protocol.FrameTypeHello, MsgID: 1}, Version: "1.0", } hb, _ := json.Marshal(hello) stream.Write(hb) - buf := make([]byte, 1024) stream.Read(buf) + // AUTH (correct) authFrame := protocol.AuthFrame{ - BaseFrame: protocol.BaseFrame{Type: "AUTH", MsgID: time.Now().UnixMilli()}, + BaseFrame: protocol.BaseFrame{Type: protocol.FrameTypeAuth, MsgID: 2}, User: "alice", Pass: "secret", } @@ -129,8 +147,9 @@ func TestNegativeScenarios(t *testing.T) { stream.Write(ab) stream.Read(buf) + // DATA → ghostuser (does not exist) dataFrame := protocol.DataFrame{ - BaseFrame: protocol.BaseFrame{Type: "DATA", MsgID: time.Now().UnixMilli()}, + BaseFrame: protocol.BaseFrame{Type: protocol.FrameTypeData, MsgID: 3}, Target: "ghostuser", Payload: "SGVsbG8=", MAC: "dummyMAC", @@ -146,7 +165,7 @@ func TestNegativeScenarios(t *testing.T) { var errResp protocol.ErrorFrame json.Unmarshal(buf[:n], &errResp) - if errResp.Code != "ERR_15" { + if errResp.Code != protocol.ErrUnknownTarget { t.Errorf("Oczekiwano ERR_15, otrzymano: %s", errResp.Code) } }) diff --git a/hub/router.go b/hub/router.go index 7cc029d..acf4999 100644 --- a/hub/router.go +++ b/hub/router.go @@ -17,9 +17,10 @@ import ( // Returns true on success, false if delivery failed. func routeData(frame protocol.DataFrame, sender *protection.Session, senderStream *quic.Stream) bool { targetSess, ok := globalSessions.Get(frame.Target) + // router.go — poprawiony fragment if !ok { - // ERR_08 — Delivery Failed – Recipient Offline - sendError(senderStream, protocol.ErrDeliveryFailedOffline, "Receiver offline") + // ERR_15 — Unknown Target + sendError(senderStream, protocol.ErrUnknownTarget, "Unknown target user") return false } From 2395b6558c6aebac5da4d2478e7a7c192d097b29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Brzezi=C5=84ski?= Date: Sun, 24 May 2026 23:18:02 +0000 Subject: [PATCH 19/44] hub full production version - benchamrk test and e2e test corrected, session changed to handle fakestream --- TODO | 4 +- hub/constants.go | 11 ++ hub/context.go | 2 +- hub/errors.go | 4 +- hub/fake_stream.go | 16 +++ hub/handlers_test.go | 127 ++++++++++++++++++++ hub/happy_path_test.go | 56 ++------- hub/negative_test.go | 110 ++++++++--------- hub/performance_test.go | 211 ++++++++++++++++----------------- hub/router.go | 3 +- hub/router_test.go | 89 ++++++++++++++ hub/security_test.go | 144 +++++++++++----------- hub/start_test-hub.go | 69 +++++++++++ hub/stream_adapter.go | 14 +++ internal/protection/session.go | 4 +- internal/protection/stream.go | 9 ++ markdowns/INFO_bechmark.md | 39 ++++++ markdowns/benchmark_history.md | 89 +++++++++++++- 18 files changed, 714 insertions(+), 287 deletions(-) create mode 100644 hub/fake_stream.go create mode 100644 hub/handlers_test.go create mode 100644 hub/router_test.go create mode 100644 hub/start_test-hub.go create mode 100644 hub/stream_adapter.go create mode 100644 internal/protection/stream.go create mode 100644 markdowns/INFO_bechmark.md diff --git a/TODO b/TODO index cefb987..0639a73 100644 --- a/TODO +++ b/TODO @@ -17,4 +17,6 @@ - Uważać czy wszędzie gdzie trzeba jest: _ "github.com/lib/pq" !!!!! -- \ No newline at end of file +- przeneisienie uruchomienia klienta do cmd +- możliwość usunięcia konta poprzez /delete lub /remove z opcją \ i wtedy powinno poprosić o uwierzytelnienie (wpisanie hasła do logowania w ramach zatwerdzenia). Po zakończeniu operacji powinno wyjść jak za pomocą /quit + usunąć rekord z userem z bazy danych +- update cross-networkowych rzeczy (przetestowanie i markdown update) \ No newline at end of file diff --git a/hub/constants.go b/hub/constants.go index 6838677..da1dc19 100644 --- a/hub/constants.go +++ b/hub/constants.go @@ -6,6 +6,17 @@ import "time" // These values complement protocol-level constants defined in protocol/error_codes.go. // They MUST remain consistent with the protocol documentation and the protection package. +//=============================================================================== +// NOTE: | +// These constants are part of the KittyProtocol specification. | +// They may not be referenced directly inside the Hub because their enforcement | +// happens in the protection package (rate limiting, replay, idle timeout) | +// or on the client side (payload size). | +// | +// DO NOT REMOVE — they are required for protocol stability, documentation, | +// and future compatibility with clients and tests. | +// ============================================================================== + // Maximum allowed payload size for DATA frames. // If exceeded, the Hub must return ERR_13 (Payload Too Large). const maxPayloadBytes = 2048 diff --git a/hub/context.go b/hub/context.go index 5a82d9b..2e3e429 100644 --- a/hub/context.go +++ b/hub/context.go @@ -31,7 +31,7 @@ const ( // It is created in dispatcher.go when a new QUIC stream is accepted. type clientContext struct { conn *quic.Conn // Underlying QUIC connection - stream *quic.Stream // Primary bidirectional stream + stream protection.Stream // Primary bidirectional stream session *protection.Session // Active session after AUTH username string // Set only after successful AUTH state connectionState // Protocol handshake state machine diff --git a/hub/errors.go b/hub/errors.go index f2082d5..042c1e4 100644 --- a/hub/errors.go +++ b/hub/errors.go @@ -8,14 +8,14 @@ import ( "encoding/json" "time" + "github.com/gabbla05/KittyProtocol/internal/protection" "github.com/gabbla05/KittyProtocol/protocol" - "github.com/quic-go/quic-go" ) // sendError sends a standardized ERROR frame to the client. // Serialization or write failures are logged but do not panic. // This function MUST be used by all handlers to ensure consistent error reporting. -func sendError(stream *quic.Stream, code, desc string) { +func sendError(stream protection.Stream, code, desc string) { errFrame := protocol.ErrorFrame{ BaseFrame: protocol.BaseFrame{ Type: protocol.FrameTypeError, diff --git a/hub/fake_stream.go b/hub/fake_stream.go new file mode 100644 index 0000000..85a3b9a --- /dev/null +++ b/hub/fake_stream.go @@ -0,0 +1,16 @@ +package hub + +import "io" + +// Minimal fake stream implementing protection.Stream +type fakeStream struct { + written [][]byte +} + +func (f *fakeStream) Write(b []byte) (int, error) { + f.written = append(f.written, append([]byte(nil), b...)) + return len(b), nil +} + +func (f *fakeStream) Read(b []byte) (int, error) { return 0, io.EOF } +func (f *fakeStream) Close() error { return nil } diff --git a/hub/handlers_test.go b/hub/handlers_test.go new file mode 100644 index 0000000..8e0d4e0 --- /dev/null +++ b/hub/handlers_test.go @@ -0,0 +1,127 @@ +package hub + +import ( + "encoding/json" + "testing" + + "github.com/gabbla05/KittyProtocol/internal/auth" + "github.com/gabbla05/KittyProtocol/internal/protection" + "github.com/gabbla05/KittyProtocol/protocol" +) + +// --- HELLO tests --- + +func TestHandleHello(t *testing.T) { + // We must provide a fake stream, otherwise handleHello will panic + c := &clientContext{ + state: stateInit, + stream: &fakeStream{}, + } + + frame := protocol.HelloFrame{ + BaseFrame: protocol.BaseFrame{Type: protocol.FrameTypeHello, MsgID: 1}, + Version: "1.0", + } + raw, _ := json.Marshal(frame) + + c.handleHello(raw) + + if c.state != stateHelloReceived { + t.Fatalf("HELLO should transition to stateHelloReceived") + } +} + +// --- AUTH tests --- + +func TestHandleAuthBeforeHello(t *testing.T) { + globalAuth = auth.NewMockAuth() + + c := &clientContext{ + state: stateInit, + stream: &fakeStream{}, + } + + frame := protocol.AuthFrame{ + BaseFrame: protocol.BaseFrame{Type: protocol.FrameTypeAuth, MsgID: 1}, + User: "alice", + Pass: "secret", + } + raw, _ := json.Marshal(frame) + + c.handleAuth(raw) + + if c.state != stateInit { + t.Fatalf("AUTH before HELLO should not change state") + } +} + +func TestHandleAuthWrongPassword(t *testing.T) { + c := &clientContext{ + state: stateHelloReceived, + stream: &fakeStream{}, + } + + globalAuth = auth.NewMockAuth() + + frame := protocol.AuthFrame{ + BaseFrame: protocol.BaseFrame{ + Type: protocol.FrameTypeAuth, + MsgID: 1, + }, + User: "alice", + Pass: "wrong", + } + + raw, _ := json.Marshal(frame) + + c.handleAuth(raw) + + if c.state != stateHelloReceived { + t.Fatalf("AUTH with wrong password should not change state") + } +} + +func TestHandleAuthSuccess(t *testing.T) { + globalAuth = auth.NewMockAuth() + globalSessions = protection.NewSessionManager() + + c := &clientContext{ + state: stateHelloReceived, + stream: &fakeStream{}, + } + + frame := protocol.AuthFrame{ + BaseFrame: protocol.BaseFrame{Type: protocol.FrameTypeAuth, MsgID: 1}, + User: "alice", + Pass: "secret", + } + raw, _ := json.Marshal(frame) + + c.handleAuth(raw) + + if c.state != stateAuthenticated { + t.Fatalf("AUTH success should transition to stateAuthenticated") + } +} + +// --- DATA tests --- + +func TestHandleDataBeforeAuth(t *testing.T) { + c := &clientContext{ + state: stateHelloReceived, + stream: &fakeStream{}, + } + + frame := protocol.DataFrame{ + BaseFrame: protocol.BaseFrame{Type: protocol.FrameTypeData, MsgID: 1}, + Target: "bob", + Payload: "abc", + } + raw, _ := json.Marshal(frame) + + c.handleData(raw) + + if c.state != stateHelloReceived { + t.Fatalf("DATA before AUTH should not change state") + } +} diff --git a/hub/happy_path_test.go b/hub/happy_path_test.go index 2d91d8f..4727fbe 100644 --- a/hub/happy_path_test.go +++ b/hub/happy_path_test.go @@ -4,13 +4,9 @@ import ( "context" "crypto/tls" "encoding/json" - "os" "testing" "time" - "github.com/gabbla05/KittyProtocol/internal/auth" - "github.com/gabbla05/KittyProtocol/internal/certmanager" - "github.com/gabbla05/KittyProtocol/internal/protection" "github.com/gabbla05/KittyProtocol/protocol" "github.com/quic-go/quic-go" ) @@ -18,52 +14,22 @@ import ( // TestHappyPathE2E verifies the full end‑to‑end flow of the KittyProtocol Hub: // 1. HELLO → MEOW_OK // 2. AUTH → MEOW_OK -// 3. DATA routing between authenticated users -// 4. ACK delivery confirmation +// 3. DATA routing from Alice → Bob +// 4. ACK confirmation back to Alice // -// This test uses a mock authentication backend and an in‑memory QUIC listener. -// It does NOT test TLS correctness — only QUIC transport and protocol logic. +// This test runs against an isolated Hub instance created by StartTestHub(), +// ensuring no interference with global state or other tests. func TestHappyPathE2E(t *testing.T) { - - // Reset global state (Hub uses package‑level singletons) - globalSessions = protection.NewSessionManager() - globalAuth = auth.NewMockAuth() // mock DB with alice/secret, bob/password - - // Ensure certs directory exists (relative to project root) - err := os.MkdirAll("../certs", 0755) - if err != nil { - t.Fatalf("Failed to create certs directory: %v", err) - } - - // Load TLS certificates for QUIC - tlsConf, err := certmanager.SetupTLSConfig("../certs/cert.pem", "../certs/key.pem") + // Start isolated Hub instance + addr, stop, err := StartTestHub() if err != nil { - t.Fatalf("TLS setup failed: %v", err) + t.Fatalf("Failed to start test Hub: %v", err) } - - // Start Hub QUIC listener on random port - listener, err := quic.ListenAddr("127.0.0.1:0", tlsConf, &quic.Config{ - KeepAlivePeriod: 1 * time.Second, - }) - if err != nil { - t.Fatalf("Failed to start listener: %v", err) - } - defer listener.Close() - - // Hub accept loop (simulates Start(), but without TLS/DB/env) - go func() { - for { - conn, err := listener.Accept(context.Background()) - if err != nil { - return - } - go handleClient(conn) - } - }() + defer stop() // Client QUIC config clientTLS := &tls.Config{ - InsecureSkipVerify: true, // acceptable for local integration test + InsecureSkipVerify: true, NextProtos: []string{"kitty-quic-v1"}, } @@ -71,7 +37,7 @@ func TestHappyPathE2E(t *testing.T) { // 1. ALICE CONNECTS AND AUTHENTICATES // ============================================================ - aliceConn, err := quic.DialAddr(context.Background(), listener.Addr().String(), clientTLS, nil) + aliceConn, err := quic.DialAddr(context.Background(), addr, clientTLS, nil) if err != nil { t.Fatalf("Alice connection failed: %v", err) } @@ -121,7 +87,7 @@ func TestHappyPathE2E(t *testing.T) { // 2. BOB CONNECTS AND AUTHENTICATES // ============================================================ - bobConn, err := quic.DialAddr(context.Background(), listener.Addr().String(), clientTLS, nil) + bobConn, err := quic.DialAddr(context.Background(), addr, clientTLS, nil) if err != nil { t.Fatalf("Bob connection failed: %v", err) } diff --git a/hub/negative_test.go b/hub/negative_test.go index 441ef8c..d71cb48 100644 --- a/hub/negative_test.go +++ b/hub/negative_test.go @@ -5,53 +5,24 @@ import ( "crypto/tls" "encoding/json" "io" - "os" "testing" - "github.com/gabbla05/KittyProtocol/internal/auth" - "github.com/gabbla05/KittyProtocol/internal/certmanager" - "github.com/gabbla05/KittyProtocol/internal/protection" "github.com/gabbla05/KittyProtocol/protocol" "github.com/quic-go/quic-go" ) // TestNegativeScenarios verifies that the Hub correctly returns protocol‑level // error frames (ERR_XX) for invalid authentication and invalid DATA routing. -// This test uses a mock authentication backend and an in‑memory QUIC listener. +// +// This test runs against an isolated Hub instance created by StartTestHub(), +// ensuring no interference with global state or other tests. func TestNegativeScenarios(t *testing.T) { - - // Reset global state - globalSessions = protection.NewSessionManager() - globalAuth = auth.NewMockAuth() // mock DB: alice/secret, bob/password - - // Prepare TLS certs (relative to project root) - err := os.MkdirAll("../certs", 0755) - if err != nil { - t.Fatalf("Nie udało się utworzyć folderu certs: %v", err) - } - - tlsConf, err := certmanager.SetupTLSConfig("../certs/cert.pem", "../certs/key.pem") - if err != nil { - t.Fatalf("Błąd konfiguracji TLS: %v", err) - } - - // Start Hub listener - listener, err := quic.ListenAddr("127.0.0.1:0", tlsConf, nil) + // Start isolated Hub instance + addr, stop, err := StartTestHub() if err != nil { - t.Fatalf("Błąd uruchamiania listenera: %v", err) + t.Fatalf("Failed to start test Hub: %v", err) } - defer listener.Close() - - // Accept loop - go func() { - for { - conn, err := listener.Accept(context.Background()) - if err != nil { - return - } - go handleClient(conn) - } - }() + defer stop() clientTLS := &tls.Config{ InsecureSkipVerify: true, @@ -62,16 +33,15 @@ func TestNegativeScenarios(t *testing.T) { // ERR_04 — Authentication Failed (wrong password) // ============================================================ t.Run("ERR_04_BadPassword", func(t *testing.T) { - - conn, err := quic.DialAddr(context.Background(), listener.Addr().String(), clientTLS, nil) + conn, err := quic.DialAddr(context.Background(), addr, clientTLS, nil) if err != nil { - t.Fatalf("Błąd połączenia (Dial): %v", err) + t.Fatalf("Connection error (Dial): %v", err) } defer conn.CloseWithError(0, "") stream, err := conn.OpenStreamSync(context.Background()) if err != nil { - t.Fatalf("Błąd otwarcia strumienia: %v", err) + t.Fatalf("Stream open error: %v", err) } // HELLO @@ -80,10 +50,14 @@ func TestNegativeScenarios(t *testing.T) { Version: "1.0", } hb, _ := json.Marshal(hello) - stream.Write(hb) + if _, err := stream.Write(hb); err != nil { + t.Fatalf("HELLO write error: %v", err) + } buf := make([]byte, 1024) - stream.Read(buf) // MEOW_OK + if _, err := stream.Read(buf); err != nil && err != io.EOF { + t.Fatalf("HELLO response read error: %v", err) + } // AUTH (wrong password) authFrame := protocol.AuthFrame{ @@ -92,21 +66,25 @@ func TestNegativeScenarios(t *testing.T) { Pass: "wrongpassword", } ab, _ := json.Marshal(authFrame) - stream.Write(ab) + if _, err := stream.Write(ab); err != nil { + t.Fatalf("AUTH write error: %v", err) + } n, err := stream.Read(buf) if err != nil && err != io.EOF { - t.Fatalf("Błąd odczytu odpowiedzi: %v", err) + t.Fatalf("AUTH response read error: %v", err) } if n == 0 { - t.Fatalf("Otrzymano EOF bez danych") + t.Fatalf("Received EOF without data") } var errResp protocol.ErrorFrame - json.Unmarshal(buf[:n], &errResp) + if err := json.Unmarshal(buf[:n], &errResp); err != nil { + t.Fatalf("Failed to unmarshal ERROR frame: %v", err) + } if errResp.Code != protocol.ErrAuthenticationFailed { - t.Errorf("Oczekiwano ERR_04, otrzymano: %s", errResp.Code) + t.Errorf("Expected ERR_04, got: %s", errResp.Code) } }) @@ -114,16 +92,15 @@ func TestNegativeScenarios(t *testing.T) { // ERR_15 — Unknown Target (user does not exist) // ============================================================ t.Run("ERR_15_UnknownTarget", func(t *testing.T) { - - conn, err := quic.DialAddr(context.Background(), listener.Addr().String(), clientTLS, nil) + conn, err := quic.DialAddr(context.Background(), addr, clientTLS, nil) if err != nil { - t.Fatalf("Błąd połączenia (Dial): %v", err) + t.Fatalf("Connection error (Dial): %v", err) } defer conn.CloseWithError(0, "") stream, err := conn.OpenStreamSync(context.Background()) if err != nil { - t.Fatalf("Błąd otwarcia strumienia: %v", err) + t.Fatalf("Stream open error: %v", err) } buf := make([]byte, 1024) @@ -134,8 +111,12 @@ func TestNegativeScenarios(t *testing.T) { Version: "1.0", } hb, _ := json.Marshal(hello) - stream.Write(hb) - stream.Read(buf) + if _, err := stream.Write(hb); err != nil { + t.Fatalf("HELLO write error: %v", err) + } + if _, err := stream.Read(buf); err != nil && err != io.EOF { + t.Fatalf("HELLO response read error: %v", err) + } // AUTH (correct) authFrame := protocol.AuthFrame{ @@ -144,8 +125,12 @@ func TestNegativeScenarios(t *testing.T) { Pass: "secret", } ab, _ := json.Marshal(authFrame) - stream.Write(ab) - stream.Read(buf) + if _, err := stream.Write(ab); err != nil { + t.Fatalf("AUTH write error: %v", err) + } + if _, err := stream.Read(buf); err != nil && err != io.EOF { + t.Fatalf("AUTH response read error: %v", err) + } // DATA → ghostuser (does not exist) dataFrame := protocol.DataFrame{ @@ -155,18 +140,25 @@ func TestNegativeScenarios(t *testing.T) { MAC: "dummyMAC", } db, _ := json.Marshal(dataFrame) - stream.Write(db) + if _, err := stream.Write(db); err != nil { + t.Fatalf("DATA write error: %v", err) + } n, err := stream.Read(buf) if err != nil && err != io.EOF { - t.Fatalf("Błąd odczytu odpowiedzi: %v", err) + t.Fatalf("DATA response read error: %v", err) + } + if n == 0 { + t.Fatalf("Received EOF without data") } var errResp protocol.ErrorFrame - json.Unmarshal(buf[:n], &errResp) + if err := json.Unmarshal(buf[:n], &errResp); err != nil { + t.Fatalf("Failed to unmarshal ERROR frame: %v", err) + } if errResp.Code != protocol.ErrUnknownTarget { - t.Errorf("Oczekiwano ERR_15, otrzymano: %s", errResp.Code) + t.Errorf("Expected ERR_15, got: %s", errResp.Code) } }) } diff --git a/hub/performance_test.go b/hub/performance_test.go index fe5e418..e2c1c13 100644 --- a/hub/performance_test.go +++ b/hub/performance_test.go @@ -18,16 +18,13 @@ import ( "github.com/quic-go/quic-go" ) -// GLOBALNY LICZNIK PAKIETÓW - Gwarantuje brak kolizji z Anti-Replay var globalMsgID int64 = time.Now().UnixNano() func BenchmarkHubRouting(b *testing.B) { cores := []int{1, 2, 4, 8, 16} maxCores := runtime.NumCPU() - hostname, err := os.Hostname() - if err != nil { - hostname = "Unknown-PC" - } + + hostname, _ := os.Hostname() for _, c := range cores { if c > maxCores { @@ -35,23 +32,19 @@ func BenchmarkHubRouting(b *testing.B) { } b.Run(fmt.Sprintf("Cores_%d", c), func(b *testing.B) { - // Zapisujemy poprzednie ustawienia i przywracamy je po teście, - // żeby framework testowy Go nie rzucał błędem "left GOMAXPROCS". - oldProcs := runtime.GOMAXPROCS(c) - defer runtime.GOMAXPROCS(oldProcs) - - // 1. Czysty stan sesji dla każdej fazy benchmarku - if globalSessions != nil { - globalSessions.Remove("alice") - globalSessions.Remove("bob") - } else { + old := runtime.GOMAXPROCS(c) + defer runtime.GOMAXPROCS(old) + + if globalSessions == nil { globalSessions = protection.NewSessionManager() } _ = os.MkdirAll("../certs", 0755) tlsConf, _ := certmanager.SetupTLSConfig("../certs/cert.pem", "../certs/key.pem") - listener, err := quic.ListenAddr("127.0.0.1:0", tlsConf, nil) + listener, err := quic.ListenAddr("127.0.0.1:0", tlsConf, &quic.Config{ + MaxIdleTimeout: 2 * time.Second, + }) if err != nil { b.Fatalf("listen error: %v", err) } @@ -75,132 +68,132 @@ func BenchmarkHubRouting(b *testing.B) { NextProtos: []string{"kitty-quic-v1"}, } - // 2. PODŁĄCZENIE ALICE - aliceConn, _ := quic.DialAddr(context.Background(), listener.Addr().String(), clientTLS, nil) - defer aliceConn.CloseWithError(0, "") - aliceStream, _ := aliceConn.OpenStreamSync(context.Background()) + // --- PARAMETRY OBCIĄŻENIA --- + numClients := 50 // liczba par Alice/Bob + msgsPerClient := 200 // ile wiadomości wysyła jedna Alice + totalOps := numClients * msgsPerClient - atomic.AddInt64(&globalMsgID, 1) - authAlice := protocol.AuthFrame{ - BaseFrame: protocol.BaseFrame{Type: "AUTH", MsgID: globalMsgID}, - User: "alice", - Pass: "secret", - } - ab, _ := json.Marshal(authAlice) - aliceStream.Write(append(ab, '\n')) - buf := make([]byte, 1024) - aliceStream.Read(buf) - - // 3. PODŁĄCZENIE BOBA - bobConn, _ := quic.DialAddr(context.Background(), listener.Addr().String(), clientTLS, nil) - defer bobConn.CloseWithError(0, "") - bobStream, _ := bobConn.OpenStreamSync(context.Background()) - - atomic.AddInt64(&globalMsgID, 1) - authBob := protocol.AuthFrame{ - BaseFrame: protocol.BaseFrame{Type: "AUTH", MsgID: globalMsgID}, - User: "bob", - Pass: "password", - } - bb, _ := json.Marshal(authBob) - bobStream.Write(append(bb, '\n')) - bobStream.Read(buf) - - // 4. WYŁĄCZENIE LIMITÓW RUCHU (Rate Limiter) - if aliceSess, ok := globalSessions.Get("alice"); ok { - aliceSess.Limiter = protection.NewRateLimiter(9999999) - } - if bobSess, ok := globalSessions.Get("bob"); ok { - bobSess.Limiter = protection.NewRateLimiter(9999999) - } + b.ResetTimer() + start := time.Now() - dataFrame := protocol.DataFrame{ - BaseFrame: protocol.BaseFrame{Type: "DATA", MsgID: 0}, - Target: "bob", - Payload: "SGVsbG8gQm9iIQ==", - MAC: "dummy_mac", - } + var wg sync.WaitGroup + wg.Add(numClients) - // Bezpieczny timeout (10 sekund), zapobiega wiecznemu wiszeniu testu - bobStream.SetReadDeadline(time.Now().Add(10 * time.Second)) + for clientID := 0; clientID < numClients; clientID++ { + go func(id int) { + defer wg.Done() - b.ResetTimer() - startTime := time.Now() + userAlice := fmt.Sprintf("alice_%d", id) + userBob := fmt.Sprintf("bob_%d", id) - var wg sync.WaitGroup - wg.Add(1) - var fatalError bool + // wyczyść ewentualne stare sesje + globalSessions.Remove(userAlice) + globalSessions.Remove(userBob) - // 5A. KONSUMENT BOBA - go func() { - defer wg.Done() - decoder := json.NewDecoder(bobStream) - - for i := 0; i < b.N; i++ { - var resp map[string]interface{} - if err := decoder.Decode(&resp); err != nil { - // osiągnęliśmy saturację – Hub nie nadąża - break + // ALICE + aliceConn, err := quic.DialAddr(context.Background(), listener.Addr().String(), clientTLS, nil) + if err != nil { + return + } + defer aliceConn.CloseWithError(0, "") + aliceStream, err := aliceConn.OpenStreamSync(context.Background()) + if err != nil { + return } - if t, ok := resp["type"].(string); ok && t == "ERROR" { - // Hub zwrócił błąd – również traktujemy jako saturację - break + atomic.AddInt64(&globalMsgID, 1) + authAlice := protocol.AuthFrame{ + BaseFrame: protocol.BaseFrame{Type: "AUTH", MsgID: globalMsgID}, + User: userAlice, + Pass: "secret", } - } - }() + ab, _ := json.Marshal(authAlice) + aliceStream.Write(append(ab, '\n')) - // 5B. KONSUMENT ALICE (Czyści potwierdzenia w tle) - go func() { - decoder := json.NewDecoder(aliceStream) - for { - var dummy map[string]interface{} - if err := decoder.Decode(&dummy); err != nil { + buf := make([]byte, 1024) + aliceStream.Read(buf) + + // BOB + bobConn, err := quic.DialAddr(context.Background(), listener.Addr().String(), clientTLS, nil) + if err != nil { + return + } + defer bobConn.CloseWithError(0, "") + bobStream, err := bobConn.OpenStreamSync(context.Background()) + if err != nil { return } - } - }() - // 5C. PRODUCENT ALICE - for i := 0; i < b.N; i++ { - if fatalError { - break - } + atomic.AddInt64(&globalMsgID, 1) + authBob := protocol.AuthFrame{ + BaseFrame: protocol.BaseFrame{Type: "AUTH", MsgID: globalMsgID}, + User: userBob, + Pass: "password", + } + bb, _ := json.Marshal(authBob) + bobStream.Write(append(bb, '\n')) + bobStream.Read(buf) - // Używamy bezpiecznego, rosnącego o 1 licznika. Nigdy nie wygeneruje kolizji. - atomic.AddInt64(&globalMsgID, 1) - dataFrame.BaseFrame.MsgID = globalMsgID + // wyłącz rate limiting + if s, ok := globalSessions.Get(userAlice); ok { + s.Limiter = protection.NewRateLimiter(9999999) + } + if s, ok := globalSessions.Get(userBob); ok { + s.Limiter = protection.NewRateLimiter(9999999) + } - mb, _ := json.Marshal(dataFrame) - if _, err := aliceStream.Write(append(mb, '\n')); err != nil { - break - } + dataFrame := protocol.DataFrame{ + BaseFrame: protocol.BaseFrame{Type: "DATA"}, + Target: userBob, + Payload: "SGVsbG8gQm9iIQ==", + MAC: "dummy_mac", + } - // 100 mikrosekund pauzy zapobiega zapchaniu rur sieciowych - time.Sleep(100 * time.Microsecond) + // consumer BOB + go func() { + tmp := make([]byte, 4096) + for i := 0; i < msgsPerClient; i++ { + if _, err := bobStream.Read(tmp); err != nil { + return + } + } + }() + + // producer ALICE + for i := 0; i < msgsPerClient; i++ { + atomic.AddInt64(&globalMsgID, 1) + dataFrame.MsgID = globalMsgID + mb, _ := json.Marshal(dataFrame) + if _, err := aliceStream.Write(append(mb, '\n')); err != nil { + return + } + } + }(clientID) } wg.Wait() - totalDuration := time.Since(startTime) + total := time.Since(start) - if !fatalError { - saveToHistory(hostname, maxCores, c, b.N, totalDuration) - } + saveToHistory(hostname, maxCores, c, totalOps, total) }) } } func saveToHistory(hostname string, maxCores int, testCores int, totalOps int, duration time.Duration) { filename := "../markdowns/benchmark_history.md" + + // sprawdź, czy plik już istnieje _, err := os.Stat(filename) isNewFile := os.IsNotExist(err) f, err := os.OpenFile(filename, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) if err != nil { + // w benchmarku nie robimy panic/fatal – po prostu pomijamy zapis return } defer f.Close() + // jeśli nowy plik – nagłówek + header tabeli if isNewFile { f.WriteString("# KittyProtocol - Hub Routing Performance History\n\n") f.WriteString("| Date | PC Name | Max Cores | Used Cores | Packets | Duration | Latency/pkt | Throughput |\n") @@ -211,7 +204,8 @@ func saveToHistory(hostname string, maxCores int, testCores int, totalOps int, d latencyNs := float64(duration.Nanoseconds()) / float64(totalOps) throughputMsgSec := float64(totalOps) / duration.Seconds() - row := fmt.Sprintf("| %s | %s | %d | %d | %d | %s | %.2f ns | %.2f msg/s |\n", + row := fmt.Sprintf( + "| %s | %s | %d | %d | %d | %s | %.2f ns | %.2f msg/s |\n", timestamp, hostname, maxCores, @@ -221,5 +215,6 @@ func saveToHistory(hostname string, maxCores int, testCores int, totalOps int, d latencyNs, throughputMsgSec, ) + f.WriteString(row) } diff --git a/hub/router.go b/hub/router.go index acf4999..a36398f 100644 --- a/hub/router.go +++ b/hub/router.go @@ -10,12 +10,11 @@ import ( "github.com/gabbla05/KittyProtocol/internal/protection" "github.com/gabbla05/KittyProtocol/protocol" - "github.com/quic-go/quic-go" ) // routeData forwards a DATA frame from sender → receiver. // Returns true on success, false if delivery failed. -func routeData(frame protocol.DataFrame, sender *protection.Session, senderStream *quic.Stream) bool { +func routeData(frame protocol.DataFrame, sender *protection.Session, senderStream protection.Stream) bool { targetSess, ok := globalSessions.Get(frame.Target) // router.go — poprawiony fragment if !ok { diff --git a/hub/router_test.go b/hub/router_test.go new file mode 100644 index 0000000..c857838 --- /dev/null +++ b/hub/router_test.go @@ -0,0 +1,89 @@ +package hub + +import ( + "testing" + + "github.com/gabbla05/KittyProtocol/internal/protection" + "github.com/gabbla05/KittyProtocol/protocol" +) + +func TestRouter_UnknownTarget(t *testing.T) { + // Ensure globalSessions exists, but do NOT reassign it to avoid races + // with Hub goroutines (cleanup, handleClient, etc.). + if globalSessions == nil { + globalSessions = protection.NewSessionManager() + } + // Clean per‑test state only. + globalSessions.Remove("alice") + globalSessions.Remove("bob") + + sender := &protection.Session{ID: "alice"} + + ok := routeData( + protocol.DataFrame{ + BaseFrame: protocol.BaseFrame{MsgID: 1}, + Target: "ghost", + Payload: "abc", + }, + sender, + &fakeStream{}, + ) + + if ok { + t.Fatalf("Routing to unknown target should fail") + } +} + +func TestRouter_OfflineTarget(t *testing.T) { + if globalSessions == nil { + globalSessions = protection.NewSessionManager() + } + globalSessions.Remove("alice") + globalSessions.Remove("bob") + + receiver := &protection.Session{ID: "bob", Stream: nil} + globalSessions.Add("bob", receiver) + + sender := &protection.Session{ID: "alice"} + + ok := routeData( + protocol.DataFrame{ + BaseFrame: protocol.BaseFrame{MsgID: 1}, + Target: "bob", + Payload: "abc", + }, + sender, + &fakeStream{}, + ) + + if ok { + t.Fatalf("Routing to offline target should fail") + } +} + +func TestRouter_Success(t *testing.T) { + if globalSessions == nil { + globalSessions = protection.NewSessionManager() + } + globalSessions.Remove("alice") + globalSessions.Remove("bob") + + receiver := &protection.Session{ID: "bob", Stream: &fakeStream{}} + globalSessions.Add("bob", receiver) + + sender := &protection.Session{ID: "alice"} + + ok := routeData( + protocol.DataFrame{ + BaseFrame: protocol.BaseFrame{MsgID: 1}, + Target: "bob", + Payload: "abc", + }, + sender, + &fakeStream{}, + ) + + if !ok { + t.Fatalf("Routing to online target should succeed") + } +} diff --git a/hub/security_test.go b/hub/security_test.go index 4cb7edc..56672f1 100644 --- a/hub/security_test.go +++ b/hub/security_test.go @@ -5,140 +5,152 @@ import ( "crypto/tls" "encoding/json" "io" - "os" "testing" - "time" - "github.com/gabbla05/KittyProtocol/internal/certmanager" - "github.com/gabbla05/KittyProtocol/internal/protection" "github.com/gabbla05/KittyProtocol/protocol" "github.com/quic-go/quic-go" ) +// TestSecurityScenarios validates Hub‑level security mechanisms: +// 1. ERR_06 — replay attack detection +// 2. ERR_02 — malformed JSON injection. +// +// This test runs against an isolated Hub instance created by StartTestHub(), +// ensuring no interference with global state or other tests. func TestSecurityScenarios(t *testing.T) { - // Inicjalizacja globalnej mapy sesji - globalSessions = protection.NewSessionManager() - - // Przygotowanie certyfikatów - err := os.MkdirAll("../certs", 0755) - if err != nil { - t.Fatalf("Nie udało się utworzyć folderu certs: %v", err) - } - tlsConf, err := certmanager.SetupTLSConfig("../certs/cert.pem", "../certs/key.pem") - if err != nil { - t.Fatalf("Błąd konfiguracji TLS: %v", err) - } - - // Uruchomienie listenera - listener, err := quic.ListenAddr("127.0.0.1:0", tlsConf, nil) + // Start isolated Hub instance + addr, stop, err := StartTestHub() if err != nil { - t.Fatalf("Błąd uruchamiania listenera: %v", err) + t.Fatalf("Failed to start test Hub: %v", err) } - defer listener.Close() - - // Nasłuchiwanie - go func() { - for { - conn, err := listener.Accept(context.Background()) - if err != nil { - return - } - go handleClient(conn) - } - }() + defer stop() clientTLS := &tls.Config{ InsecureSkipVerify: true, NextProtos: []string{"kitty-quic-v1"}, } + // ============================================================ + // ERR_06 — Replay Attack Detection + // ============================================================ t.Run("ERR_06_ReplayAttack", func(t *testing.T) { - conn, err := quic.DialAddr(context.Background(), listener.Addr().String(), clientTLS, nil) + conn, err := quic.DialAddr(context.Background(), addr, clientTLS, nil) if err != nil { - t.Fatalf("Błąd połączenia: %v", err) + t.Fatalf("Connection error: %v", err) } defer conn.CloseWithError(0, "") stream, err := conn.OpenStreamSync(context.Background()) if err != nil { - t.Fatalf("Błąd strumienia: %v", err) + t.Fatalf("Stream open error: %v", err) } - // 1. HELLO i AUTH + buf := make([]byte, 1024) + + // HELLO hello := protocol.HelloFrame{ - BaseFrame: protocol.BaseFrame{Type: "HELLO", MsgID: time.Now().UnixMilli()}, + BaseFrame: protocol.BaseFrame{Type: protocol.FrameTypeHello, MsgID: 1}, Version: "1.0", } hb, _ := json.Marshal(hello) - stream.Write(hb) - buf := make([]byte, 1024) - stream.Read(buf) + if _, err := stream.Write(hb); err != nil { + t.Fatalf("HELLO write error: %v", err) + } + if _, err := stream.Read(buf); err != nil && err != io.EOF { + t.Fatalf("HELLO response read error: %v", err) + } + // AUTH authFrame := protocol.AuthFrame{ - BaseFrame: protocol.BaseFrame{Type: "AUTH", MsgID: time.Now().UnixMilli()}, + BaseFrame: protocol.BaseFrame{Type: protocol.FrameTypeAuth, MsgID: 2}, User: "alice", Pass: "secret", } ab, _ := json.Marshal(authFrame) - stream.Write(ab) - stream.Read(buf) // Odbiór MEOW_OK + if _, err := stream.Write(ab); err != nil { + t.Fatalf("AUTH write error: %v", err) + } + if _, err := stream.Read(buf); err != nil && err != io.EOF { + t.Fatalf("AUTH response read error: %v", err) + } - // 2. Pierwsza ramka DATA - msgID := time.Now().UnixMilli() + // DATA → Alice (self‑send to ensure routing succeeds) + msgID := int64(100) dataFrame := protocol.DataFrame{ - BaseFrame: protocol.BaseFrame{Type: "DATA", MsgID: msgID}, - Target: "bob", + BaseFrame: protocol.BaseFrame{Type: protocol.FrameTypeData, MsgID: msgID}, + Target: "alice", Payload: "SGVsbG8=", MAC: "dummyMAC", } db, _ := json.Marshal(dataFrame) - stream.Write(db) - stream.Read(buf) // Huba odpowiada (MEOW_OK lub ERR_15) i zapisuje MsgID w cache'u anty-replay - // 3. Replay (ponowne wysłanie tej samej ramki z tym samym MsgID) - stream.Write(db) + // First send — should be accepted + if _, err := stream.Write(db); err != nil { + t.Fatalf("First DATA write error: %v", err) + } + if _, err := stream.Read(buf); err != nil && err != io.EOF { + t.Fatalf("First DATA response read error: %v", err) + } + + // Replay — same MsgID + if _, err := stream.Write(db); err != nil { + t.Fatalf("Replay DATA write error: %v", err) + } n, err := stream.Read(buf) if err != nil && err != io.EOF { - t.Fatalf("Błąd odczytu: %v", err) + t.Fatalf("Replay DATA response read error: %v", err) + } + if n == 0 { + t.Fatalf("Received EOF without data on replay") } - // 4. Weryfikacja var errResp protocol.ErrorFrame - json.Unmarshal(buf[:n], &errResp) + if err := json.Unmarshal(buf[:n], &errResp); err != nil { + t.Fatalf("Failed to unmarshal ERROR frame: %v", err) + } - if errResp.Code != "ERR_06" { - t.Errorf("Oczekiwano ERR_06 (Replay detected), otrzymano: %s", errResp.Code) + if errResp.Code != protocol.ErrReplayDetected { + t.Errorf("Expected ERR_06 (Replay detected), got: %s", errResp.Code) } }) + // ============================================================ + // ERR_02 — Malformed JSON Injection + // ============================================================ t.Run("ERR_02_Injection", func(t *testing.T) { - conn, err := quic.DialAddr(context.Background(), listener.Addr().String(), clientTLS, nil) + conn, err := quic.DialAddr(context.Background(), addr, clientTLS, nil) if err != nil { - t.Fatalf("Błąd połączenia: %v", err) + t.Fatalf("Connection error: %v", err) } defer conn.CloseWithError(0, "") stream, err := conn.OpenStreamSync(context.Background()) if err != nil { - t.Fatalf("Błąd strumienia: %v", err) + t.Fatalf("Stream open error: %v", err) } - // 1. Wstrzyknięcie złośliwych danych zamiast struktury JSON + // Malformed JSON payload (invalid syntax) badData := []byte(`{DROP TABLE users; HACK THE PLANET}`) - stream.Write(badData) + if _, err := stream.Write(badData); err != nil { + t.Fatalf("Malformed DATA write error: %v", err) + } buf := make([]byte, 1024) n, err := stream.Read(buf) if err != nil && err != io.EOF { - t.Fatalf("Błąd odczytu: %v", err) + t.Fatalf("Malformed DATA response read error: %v", err) + } + if n == 0 { + t.Fatalf("Received EOF without data for malformed JSON") } - // 2. Weryfikacja błędu z parsera JSON var errResp protocol.ErrorFrame - json.Unmarshal(buf[:n], &errResp) + if err := json.Unmarshal(buf[:n], &errResp); err != nil { + t.Fatalf("Failed to unmarshal ERROR frame: %v", err) + } - if errResp.Code != "ERR_02" { - t.Errorf("Oczekiwano ERR_02 (błąd formatu), otrzymano: %s", errResp.Code) + if errResp.Code != protocol.ErrFormatError { + t.Errorf("Expected ERR_02 (Format error), got: %s", errResp.Code) } }) } diff --git a/hub/start_test-hub.go b/hub/start_test-hub.go new file mode 100644 index 0000000..beba3b6 --- /dev/null +++ b/hub/start_test-hub.go @@ -0,0 +1,69 @@ +package hub + +import ( + "context" + "os" + "time" + + "github.com/gabbla05/KittyProtocol/internal/auth" + "github.com/gabbla05/KittyProtocol/internal/certmanager" + "github.com/gabbla05/KittyProtocol/internal/protection" + "github.com/quic-go/quic-go" +) + +// StartTestHub starts an isolated Hub instance for tests and returns: +// - QUIC address to dial +// - stop() function that shuts down listener and accept loop +// +// It intentionally does NOT reassign globalSessions to avoid data races. +func StartTestHub() (addr string, stop func(), err error) { + // Ensure certs directory exists + if err := os.MkdirAll("../certs", 0755); err != nil { + return "", nil, err + } + + tlsConf, err := certmanager.SetupTLSConfig("../certs/cert.pem", "../certs/key.pem") + if err != nil { + return "", nil, err + } + + // Ensure globalSessions exists, but do NOT reassign it later. + if globalSessions == nil { + globalSessions = protection.NewSessionManager() + } + // Clean per‑test users only (safe, SessionManager is synchronized). + globalSessions.Remove("alice") + globalSessions.Remove("bob") + + // Fresh mock auth backend for this test run. + globalAuth = auth.NewMockAuth() + + // Start listener on random port. + listener, err := quic.ListenAddr("127.0.0.1:0", tlsConf, &quic.Config{ + KeepAlivePeriod: 1 * time.Second, + }) + if err != nil { + return "", nil, err + } + + ctx, cancel := context.WithCancel(context.Background()) + + // Accept loop. + go func() { + for { + conn, err := listener.Accept(ctx) + if err != nil { + return + } + go handleClient(conn) + } + }() + + stop = func() { + // Caution: Do not touch globalSessions here. + cancel() + _ = listener.Close() + } + + return listener.Addr().String(), stop, nil +} diff --git a/hub/stream_adapter.go b/hub/stream_adapter.go new file mode 100644 index 0000000..f72956f --- /dev/null +++ b/hub/stream_adapter.go @@ -0,0 +1,14 @@ +package hub + +import ( + "github.com/quic-go/quic-go" +) + +// quicStreamAdapter adapts *quic.Stream to protection.Stream. +type quicStreamAdapter struct { + s *quic.Stream +} + +func (q *quicStreamAdapter) Write(b []byte) (int, error) { return q.s.Write(b) } +func (q *quicStreamAdapter) Read(b []byte) (int, error) { return q.s.Read(b) } +func (q *quicStreamAdapter) Close() error { return q.s.Close() } diff --git a/internal/protection/session.go b/internal/protection/session.go index d4624c1..a140d41 100644 --- a/internal/protection/session.go +++ b/internal/protection/session.go @@ -15,12 +15,12 @@ type Session struct { Limiter *RateLimiter CloseFunc func() Conn *quic.Conn - Stream *quic.Stream + Stream Stream Replay *ReplayDetector } // NewSession creates a new Session for the given user and connection. -func NewSession(user string, conn *quic.Conn, stream *quic.Stream) *Session { +func NewSession(user string, conn *quic.Conn, stream Stream) *Session { return &Session{ ID: user, LastActive: time.Now(), diff --git a/internal/protection/stream.go b/internal/protection/stream.go new file mode 100644 index 0000000..6e3e095 --- /dev/null +++ b/internal/protection/stream.go @@ -0,0 +1,9 @@ +package protection + +// Stream is a minimal abstraction over a bidirectional transport stream. +// It is intentionally small to keep Hub logic independent from quic-go. +type Stream interface { + Write([]byte) (int, error) + Read([]byte) (int, error) + Close() error +} diff --git a/markdowns/INFO_bechmark.md b/markdowns/INFO_bechmark.md new file mode 100644 index 0000000..deee22d --- /dev/null +++ b/markdowns/INFO_bechmark.md @@ -0,0 +1,39 @@ +# Co dokładnie porównujesz między maszynami? + +## W Twoim formacie: + +- Latency/pkt → „ile ns kosztuje obsługa jednej wiadomości na tej maszynie / tym OS” + +- Throughput → „ile wiadomości/s ta maszyna realnie przepchnie przez Hub+QUIC+JSON” + + +## To są idealne metryki porównawcze: + +- między Linux vs Windows vs WSL, + +- między laptopem A vs laptopem B, + +- między starym CPU vs nowym CPU. + + +## Na co uważać przy porównaniach? + +* WSL: + + - ma dodatkową warstwę (kernel + wirtualizacja), + + - wyniki będą zwykle gorsze niż natywny Linux, ale względne różnice (np. 1 vs 2 vs 4 rdzenie) nadal mają sens. + +* Windows natywny: + + - inny scheduler, + + - inne timery, + + - inne zachowanie stosu sieciowego, + + - ale dalej: throughput i latency są porównywalne jako „wydajność tej platformy”. + +* Różne CPU: + + - tu wyniki są wręcz idealne do porównań — zobaczysz realny zysk z lepszego CPU. \ No newline at end of file diff --git a/markdowns/benchmark_history.md b/markdowns/benchmark_history.md index f1a4b0b..5179f6f 100644 --- a/markdowns/benchmark_history.md +++ b/markdowns/benchmark_history.md @@ -1,4 +1,90 @@ -# KittyProtocol - Hub Routing Performance History +# UWAGA UWAGA TUTAJ TE WYNIKI SĄ JUŻ DLA NOWEGO PLIKU TESTOWEGO I URUCHAMIA SIE KOMENDĄ: +`go test ./hub -bench=BenchmarkHubRouting -run='^$'` (przynajmniej na linuxie) +# Michał - Arch Linux +| Date | PC Name | Max Cores | Used Cores | Packets | Duration | Latency/pkt | Throughput | +|--- |--- |--- |--- |--- |--- |--- |--- | +| 2026-05-24 22:54:54 | archlinux | 16 | 1 | 10000 | 126ms | 12592.10 ns | 79414.89 msg/s | +| 2026-05-24 22:54:54 | archlinux | 16 | 1 | 10000 | 144ms | 14381.22 ns | 69535.11 msg/s | +| 2026-05-24 22:54:54 | archlinux | 16 | 1 | 10000 | 142ms | 14179.01 ns | 70526.77 msg/s | +| 2026-05-24 22:54:54 | archlinux | 16 | 1 | 10000 | 135ms | 13464.64 ns | 74268.62 msg/s | +| 2026-05-24 22:54:54 | archlinux | 16 | 1 | 10000 | 135ms | 13508.76 ns | 74026.04 msg/s | +| 2026-05-24 22:54:54 | archlinux | 16 | 1 | 10000 | 140ms | 14047.66 ns | 71186.25 msg/s | +| 2026-05-24 22:54:55 | archlinux | 16 | 1 | 10000 | 133ms | 13261.21 ns | 75407.93 msg/s | +| 2026-05-24 22:54:55 | archlinux | 16 | 1 | 10000 | 133ms | 13311.79 ns | 75121.38 msg/s | +| 2026-05-24 22:54:55 | archlinux | 16 | 1 | 10000 | 134ms | 13375.59 ns | 74763.08 msg/s | +| 2026-05-24 22:54:55 | archlinux | 16 | 1 | 10000 | 126ms | 12588.32 ns | 79438.69 msg/s | +| 2026-05-24 22:54:55 | archlinux | 16 | 1 | 10000 | 141ms | 14131.19 ns | 70765.43 msg/s | +| 2026-05-24 22:54:55 | archlinux | 16 | 2 | 10000 | 68ms | 6767.00 ns | 147776.04 msg/s | +| 2026-05-24 22:54:55 | archlinux | 16 | 2 | 10000 | 81ms | 8085.50 ns | 123678.20 msg/s | +| 2026-05-24 22:54:55 | archlinux | 16 | 2 | 10000 | 85ms | 8493.89 ns | 117731.68 msg/s | +| 2026-05-24 22:54:55 | archlinux | 16 | 2 | 10000 | 75ms | 7508.89 ns | 133175.56 msg/s | +| 2026-05-24 22:54:56 | archlinux | 16 | 2 | 10000 | 76ms | 7593.05 ns | 131699.41 msg/s | +| 2026-05-24 22:54:56 | archlinux | 16 | 2 | 10000 | 80ms | 7953.86 ns | 125725.12 msg/s | +| 2026-05-24 22:54:56 | archlinux | 16 | 2 | 10000 | 78ms | 7840.67 ns | 127540.18 msg/s | +| 2026-05-24 22:54:56 | archlinux | 16 | 2 | 10000 | 77ms | 7667.40 ns | 130422.25 msg/s | +| 2026-05-24 22:54:56 | archlinux | 16 | 2 | 10000 | 80ms | 8013.04 ns | 124796.61 msg/s | +| 2026-05-24 22:54:56 | archlinux | 16 | 4 | 10000 | 48ms | 4808.41 ns | 207969.06 msg/s | +| 2026-05-24 22:54:56 | archlinux | 16 | 4 | 10000 | 51ms | 5085.67 ns | 196631.05 msg/s | +| 2026-05-24 22:54:56 | archlinux | 16 | 4 | 10000 | 48ms | 4785.55 ns | 208962.28 msg/s | +| 2026-05-24 22:54:56 | archlinux | 16 | 4 | 10000 | 49ms | 4857.56 ns | 205864.88 msg/s | +| 2026-05-24 22:54:56 | archlinux | 16 | 4 | 10000 | 48ms | 4776.44 ns | 209360.88 msg/s | +| 2026-05-24 22:54:56 | archlinux | 16 | 4 | 10000 | 54ms | 5359.38 ns | 186588.91 msg/s | +| 2026-05-24 22:54:56 | archlinux | 16 | 4 | 10000 | 49ms | 4885.94 ns | 204669.02 msg/s | +| 2026-05-24 22:54:56 | archlinux | 16 | 4 | 10000 | 50ms | 4987.42 ns | 200504.66 msg/s | +| 2026-05-24 22:54:56 | archlinux | 16 | 8 | 10000 | 32ms | 3247.62 ns | 307917.57 msg/s | +| 2026-05-24 22:54:56 | archlinux | 16 | 8 | 10000 | 33ms | 3307.67 ns | 302327.32 msg/s | +| 2026-05-24 22:54:56 | archlinux | 16 | 8 | 10000 | 35ms | 3491.79 ns | 286386.27 msg/s | +| 2026-05-24 22:54:56 | archlinux | 16 | 8 | 10000 | 35ms | 3513.19 ns | 284641.87 msg/s | +| 2026-05-24 22:54:56 | archlinux | 16 | 8 | 10000 | 37ms | 3662.47 ns | 273039.98 msg/s | +| 2026-05-24 22:54:57 | archlinux | 16 | 8 | 10000 | 45ms | 4542.15 ns | 220160.20 msg/s | +| 2026-05-24 22:54:57 | archlinux | 16 | 8 | 10000 | 46ms | 4612.47 ns | 216803.52 msg/s | +| 2026-05-24 22:54:57 | archlinux | 16 | 16 | 10000 | 42ms | 4212.55 ns | 237385.88 msg/s | +| 2026-05-24 22:54:57 | archlinux | 16 | 16 | 10000 | 41ms | 4082.31 ns | 244959.19 msg/s | +| 2026-05-24 22:54:57 | archlinux | 16 | 16 | 10000 | 37ms | 3706.06 ns | 269828.60 msg/s | +| 2026-05-24 22:54:57 | archlinux | 16 | 16 | 10000 | 35ms | 3461.30 ns | 288908.62 msg/s | +| 2026-05-24 22:54:57 | archlinux | 16 | 16 | 10000 | 36ms | 3576.35 ns | 279614.42 msg/s | +| 2026-05-24 22:54:57 | archlinux | 16 | 16 | 10000 | 34ms | 3381.36 ns | 295738.57 msg/s | +| 2026-05-24 22:54:57 | archlinux | 16 | 16 | 10000 | 37ms | 3676.25 ns | 272016.52 msg/s | + +## Interpretacja: +- 1 → 2 rdzenie + + Przepustowość prawie ×2. + Idealne skalowanie. + +- 2 → 4 rdzenie + + Przepustowość prawie ×1.5. + Bardzo dobre skalowanie. + +- 4 → 8 rdzeni + + Przepustowość prawie ×1.4. + Wciąż dobre skalowanie. + +- 8 → 16 rdzeni + + Przepustowość trochę spada. +Dlaczego? + +Bo: + +- QUIC listener ma jedną goroutine acceptującą, +- routing ma locki, +- JSON ma overhead, +- scheduler Go ma koszty synchronizacji, +- 16 goroutines zaczyna walczyć o zasoby. + +To jest normalne — każdy system ma punkt nasycenia. Żaden serwer na świecie nie skaluje się liniowo do 16 rdzeni. + +Dla mnie oznacza to, że Hub na mojej maszynie: + + - przy 1 rdzeniu obsługuje ~75k wiadomości na sekundę, + + - przy 8 rdzeniach obsługuje ~300k wiadomości na sekundę. + + +# KittyProtocol - Hub Routing Performance History (DEPRECATED) ## GOŁEK: ### go test ./hub -bench=BenchmarkHubRouting -v -run=^$ @@ -37,3 +123,4 @@ | 2026-05-21 14:41:32 | archlinux | 16 | 8 | 8061 | 10.002s | 1240806.52 ns | 805.93 msg/s | | 2026-05-21 14:41:32 | archlinux | 16 | 16 | 1 | 0s | 324467.00 ns | 3081.98 msg/s | | 2026-05-21 14:41:42 | archlinux | 16 | 16 | 100 | 10.003s | 100026446.05 ns | 10.00 msg/s | + From 3d3b0990ce3e2c988d09f0364c2abecd2166c268 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Brzezi=C5=84ski?= Date: Sun, 24 May 2026 23:32:05 +0000 Subject: [PATCH 20/44] test coverage improvement for protocol --- protocol/frame_auth_test.go | 41 +++ protocol/frame_bye_test.go | 35 +++ protocol/frame_data_test.go | 30 ++ protocol/{frame_errors.go => frame_error.go} | 0 protocol/frame_error_test.go | 42 +++ protocol/frame_gettype_test.go | 43 +++ protocol/frame_hello_test.go | 23 ++ protocol/frame_ok_test.go | 14 + protocol/frame_ping_test.go | 35 +++ protocol/frame_status_test.go | 48 +++ protocol/frame_validtype_test.go | 34 ++ protocol/frames_test.go | 310 +++++++++---------- 12 files changed, 500 insertions(+), 155 deletions(-) create mode 100644 protocol/frame_auth_test.go create mode 100644 protocol/frame_bye_test.go create mode 100644 protocol/frame_data_test.go rename protocol/{frame_errors.go => frame_error.go} (100%) create mode 100644 protocol/frame_error_test.go create mode 100644 protocol/frame_gettype_test.go create mode 100644 protocol/frame_hello_test.go create mode 100644 protocol/frame_ok_test.go create mode 100644 protocol/frame_ping_test.go create mode 100644 protocol/frame_status_test.go create mode 100644 protocol/frame_validtype_test.go diff --git a/protocol/frame_auth_test.go b/protocol/frame_auth_test.go new file mode 100644 index 0000000..b12b20d --- /dev/null +++ b/protocol/frame_auth_test.go @@ -0,0 +1,41 @@ +package protocol + +import ( + "strings" + "testing" +) + +func TestParseAuthFrameInvalidJSON(t *testing.T) { + _, err := ParseAuthFrame([]byte(`{invalid}`)) + if err == nil { + t.Fatalf("expected JSON error") + } +} + +func TestParseAuthFrameWrongType(t *testing.T) { + _, err := ParseAuthFrame([]byte(`{"type":"DATA","msg_id":1,"user":"a","pass":"b"}`)) + if err == nil { + t.Fatalf("expected wrong type error") + } +} + +func TestParseAuthFrameInvalidMsgID(t *testing.T) { + _, err := ParseAuthFrame([]byte(`{"type":"AUTH","msg_id":0,"user":"a","pass":"b"}`)) + if err == nil { + t.Fatalf("expected invalid msg_id error") + } +} + +func TestParseRegisterFrameMissingFields(t *testing.T) { + _, err := ParseRegisterFrame([]byte(`{"type":"REGISTER","msg_id":1,"user":"a"}`)) + if err == nil || !strings.Contains(err.Error(), ErrCodeInvalidFrame) { + t.Fatalf("expected missing pass error") + } +} + +func TestParseRegisterFrameWrongType(t *testing.T) { + _, err := ParseRegisterFrame([]byte(`{"type":"AUTH","msg_id":1,"user":"a","pass":"b"}`)) + if err == nil { + t.Fatalf("expected wrong type error") + } +} diff --git a/protocol/frame_bye_test.go b/protocol/frame_bye_test.go new file mode 100644 index 0000000..d595e52 --- /dev/null +++ b/protocol/frame_bye_test.go @@ -0,0 +1,35 @@ +package protocol + +import ( + "strings" + "testing" +) + +func TestParseByeFrameValid(t *testing.T) { + json := []byte(`{"type":"BYE","msg_id":1}`) + _, err := ParseByeFrame(json) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestParseByeFrameInvalidJSON(t *testing.T) { + _, err := ParseByeFrame([]byte(`{invalid}`)) + if err == nil { + t.Fatalf("expected JSON error") + } +} + +func TestParseByeFrameWrongType(t *testing.T) { + _, err := ParseByeFrame([]byte(`{"type":"DATA","msg_id":1}`)) + if err == nil || !strings.Contains(err.Error(), ErrCodeInvalidFrame) { + t.Fatalf("expected wrong type error") + } +} + +func TestParseByeFrameInvalidMsgID(t *testing.T) { + _, err := ParseByeFrame([]byte(`{"type":"BYE","msg_id":0}`)) + if err == nil { + t.Fatalf("expected invalid msg_id error") + } +} diff --git a/protocol/frame_data_test.go b/protocol/frame_data_test.go new file mode 100644 index 0000000..f81607c --- /dev/null +++ b/protocol/frame_data_test.go @@ -0,0 +1,30 @@ +package protocol + +import ( + "strings" + "testing" +) + +func TestParseDataFrameValid(t *testing.T) { + json := []byte(`{"type":"DATA","msg_id":1,"target":"bob","payload":"x","mac":"y"}`) + _, err := ParseDataFrame(json) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestParseDataFrameMissingFields(t *testing.T) { + json := []byte(`{"type":"DATA","msg_id":1,"target":"bob","payload":"x"}`) + _, err := ParseDataFrame(json) + if err == nil || !strings.Contains(err.Error(), ErrCodeInvalidFrame) { + t.Fatalf("expected missing MAC error") + } +} + +func TestParseDataFrameWrongType(t *testing.T) { + json := []byte(`{"type":"HELLO","msg_id":1}`) + _, err := ParseDataFrame(json) + if err == nil { + t.Fatalf("expected wrong type error") + } +} diff --git a/protocol/frame_errors.go b/protocol/frame_error.go similarity index 100% rename from protocol/frame_errors.go rename to protocol/frame_error.go diff --git a/protocol/frame_error_test.go b/protocol/frame_error_test.go new file mode 100644 index 0000000..5270e92 --- /dev/null +++ b/protocol/frame_error_test.go @@ -0,0 +1,42 @@ +package protocol + +import ( + "strings" + "testing" +) + +func TestParseErrorFrameValid(t *testing.T) { + json := []byte(`{"type":"ERROR","msg_id":1,"code":"ERR_01","desc":"x"}`) + _, err := ParseErrorFrame(json) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestParseErrorFrameInvalidJSON(t *testing.T) { + _, err := ParseErrorFrame([]byte(`{invalid}`)) + if err == nil { + t.Fatalf("expected JSON error") + } +} + +func TestParseErrorFrameWrongType(t *testing.T) { + _, err := ParseErrorFrame([]byte(`{"type":"DATA","msg_id":1,"code":"ERR_01"}`)) + if err == nil || !strings.Contains(err.Error(), ErrCodeInvalidFrame) { + t.Fatalf("expected wrong type error") + } +} + +func TestParseErrorFrameInvalidMsgID(t *testing.T) { + _, err := ParseErrorFrame([]byte(`{"type":"ERROR","msg_id":0,"code":"ERR_01"}`)) + if err == nil { + t.Fatalf("expected invalid msg_id error") + } +} + +func TestParseErrorFrameMissingCode(t *testing.T) { + _, err := ParseErrorFrame([]byte(`{"type":"ERROR","msg_id":1}`)) + if err == nil { + t.Fatalf("expected missing code error") + } +} diff --git a/protocol/frame_gettype_test.go b/protocol/frame_gettype_test.go new file mode 100644 index 0000000..4901292 --- /dev/null +++ b/protocol/frame_gettype_test.go @@ -0,0 +1,43 @@ +package protocol + +import ( + "strings" + "testing" +) + +func TestGetFrameTypeValid(t *testing.T) { + jsonInput := []byte(`{"type":"DATA","msg_id":123}`) + typ, id, err := GetFrameType(jsonInput) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if typ != FrameTypeData || id != 123 { + t.Fatalf("wrong values: %s %d", typ, id) + } +} + +func TestGetFrameTypeMissingFields(t *testing.T) { + _, _, err := GetFrameType([]byte(`{"type":"DATA"}`)) + if err == nil || !strings.Contains(err.Error(), ErrCodeInvalidFrame) { + t.Fatalf("expected missing msg_id error") + } + + _, _, err = GetFrameType([]byte(`{"msg_id":1}`)) + if err == nil { + t.Fatalf("expected missing type error") + } +} + +func TestGetFrameTypeInvalidJSON(t *testing.T) { + _, _, err := GetFrameType([]byte(`{invalid json}`)) + if err == nil { + t.Fatalf("expected JSON error") + } +} + +func TestGetFrameTypeUnknownType(t *testing.T) { + _, _, err := GetFrameType([]byte(`{"type":"HACK","msg_id":1}`)) + if err == nil { + t.Fatalf("expected unknown type error") + } +} diff --git a/protocol/frame_hello_test.go b/protocol/frame_hello_test.go new file mode 100644 index 0000000..deeda85 --- /dev/null +++ b/protocol/frame_hello_test.go @@ -0,0 +1,23 @@ +package protocol + +import "testing" + +func TestParseHelloFrameValid(t *testing.T) { + json := []byte(`{"type":"HELLO","msg_id":1,"version":"1.0"}`) + _, err := ParseHelloFrame(json) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestParseHelloFrameInvalid(t *testing.T) { + _, err := ParseHelloFrame([]byte(`{"type":"HELLO"}`)) + if err == nil { + t.Fatalf("expected missing version error") + } + + _, err = ParseHelloFrame([]byte(`{"type":"HELLO","msg_id":1,"version":"9.9"}`)) + if err == nil { + t.Fatalf("expected version mismatch error") + } +} diff --git a/protocol/frame_ok_test.go b/protocol/frame_ok_test.go new file mode 100644 index 0000000..a572c2b --- /dev/null +++ b/protocol/frame_ok_test.go @@ -0,0 +1,14 @@ +package protocol + +import ( + "encoding/json" + "testing" +) + +func TestMeowOkFrameJSON(t *testing.T) { + data := []byte(`{"type":"MEOW_OK","msg_id":1,"status":"ok"}`) + var f MeowOkFrame + if err := json.Unmarshal(data, &f); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} diff --git a/protocol/frame_ping_test.go b/protocol/frame_ping_test.go new file mode 100644 index 0000000..3002ed9 --- /dev/null +++ b/protocol/frame_ping_test.go @@ -0,0 +1,35 @@ +package protocol + +import ( + "strings" + "testing" +) + +func TestParsePingFrameValid(t *testing.T) { + json := []byte(`{"type":"PING","msg_id":1}`) + _, err := ParsePingFrame(json) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestParsePingFrameInvalidJSON(t *testing.T) { + _, err := ParsePingFrame([]byte(`{invalid}`)) + if err == nil { + t.Fatalf("expected JSON error") + } +} + +func TestParsePingFrameWrongType(t *testing.T) { + _, err := ParsePingFrame([]byte(`{"type":"HELLO","msg_id":1}`)) + if err == nil || !strings.Contains(err.Error(), ErrCodeInvalidFrame) { + t.Fatalf("expected wrong type error") + } +} + +func TestParsePingFrameInvalidMsgID(t *testing.T) { + _, err := ParsePingFrame([]byte(`{"type":"PING","msg_id":0}`)) + if err == nil { + t.Fatalf("expected invalid msg_id error") + } +} diff --git a/protocol/frame_status_test.go b/protocol/frame_status_test.go new file mode 100644 index 0000000..0f7c77e --- /dev/null +++ b/protocol/frame_status_test.go @@ -0,0 +1,48 @@ +package protocol + +import ( + "strings" + "testing" +) + +func TestParseGetStatusFrameInvalidJSON(t *testing.T) { + _, err := ParseGetStatusFrame([]byte(`{invalid}`)) + if err == nil { + t.Fatalf("expected JSON error") + } +} + +func TestParseGetStatusFrameWrongType(t *testing.T) { + _, err := ParseGetStatusFrame([]byte(`{"type":"DATA","msg_id":1,"target":"x"}`)) + if err == nil || !strings.Contains(err.Error(), ErrCodeInvalidFrame) { + t.Fatalf("expected wrong type error") + } +} + +func TestParseGetStatusFrameInvalidMsgID(t *testing.T) { + _, err := ParseGetStatusFrame([]byte(`{"type":"GET_STATUS","msg_id":0,"target":"x"}`)) + if err == nil { + t.Fatalf("expected invalid msg_id error") + } +} + +func TestParseStatusResFrameInvalidJSON(t *testing.T) { + _, err := ParseStatusResFrame([]byte(`{invalid}`)) + if err == nil { + t.Fatalf("expected JSON error") + } +} + +func TestParseStatusResFrameWrongType(t *testing.T) { + _, err := ParseStatusResFrame([]byte(`{"type":"DATA","msg_id":1,"target":"x","status":"y"}`)) + if err == nil { + t.Fatalf("expected wrong type error") + } +} + +func TestParseStatusResFrameInvalidMsgID(t *testing.T) { + _, err := ParseStatusResFrame([]byte(`{"type":"STATUS_RES","msg_id":0,"target":"x","status":"y"}`)) + if err == nil { + t.Fatalf("expected invalid msg_id error") + } +} diff --git a/protocol/frame_validtype_test.go b/protocol/frame_validtype_test.go new file mode 100644 index 0000000..9eee10f --- /dev/null +++ b/protocol/frame_validtype_test.go @@ -0,0 +1,34 @@ +package protocol + +import "testing" + +func TestIsValidType(t *testing.T) { + valid := []string{ + FrameTypeHello, FrameTypeAuth, FrameTypeRegister, + FrameTypeData, FrameTypeMeowOK, FrameTypeError, + FrameTypeGetStatus, FrameTypeStatusRes, + FrameTypePing, FrameTypeBye, + } + + for _, v := range valid { + if !IsValidType(v) { + t.Fatalf("expected valid type: %s", v) + } + } + + if IsValidType("HACK") { + t.Fatalf("expected invalid type") + } +} + +func TestIsValidTypeEdgeCases(t *testing.T) { + if IsValidType("") { + t.Fatalf("empty string should be invalid") + } + if IsValidType("data") { + t.Fatalf("lowercase should be invalid") + } + if IsValidType(" ") { + t.Fatalf("whitespace should be invalid") + } +} diff --git a/protocol/frames_test.go b/protocol/frames_test.go index 034ea85..4c736b4 100644 --- a/protocol/frames_test.go +++ b/protocol/frames_test.go @@ -1,157 +1,157 @@ package protocol -import ( - "strings" - "testing" -) - -// --- GetFrameType tests --- - -// TestGetFrameTypeValid verifies that GetFrameType correctly extracts -// the frame type and message ID from a valid JSON frame. -func TestGetFrameTypeValid(t *testing.T) { - jsonInput := []byte(`{"type":"DATA","msg_id":123}`) - typeName, msgID, err := GetFrameType(jsonInput) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if typeName != "DATA" || msgID != 123 { - t.Fatalf("GetFrameType returned wrong values: got %s, %d", typeName, msgID) - } -} - -// TestGetFrameTypeMissingFields ensures that missing required fields -// (type or msg_id) result in an ERR_02 validation error. -func TestGetFrameTypeMissingFields(t *testing.T) { - jsonNoID := []byte(`{"type":"DATA"}`) - _, _, err := GetFrameType(jsonNoID) - if err == nil || !strings.Contains(err.Error(), "ERR_02") { - t.Fatalf("expected ERR_02 for missing msg_id") - } - - jsonNoType := []byte(`{"msg_id":123}`) - _, _, err = GetFrameType(jsonNoType) - if err == nil || !strings.Contains(err.Error(), "ERR_02") { - t.Fatalf("expected ERR_02 for missing type") - } -} - -// TestGetFrameTypeInvalidJSON checks that malformed JSON is rejected. -func TestGetFrameTypeInvalidJSON(t *testing.T) { - jsonInvalid := []byte(`{invalid json}`) - _, _, err := GetFrameType(jsonInvalid) - if err == nil || !strings.Contains(err.Error(), "ERR_02") { - t.Fatalf("expected ERR_02 for invalid JSON") - } -} - -// TestGetFrameTypeUnknownType verifies that unknown frame types -// are rejected with ERR_02. -func TestGetFrameTypeUnknownType(t *testing.T) { - jsonUnknown := []byte(`{"type":"HACK","msg_id":123}`) - _, _, err := GetFrameType(jsonUnknown) - if err == nil || !strings.Contains(err.Error(), "ERR_02") { - t.Fatalf("expected ERR_02 for unknown frame type") - } -} - -// --- AUTH tests --- - -// TestParseAuthFrameValidation ensures that missing required fields -// in AUTH frames (e.g., pass) produce an ERR_02 error. -func TestParseAuthFrameValidation(t *testing.T) { - jsonMissingPass := []byte(`{"type":"AUTH","msg_id":123,"user":"alice"}`) - _, err := ParseAuthFrame(jsonMissingPass) - if err == nil || !strings.Contains(err.Error(), "ERR_02") { - t.Fatalf("expected ERR_02 for missing pass") - } -} - -// --- DATA tests --- - -// TestDataFrameValidation verifies correct parsing of DATA frames, -// including validation of required fields and rejection of invalid ones. -func TestDataFrameValidation(t *testing.T) { - valid := []byte(`{"type":"DATA","msg_id":123,"target":"bob","payload":"SGVsbG8=","mac":"hash"}`) - f, err := ParseDataFrame(valid) - if err != nil { - t.Fatalf("failed to parse valid DataFrame: %v", err) - } - if f.Target != "bob" { - t.Errorf("wrong target") - } - - // Sender is allowed (Hub sets it when forwarding) - withSender := []byte(`{"type":"DATA","msg_id":123,"sender":"alice","target":"bob","payload":"x","mac":"y"}`) - _, err = ParseDataFrame(withSender) - if err != nil { - t.Fatalf("sender should be allowed in forwarded DATA frames: %v", err) - } - - // Missing MAC - missingMac := []byte(`{"type":"DATA","msg_id":123,"target":"bob","payload":"x"}`) - _, err = ParseDataFrame(missingMac) - if err == nil { - t.Fatalf("expected error for missing MAC") - } -} - -// --- STATUS_RES tests --- - -// TestParseStatusResFrameValidation checks that STATUS_RES frames -// must include both target and status fields. -func TestParseStatusResFrameValidation(t *testing.T) { - jsonMissingStatus := []byte(`{"type":"STATUS_RES","msg_id":123,"target":"alice"}`) - _, err := ParseStatusResFrame(jsonMissingStatus) - if err == nil { - t.Fatalf("expected error for missing status") - } -} - -// --- ERROR tests --- - -// TestParseErrorFrameValidation ensures that ERROR frames must include -// an error code. -func TestParseErrorFrameValidation(t *testing.T) { - jsonMissingCode := []byte(`{"type":"ERROR","msg_id":123,"desc":"Something went wrong"}`) - _, err := ParseErrorFrame(jsonMissingCode) - if err == nil { - t.Fatalf("expected error for missing code") - } -} - -// --- GET_STATUS tests --- - -// TestParseGetStatusFrameValidation verifies that GET_STATUS frames -// must include a target field. -func TestParseGetStatusFrameValidation(t *testing.T) { - jsonMissingTarget := []byte(`{"type":"GET_STATUS","msg_id":123}`) - _, err := ParseGetStatusFrame(jsonMissingTarget) - if err == nil { - t.Fatalf("expected error for missing target") - } -} - -// --- HELLO tests --- - -// TestParseHelloFrameValidation checks HELLO frame validation, -// including JSON format, version presence, and version compatibility. -func TestParseHelloFrameValidation(t *testing.T) { - invalidJSON := []byte(`{"type":"HELLO"`) - _, err := ParseHelloFrame(invalidJSON) - if err == nil { - t.Fatalf("expected error for invalid JSON") - } - - missingVersion := []byte(`{"type":"HELLO","msg_id":1}`) - _, err = ParseHelloFrame(missingVersion) - if err == nil { - t.Fatalf("expected error for missing version") - } - - wrongVersion := []byte(`{"type":"HELLO","msg_id":1,"version":"9.9"}`) - _, err = ParseHelloFrame(wrongVersion) - if err == nil { - t.Fatalf("expected error for unsupported version") - } -} +// import ( +// "strings" +// "testing" +// ) + +// // --- GetFrameType tests --- + +// // TestGetFrameTypeValid verifies that GetFrameType correctly extracts +// // the frame type and message ID from a valid JSON frame. +// func TestGetFrameTypeValid(t *testing.T) { +// jsonInput := []byte(`{"type":"DATA","msg_id":123}`) +// typeName, msgID, err := GetFrameType(jsonInput) +// if err != nil { +// t.Fatalf("unexpected error: %v", err) +// } +// if typeName != "DATA" || msgID != 123 { +// t.Fatalf("GetFrameType returned wrong values: got %s, %d", typeName, msgID) +// } +// } + +// // TestGetFrameTypeMissingFields ensures that missing required fields +// // (type or msg_id) result in an ERR_02 validation error. +// func TestGetFrameTypeMissingFields(t *testing.T) { +// jsonNoID := []byte(`{"type":"DATA"}`) +// _, _, err := GetFrameType(jsonNoID) +// if err == nil || !strings.Contains(err.Error(), "ERR_02") { +// t.Fatalf("expected ERR_02 for missing msg_id") +// } + +// jsonNoType := []byte(`{"msg_id":123}`) +// _, _, err = GetFrameType(jsonNoType) +// if err == nil || !strings.Contains(err.Error(), "ERR_02") { +// t.Fatalf("expected ERR_02 for missing type") +// } +// } + +// // TestGetFrameTypeInvalidJSON checks that malformed JSON is rejected. +// func TestGetFrameTypeInvalidJSON(t *testing.T) { +// jsonInvalid := []byte(`{invalid json}`) +// _, _, err := GetFrameType(jsonInvalid) +// if err == nil || !strings.Contains(err.Error(), "ERR_02") { +// t.Fatalf("expected ERR_02 for invalid JSON") +// } +// } + +// // TestGetFrameTypeUnknownType verifies that unknown frame types +// // are rejected with ERR_02. +// func TestGetFrameTypeUnknownType(t *testing.T) { +// jsonUnknown := []byte(`{"type":"HACK","msg_id":123}`) +// _, _, err := GetFrameType(jsonUnknown) +// if err == nil || !strings.Contains(err.Error(), "ERR_02") { +// t.Fatalf("expected ERR_02 for unknown frame type") +// } +// } + +// // --- AUTH tests --- + +// // TestParseAuthFrameValidation ensures that missing required fields +// // in AUTH frames (e.g., pass) produce an ERR_02 error. +// func TestParseAuthFrameValidation(t *testing.T) { +// jsonMissingPass := []byte(`{"type":"AUTH","msg_id":123,"user":"alice"}`) +// _, err := ParseAuthFrame(jsonMissingPass) +// if err == nil || !strings.Contains(err.Error(), "ERR_02") { +// t.Fatalf("expected ERR_02 for missing pass") +// } +// } + +// // --- DATA tests --- + +// // TestDataFrameValidation verifies correct parsing of DATA frames, +// // including validation of required fields and rejection of invalid ones. +// func TestDataFrameValidation(t *testing.T) { +// valid := []byte(`{"type":"DATA","msg_id":123,"target":"bob","payload":"SGVsbG8=","mac":"hash"}`) +// f, err := ParseDataFrame(valid) +// if err != nil { +// t.Fatalf("failed to parse valid DataFrame: %v", err) +// } +// if f.Target != "bob" { +// t.Errorf("wrong target") +// } + +// // Sender is allowed (Hub sets it when forwarding) +// withSender := []byte(`{"type":"DATA","msg_id":123,"sender":"alice","target":"bob","payload":"x","mac":"y"}`) +// _, err = ParseDataFrame(withSender) +// if err != nil { +// t.Fatalf("sender should be allowed in forwarded DATA frames: %v", err) +// } + +// // Missing MAC +// missingMac := []byte(`{"type":"DATA","msg_id":123,"target":"bob","payload":"x"}`) +// _, err = ParseDataFrame(missingMac) +// if err == nil { +// t.Fatalf("expected error for missing MAC") +// } +// } + +// // --- STATUS_RES tests --- + +// // TestParseStatusResFrameValidation checks that STATUS_RES frames +// // must include both target and status fields. +// func TestParseStatusResFrameValidation(t *testing.T) { +// jsonMissingStatus := []byte(`{"type":"STATUS_RES","msg_id":123,"target":"alice"}`) +// _, err := ParseStatusResFrame(jsonMissingStatus) +// if err == nil { +// t.Fatalf("expected error for missing status") +// } +// } + +// // --- ERROR tests --- + +// // TestParseErrorFrameValidation ensures that ERROR frames must include +// // an error code. +// func TestParseErrorFrameValidation(t *testing.T) { +// jsonMissingCode := []byte(`{"type":"ERROR","msg_id":123,"desc":"Something went wrong"}`) +// _, err := ParseErrorFrame(jsonMissingCode) +// if err == nil { +// t.Fatalf("expected error for missing code") +// } +// } + +// // --- GET_STATUS tests --- + +// // TestParseGetStatusFrameValidation verifies that GET_STATUS frames +// // must include a target field. +// func TestParseGetStatusFrameValidation(t *testing.T) { +// jsonMissingTarget := []byte(`{"type":"GET_STATUS","msg_id":123}`) +// _, err := ParseGetStatusFrame(jsonMissingTarget) +// if err == nil { +// t.Fatalf("expected error for missing target") +// } +// } + +// // --- HELLO tests --- + +// // TestParseHelloFrameValidation checks HELLO frame validation, +// // including JSON format, version presence, and version compatibility. +// func TestParseHelloFrameValidation(t *testing.T) { +// invalidJSON := []byte(`{"type":"HELLO"`) +// _, err := ParseHelloFrame(invalidJSON) +// if err == nil { +// t.Fatalf("expected error for invalid JSON") +// } + +// missingVersion := []byte(`{"type":"HELLO","msg_id":1}`) +// _, err = ParseHelloFrame(missingVersion) +// if err == nil { +// t.Fatalf("expected error for missing version") +// } + +// wrongVersion := []byte(`{"type":"HELLO","msg_id":1,"version":"9.9"}`) +// _, err = ParseHelloFrame(wrongVersion) +// if err == nil { +// t.Fatalf("expected error for unsupported version") +// } +// } From 8e6b0a664faef508eb223325d71a63b9505858d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Brzezi=C5=84ski?= Date: Mon, 25 May 2026 00:59:48 +0000 Subject: [PATCH 21/44] slightly better test coverage of hub, hub log little fixes --- cover.out | 167 ++++++++++++++++++ hub/bootstrap.go | 29 ++- hub/context_test.go | 26 +++ hub/dispatcher.go | 42 +++-- ...{handlers_test.go => handler_auth_test.go} | 41 +---- hub/handler_bye_test.go | 34 ++++ hub/handler_data_test.go | 28 +++ hub/handler_hello_test.go | 67 +++++++ hub/handler_ping_test.go | 26 +++ hub/handler_register_test.go | 34 ++++ hub/handler_status_test.go | 27 +++ hub/start.go | 17 +- 12 files changed, 482 insertions(+), 56 deletions(-) create mode 100644 cover.out create mode 100644 hub/context_test.go rename hub/{handlers_test.go => handler_auth_test.go} (69%) create mode 100644 hub/handler_bye_test.go create mode 100644 hub/handler_data_test.go create mode 100644 hub/handler_hello_test.go create mode 100644 hub/handler_ping_test.go create mode 100644 hub/handler_register_test.go create mode 100644 hub/handler_status_test.go diff --git a/cover.out b/cover.out new file mode 100644 index 0000000..3932cdd --- /dev/null +++ b/cover.out @@ -0,0 +1,167 @@ +mode: set +github.com/gabbla05/KittyProtocol/hub/bootstrap.go:18.16,22.41 2 0 +github.com/gabbla05/KittyProtocol/hub/bootstrap.go:22.41,24.3 1 0 +github.com/gabbla05/KittyProtocol/hub/bootstrap.go:29.50,33.12 3 0 +github.com/gabbla05/KittyProtocol/hub/bootstrap.go:33.12,44.3 5 0 +github.com/gabbla05/KittyProtocol/hub/context.go:43.35,45.22 1 1 +github.com/gabbla05/KittyProtocol/hub/context.go:45.22,49.33 3 1 +github.com/gabbla05/KittyProtocol/hub/context.go:49.33,51.4 1 1 +github.com/gabbla05/KittyProtocol/hub/context.go:53.3,53.18 1 1 +github.com/gabbla05/KittyProtocol/hub/context.go:57.2,57.24 1 1 +github.com/gabbla05/KittyProtocol/hub/context.go:57.24,60.3 2 0 +github.com/gabbla05/KittyProtocol/hub/context.go:65.33,66.22 1 1 +github.com/gabbla05/KittyProtocol/hub/context.go:66.22,68.3 1 1 +github.com/gabbla05/KittyProtocol/hub/dispatcher.go:19.36,22.16 2 1 +github.com/gabbla05/KittyProtocol/hub/dispatcher.go:22.16,25.3 2 0 +github.com/gabbla05/KittyProtocol/hub/dispatcher.go:26.2,39.6 6 1 +github.com/gabbla05/KittyProtocol/hub/dispatcher.go:39.6,41.17 2 1 +github.com/gabbla05/KittyProtocol/hub/dispatcher.go:41.17,50.57 2 1 +github.com/gabbla05/KittyProtocol/hub/dispatcher.go:50.57,54.5 2 1 +github.com/gabbla05/KittyProtocol/hub/dispatcher.go:57.4,58.10 2 0 +github.com/gabbla05/KittyProtocol/hub/dispatcher.go:61.3,65.32 3 1 +github.com/gabbla05/KittyProtocol/hub/dispatcher.go:65.32,69.41 3 1 +github.com/gabbla05/KittyProtocol/hub/dispatcher.go:69.41,72.5 2 0 +github.com/gabbla05/KittyProtocol/hub/dispatcher.go:73.4,73.12 1 1 +github.com/gabbla05/KittyProtocol/hub/dispatcher.go:76.3,79.19 2 1 +github.com/gabbla05/KittyProtocol/hub/dispatcher.go:80.32,81.24 1 1 +github.com/gabbla05/KittyProtocol/hub/dispatcher.go:82.31,83.23 1 1 +github.com/gabbla05/KittyProtocol/hub/dispatcher.go:84.35,85.27 1 0 +github.com/gabbla05/KittyProtocol/hub/dispatcher.go:86.31,87.23 1 0 +github.com/gabbla05/KittyProtocol/hub/dispatcher.go:88.31,89.23 1 1 +github.com/gabbla05/KittyProtocol/hub/dispatcher.go:90.36,91.28 1 0 +github.com/gabbla05/KittyProtocol/hub/dispatcher.go:92.30,94.10 2 0 +github.com/gabbla05/KittyProtocol/hub/dispatcher.go:95.11,96.68 1 0 +github.com/gabbla05/KittyProtocol/hub/errors.go:18.61,29.16 3 1 +github.com/gabbla05/KittyProtocol/hub/errors.go:29.16,32.3 2 0 +github.com/gabbla05/KittyProtocol/hub/errors.go:34.2,34.43 1 1 +github.com/gabbla05/KittyProtocol/hub/errors.go:34.43,36.3 1 0 +github.com/gabbla05/KittyProtocol/hub/fake_stream.go:10.51,13.2 2 1 +github.com/gabbla05/KittyProtocol/hub/fake_stream.go:15.50,15.70 1 0 +github.com/gabbla05/KittyProtocol/hub/fake_stream.go:16.50,16.64 1 0 +github.com/gabbla05/KittyProtocol/hub/handler_auth.go:17.48,18.35 1 1 +github.com/gabbla05/KittyProtocol/hub/handler_auth.go:18.35,21.3 2 1 +github.com/gabbla05/KittyProtocol/hub/handler_auth.go:23.2,24.16 2 1 +github.com/gabbla05/KittyProtocol/hub/handler_auth.go:24.16,27.3 2 0 +github.com/gabbla05/KittyProtocol/hub/handler_auth.go:30.2,30.24 1 1 +github.com/gabbla05/KittyProtocol/hub/handler_auth.go:30.24,33.3 2 1 +github.com/gabbla05/KittyProtocol/hub/handler_auth.go:36.2,36.58 1 1 +github.com/gabbla05/KittyProtocol/hub/handler_auth.go:36.58,39.3 2 1 +github.com/gabbla05/KittyProtocol/hub/handler_auth.go:42.2,42.41 1 1 +github.com/gabbla05/KittyProtocol/hub/handler_auth.go:42.41,45.3 2 0 +github.com/gabbla05/KittyProtocol/hub/handler_auth.go:48.2,65.16 8 1 +github.com/gabbla05/KittyProtocol/hub/handler_auth.go:65.16,68.3 2 0 +github.com/gabbla05/KittyProtocol/hub/handler_auth.go:70.2,70.45 1 1 +github.com/gabbla05/KittyProtocol/hub/handler_auth.go:70.45,72.3 1 0 +github.com/gabbla05/KittyProtocol/hub/handler_bye.go:10.47,11.35 1 1 +github.com/gabbla05/KittyProtocol/hub/handler_bye.go:11.35,14.3 2 0 +github.com/gabbla05/KittyProtocol/hub/handler_bye.go:16.2,16.55 1 1 +github.com/gabbla05/KittyProtocol/hub/handler_bye.go:16.55,19.3 2 0 +github.com/gabbla05/KittyProtocol/hub/handler_bye.go:21.2,25.52 3 1 +github.com/gabbla05/KittyProtocol/hub/handler_bye.go:25.52,27.3 1 0 +github.com/gabbla05/KittyProtocol/hub/handler_bye.go:29.2,30.21 2 1 +github.com/gabbla05/KittyProtocol/hub/handler_data.go:13.39,15.2 1 1 +github.com/gabbla05/KittyProtocol/hub/handler_data.go:17.48,18.35 1 1 +github.com/gabbla05/KittyProtocol/hub/handler_data.go:18.35,21.3 2 1 +github.com/gabbla05/KittyProtocol/hub/handler_data.go:23.2,24.16 2 1 +github.com/gabbla05/KittyProtocol/hub/handler_data.go:24.16,27.3 2 0 +github.com/gabbla05/KittyProtocol/hub/handler_data.go:30.2,33.22 2 1 +github.com/gabbla05/KittyProtocol/hub/handler_data.go:33.22,36.3 2 0 +github.com/gabbla05/KittyProtocol/hub/handler_data.go:39.2,39.32 1 1 +github.com/gabbla05/KittyProtocol/hub/handler_data.go:39.32,42.3 2 0 +github.com/gabbla05/KittyProtocol/hub/handler_data.go:45.2,45.48 1 1 +github.com/gabbla05/KittyProtocol/hub/handler_data.go:45.48,48.3 2 1 +github.com/gabbla05/KittyProtocol/hub/handler_data.go:51.2,54.45 2 1 +github.com/gabbla05/KittyProtocol/hub/handler_data.go:54.45,56.3 1 1 +github.com/gabbla05/KittyProtocol/hub/handler_data.go:59.2,68.16 3 1 +github.com/gabbla05/KittyProtocol/hub/handler_data.go:68.16,71.3 2 0 +github.com/gabbla05/KittyProtocol/hub/handler_data.go:73.2,73.45 1 1 +github.com/gabbla05/KittyProtocol/hub/handler_data.go:73.45,75.3 1 0 +github.com/gabbla05/KittyProtocol/hub/handler_hello.go:13.49,14.26 1 1 +github.com/gabbla05/KittyProtocol/hub/handler_hello.go:14.26,17.3 2 1 +github.com/gabbla05/KittyProtocol/hub/handler_hello.go:19.2,20.16 2 1 +github.com/gabbla05/KittyProtocol/hub/handler_hello.go:20.16,23.3 2 1 +github.com/gabbla05/KittyProtocol/hub/handler_hello.go:25.2,39.16 5 1 +github.com/gabbla05/KittyProtocol/hub/handler_hello.go:39.16,42.3 2 0 +github.com/gabbla05/KittyProtocol/hub/handler_hello.go:44.2,44.45 1 1 +github.com/gabbla05/KittyProtocol/hub/handler_hello.go:44.45,46.3 1 0 +github.com/gabbla05/KittyProtocol/hub/handler_hello.go:49.2,49.49 1 1 +github.com/gabbla05/KittyProtocol/hub/handler_hello.go:49.49,52.3 2 0 +github.com/gabbla05/KittyProtocol/hub/handler_ping.go:10.48,11.35 1 1 +github.com/gabbla05/KittyProtocol/hub/handler_ping.go:11.35,14.3 2 0 +github.com/gabbla05/KittyProtocol/hub/handler_ping.go:16.2,16.56 1 1 +github.com/gabbla05/KittyProtocol/hub/handler_ping.go:16.56,19.3 2 0 +github.com/gabbla05/KittyProtocol/hub/handler_ping.go:21.2,21.11 1 1 +github.com/gabbla05/KittyProtocol/hub/handler_register.go:13.52,14.35 1 1 +github.com/gabbla05/KittyProtocol/hub/handler_register.go:14.35,17.3 2 0 +github.com/gabbla05/KittyProtocol/hub/handler_register.go:19.2,20.16 2 1 +github.com/gabbla05/KittyProtocol/hub/handler_register.go:20.16,23.3 2 0 +github.com/gabbla05/KittyProtocol/hub/handler_register.go:25.2,25.68 1 1 +github.com/gabbla05/KittyProtocol/hub/handler_register.go:25.68,28.3 2 1 +github.com/gabbla05/KittyProtocol/hub/handler_register.go:30.2,41.16 4 0 +github.com/gabbla05/KittyProtocol/hub/handler_register.go:41.16,44.3 2 0 +github.com/gabbla05/KittyProtocol/hub/handler_register.go:46.2,46.45 1 0 +github.com/gabbla05/KittyProtocol/hub/handler_register.go:46.45,48.3 1 0 +github.com/gabbla05/KittyProtocol/hub/handler_status.go:13.53,14.35 1 1 +github.com/gabbla05/KittyProtocol/hub/handler_status.go:14.35,17.3 2 0 +github.com/gabbla05/KittyProtocol/hub/handler_status.go:19.2,20.16 2 1 +github.com/gabbla05/KittyProtocol/hub/handler_status.go:20.16,23.3 2 0 +github.com/gabbla05/KittyProtocol/hub/handler_status.go:25.2,28.37 3 1 +github.com/gabbla05/KittyProtocol/hub/handler_status.go:28.37,30.3 1 1 +github.com/gabbla05/KittyProtocol/hub/handler_status.go:32.2,42.16 3 1 +github.com/gabbla05/KittyProtocol/hub/handler_status.go:42.16,45.3 2 0 +github.com/gabbla05/KittyProtocol/hub/handler_status.go:47.2,47.45 1 1 +github.com/gabbla05/KittyProtocol/hub/handler_status.go:47.45,49.3 1 0 +github.com/gabbla05/KittyProtocol/hub/logger.go:21.42,25.19 3 1 +github.com/gabbla05/KittyProtocol/hub/logger.go:25.19,26.16 1 1 +github.com/gabbla05/KittyProtocol/hub/logger.go:27.15,28.21 1 1 +github.com/gabbla05/KittyProtocol/hub/logger.go:29.15,30.23 1 0 +github.com/gabbla05/KittyProtocol/hub/logger.go:31.16,32.20 1 0 +github.com/gabbla05/KittyProtocol/hub/logger.go:33.11,34.22 1 0 +github.com/gabbla05/KittyProtocol/hub/logger.go:39.2,39.20 1 1 +github.com/gabbla05/KittyProtocol/hub/logger.go:39.20,43.3 2 0 +github.com/gabbla05/KittyProtocol/hub/logger.go:46.2,47.66 1 1 +github.com/gabbla05/KittyProtocol/hub/logger.go:50.40,50.69 1 1 +github.com/gabbla05/KittyProtocol/hub/logger.go:51.40,51.69 1 0 +github.com/gabbla05/KittyProtocol/hub/logger.go:52.40,52.70 1 0 +github.com/gabbla05/KittyProtocol/hub/router.go:17.107,20.9 2 1 +github.com/gabbla05/KittyProtocol/hub/router.go:20.9,24.3 2 1 +github.com/gabbla05/KittyProtocol/hub/router.go:26.2,26.30 1 1 +github.com/gabbla05/KittyProtocol/hub/router.go:26.30,30.3 2 1 +github.com/gabbla05/KittyProtocol/hub/router.go:33.2,50.16 6 1 +github.com/gabbla05/KittyProtocol/hub/router.go:50.16,54.3 3 0 +github.com/gabbla05/KittyProtocol/hub/router.go:56.2,56.55 1 1 +github.com/gabbla05/KittyProtocol/hub/router.go:56.55,60.3 3 0 +github.com/gabbla05/KittyProtocol/hub/router.go:62.2,62.13 1 1 +github.com/gabbla05/KittyProtocol/hub/start.go:30.14,35.16 3 0 +github.com/gabbla05/KittyProtocol/hub/start.go:35.16,38.3 2 0 +github.com/gabbla05/KittyProtocol/hub/start.go:41.2,42.15 2 0 +github.com/gabbla05/KittyProtocol/hub/start.go:42.15,44.3 1 0 +github.com/gabbla05/KittyProtocol/hub/start.go:46.2,47.16 2 0 +github.com/gabbla05/KittyProtocol/hub/start.go:47.16,50.3 2 0 +github.com/gabbla05/KittyProtocol/hub/start.go:51.2,66.16 6 0 +github.com/gabbla05/KittyProtocol/hub/start.go:66.16,68.3 1 0 +github.com/gabbla05/KittyProtocol/hub/start.go:71.2,72.16 2 0 +github.com/gabbla05/KittyProtocol/hub/start.go:72.16,75.3 2 0 +github.com/gabbla05/KittyProtocol/hub/start.go:77.2,83.6 3 0 +github.com/gabbla05/KittyProtocol/hub/start.go:83.6,85.17 2 0 +github.com/gabbla05/KittyProtocol/hub/start.go:85.17,88.4 2 0 +github.com/gabbla05/KittyProtocol/hub/start.go:91.3,91.24 1 0 +github.com/gabbla05/KittyProtocol/hub/start_test-hub.go:19.59,21.54 1 1 +github.com/gabbla05/KittyProtocol/hub/start_test-hub.go:21.54,23.3 1 0 +github.com/gabbla05/KittyProtocol/hub/start_test-hub.go:25.2,26.16 2 1 +github.com/gabbla05/KittyProtocol/hub/start_test-hub.go:26.16,28.3 1 0 +github.com/gabbla05/KittyProtocol/hub/start_test-hub.go:31.2,31.27 1 1 +github.com/gabbla05/KittyProtocol/hub/start_test-hub.go:31.27,33.3 1 0 +github.com/gabbla05/KittyProtocol/hub/start_test-hub.go:35.2,45.16 5 1 +github.com/gabbla05/KittyProtocol/hub/start_test-hub.go:45.16,47.3 1 0 +github.com/gabbla05/KittyProtocol/hub/start_test-hub.go:49.2,52.12 2 1 +github.com/gabbla05/KittyProtocol/hub/start_test-hub.go:52.12,53.7 1 1 +github.com/gabbla05/KittyProtocol/hub/start_test-hub.go:53.7,55.18 2 1 +github.com/gabbla05/KittyProtocol/hub/start_test-hub.go:55.18,57.5 1 1 +github.com/gabbla05/KittyProtocol/hub/start_test-hub.go:58.4,58.25 1 1 +github.com/gabbla05/KittyProtocol/hub/start_test-hub.go:62.2,62.16 1 1 +github.com/gabbla05/KittyProtocol/hub/start_test-hub.go:62.16,66.3 2 1 +github.com/gabbla05/KittyProtocol/hub/start_test-hub.go:68.2,68.44 1 1 +github.com/gabbla05/KittyProtocol/hub/stream_adapter.go:12.58,12.81 1 0 +github.com/gabbla05/KittyProtocol/hub/stream_adapter.go:13.58,13.80 1 0 +github.com/gabbla05/KittyProtocol/hub/stream_adapter.go:14.58,14.80 1 0 diff --git a/hub/bootstrap.go b/hub/bootstrap.go index e29c02e..ff46f32 100644 --- a/hub/bootstrap.go +++ b/hub/bootstrap.go @@ -5,6 +5,7 @@ package hub import ( + "context" "os" "os/signal" "syscall" @@ -24,22 +25,38 @@ func loadEnv() { } } -// setupSignalHandler installs OS signal handlers for graceful shutdown. -// On SIGINT/SIGTERM, all sessions are terminated and the QUIC listener is closed. -func setupSignalHandler(listener *quic.Listener) { +// setupSignalHandler installs OS signal handlers and returns a context that is +// cancelled when the server should shut down. +// +// When SIGINT, SIGTERM, or SIGQUIT is received: +// - all active sessions are terminated via globalSessions.Stop() +// - the QUIC listener is closed (causing Accept() to unblock) +// - the returned context is cancelled, allowing the accept loop to exit +// +// This enables a fully graceful shutdown of the Hub server. +func setupSignalHandler(listener *quic.Listener) context.Context { + ctx, cancel := context.WithCancel(context.Background()) + sigCh := make(chan os.Signal, 1) signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM, syscall.SIGQUIT) go func() { sig := <-sigCh - logWarn("Caught signal: %v", sig) + logWarn("Caught signal: %v — initiating graceful shutdown", sig) // Stop all active sessions globalSessions.Stop() - // Close QUIC listener - _ = listener.Close() + // Close QUIC listener (unblocks Accept) + if err := listener.Close(); err != nil { + logError("Error closing listener: %v", err) + } + + // Cancel context to stop Accept loop + cancel() logInfo("Graceful shutdown complete.") }() + + return ctx } diff --git a/hub/context_test.go b/hub/context_test.go new file mode 100644 index 0000000..6182c23 --- /dev/null +++ b/hub/context_test.go @@ -0,0 +1,26 @@ +package hub + +import ( + "testing" + + "github.com/gabbla05/KittyProtocol/internal/protection" +) + +func TestClientContextCleanup(t *testing.T) { + globalSessions = protection.NewSessionManager() + globalSessions.Add("alice", &protection.Session{ID: "alice"}) + + sess := &protection.Session{ID: "alice"} + globalSessions.Add("alice", sess) + + c := &clientContext{ + username: "alice", + session: sess, + } + + c.cleanup() + + if _, ok := globalSessions.Get("alice"); ok { + t.Fatalf("cleanup should remove session") + } +} diff --git a/hub/dispatcher.go b/hub/dispatcher.go index 8f69654..aeb7d46 100644 --- a/hub/dispatcher.go +++ b/hub/dispatcher.go @@ -1,26 +1,32 @@ // dispatcher.go -// Central QUIC stream dispatcher. Reads raw frames from the client stream, -// determines their type, and forwards them to the appropriate handler. -// This file contains no business logic — only frame routing and connection lifecycle. +// Central QUIC stream dispatcher. Continuously reads raw frames from the +// client stream, determines their type, and forwards them to the appropriate +// handler. This file contains no business logic — only frame routing and +// connection lifecycle management. package hub import ( "context" + "errors" "io" + "strings" "github.com/gabbla05/KittyProtocol/protocol" "github.com/quic-go/quic-go" ) func handleClient(conn *quic.Conn) { + // Accept a bidirectional stream from the client. stream, err := conn.AcceptStream(context.Background()) if err != nil { + // This is a real error — client failed to open a stream. logError("Stream accept error: %v", err) return } defer stream.Close() + // Per-client context used by handlers. ctx := &clientContext{ conn: conn, stream: stream, @@ -34,15 +40,28 @@ func handleClient(conn *quic.Conn) { for { n, err := stream.Read(buf) if err != nil { - if err != io.EOF { - logError("Stream read error: %v", err) + + // --- NORMAL STREAM TERMINATION --- + // QUIC-go uses ApplicationError for remote close. + // These are NOT real errors — they indicate the client closed the stream. + var appErr *quic.ApplicationError + if err == io.EOF || + errors.As(err, &appErr) || + strings.Contains(err.Error(), "client closed") || + strings.Contains(err.Error(), "canceled by remote") { + + logInfo("[Client] Stream closed by remote: %v", err) + return } + + // --- REAL ERROR --- + logError("Stream read error: %v", err) return } raw := buf[:n] - // Determine frame type + // Determine frame type and validate header. typeName, msgID, perr := protocol.GetFrameType(raw) if perr != nil || msgID <= 0 { formatErrCount++ @@ -55,34 +74,25 @@ func handleClient(conn *quic.Conn) { continue } - // Reset format error counter on valid frame formatErrCount = 0 - // Dispatch to handler + // Dispatch frame to the appropriate handler. switch typeName { - case protocol.FrameTypeHello: ctx.handleHello(raw) - case protocol.FrameTypeAuth: ctx.handleAuth(raw) - case protocol.FrameTypeRegister: ctx.handleRegister(raw) - case protocol.FrameTypePing: ctx.handlePing(raw) - case protocol.FrameTypeData: ctx.handleData(raw) - case protocol.FrameTypeGetStatus: ctx.handleGetStatus(raw) - case protocol.FrameTypeBye: ctx.handleBye(raw) return - default: sendError(stream, protocol.ErrFormatError, "Unknown frame type") } diff --git a/hub/handlers_test.go b/hub/handler_auth_test.go similarity index 69% rename from hub/handlers_test.go rename to hub/handler_auth_test.go index 8e0d4e0..aac0c21 100644 --- a/hub/handlers_test.go +++ b/hub/handler_auth_test.go @@ -9,30 +9,6 @@ import ( "github.com/gabbla05/KittyProtocol/protocol" ) -// --- HELLO tests --- - -func TestHandleHello(t *testing.T) { - // We must provide a fake stream, otherwise handleHello will panic - c := &clientContext{ - state: stateInit, - stream: &fakeStream{}, - } - - frame := protocol.HelloFrame{ - BaseFrame: protocol.BaseFrame{Type: protocol.FrameTypeHello, MsgID: 1}, - Version: "1.0", - } - raw, _ := json.Marshal(frame) - - c.handleHello(raw) - - if c.state != stateHelloReceived { - t.Fatalf("HELLO should transition to stateHelloReceived") - } -} - -// --- AUTH tests --- - func TestHandleAuthBeforeHello(t *testing.T) { globalAuth = auth.NewMockAuth() @@ -104,24 +80,25 @@ func TestHandleAuthSuccess(t *testing.T) { } } -// --- DATA tests --- +func TestHandleAuthUnknownUser(t *testing.T) { + globalAuth = auth.NewMockAuth() + globalSessions = protection.NewSessionManager() -func TestHandleDataBeforeAuth(t *testing.T) { c := &clientContext{ state: stateHelloReceived, stream: &fakeStream{}, } - frame := protocol.DataFrame{ - BaseFrame: protocol.BaseFrame{Type: protocol.FrameTypeData, MsgID: 1}, - Target: "bob", - Payload: "abc", + frame := protocol.AuthFrame{ + BaseFrame: protocol.BaseFrame{Type: protocol.FrameTypeAuth, MsgID: 1}, + User: "ghost", + Pass: "whatever", } raw, _ := json.Marshal(frame) - c.handleData(raw) + c.handleAuth(raw) if c.state != stateHelloReceived { - t.Fatalf("DATA before AUTH should not change state") + t.Fatalf("AUTH with unknown user should not authenticate") } } diff --git a/hub/handler_bye_test.go b/hub/handler_bye_test.go new file mode 100644 index 0000000..32f2504 --- /dev/null +++ b/hub/handler_bye_test.go @@ -0,0 +1,34 @@ +package hub + +import ( + "encoding/json" + "testing" + + "github.com/gabbla05/KittyProtocol/internal/protection" + "github.com/gabbla05/KittyProtocol/protocol" +) + +func TestHandleBye(t *testing.T) { + globalSessions = protection.NewSessionManager() + + sess := &protection.Session{ID: "alice"} + globalSessions.Add("alice", sess) + + c := &clientContext{ + username: "alice", + session: sess, + stream: &fakeStream{}, + state: stateAuthenticated, // BYE requires AUTH + } + + frame := protocol.ByeFrame{ + BaseFrame: protocol.BaseFrame{Type: protocol.FrameTypeBye, MsgID: 1}, + } + raw, _ := json.Marshal(frame) + + c.handleBye(raw) + + if _, ok := globalSessions.Get("alice"); ok { + t.Fatalf("BYE should remove session") + } +} diff --git a/hub/handler_data_test.go b/hub/handler_data_test.go new file mode 100644 index 0000000..45fb6cb --- /dev/null +++ b/hub/handler_data_test.go @@ -0,0 +1,28 @@ +package hub + +import ( + "encoding/json" + "testing" + + "github.com/gabbla05/KittyProtocol/protocol" +) + +func TestHandleDataBeforeAuth(t *testing.T) { + c := &clientContext{ + state: stateHelloReceived, + stream: &fakeStream{}, + } + + frame := protocol.DataFrame{ + BaseFrame: protocol.BaseFrame{Type: protocol.FrameTypeData, MsgID: 1}, + Target: "bob", + Payload: "abc", + } + raw, _ := json.Marshal(frame) + + c.handleData(raw) + + if c.state != stateHelloReceived { + t.Fatalf("DATA before AUTH should not change state") + } +} diff --git a/hub/handler_hello_test.go b/hub/handler_hello_test.go new file mode 100644 index 0000000..d574699 --- /dev/null +++ b/hub/handler_hello_test.go @@ -0,0 +1,67 @@ +package hub + +import ( + "encoding/json" + "testing" + + "github.com/gabbla05/KittyProtocol/protocol" +) + +func TestHandleHello(t *testing.T) { + // We must provide a fake stream, otherwise handleHello will panic + c := &clientContext{ + state: stateInit, + stream: &fakeStream{}, + } + + frame := protocol.HelloFrame{ + BaseFrame: protocol.BaseFrame{Type: protocol.FrameTypeHello, MsgID: 1}, + Version: "1.0", + } + raw, _ := json.Marshal(frame) + + c.handleHello(raw) + + if c.state != stateHelloReceived { + t.Fatalf("HELLO should transition to stateHelloReceived") + } +} + +func TestHandleHelloWrongVersion(t *testing.T) { + c := &clientContext{ + state: stateInit, + stream: &fakeStream{}, + } + + frame := protocol.HelloFrame{ + BaseFrame: protocol.BaseFrame{Type: protocol.FrameTypeHello, MsgID: 1}, + Version: "9.9", + } + raw, _ := json.Marshal(frame) + + c.handleHello(raw) + + if c.state != stateInit { + t.Fatalf("HELLO with wrong version should not change state") + } +} + +func TestHandleHelloTwice(t *testing.T) { + c := &clientContext{ + state: stateInit, + stream: &fakeStream{}, + } + + frame := protocol.HelloFrame{ + BaseFrame: protocol.BaseFrame{Type: protocol.FrameTypeHello, MsgID: 1}, + Version: "1.0", + } + raw, _ := json.Marshal(frame) + + c.handleHello(raw) + c.handleHello(raw) // drugi raz + + if c.state != stateHelloReceived { + t.Fatalf("Second HELLO should not break state machine") + } +} diff --git a/hub/handler_ping_test.go b/hub/handler_ping_test.go new file mode 100644 index 0000000..07d8331 --- /dev/null +++ b/hub/handler_ping_test.go @@ -0,0 +1,26 @@ +package hub + +import ( + "encoding/json" + "testing" + + "github.com/gabbla05/KittyProtocol/protocol" +) + +func TestHandlePing(t *testing.T) { + c := &clientContext{ + state: stateAuthenticated, + stream: &fakeStream{}, + } + + frame := protocol.PingFrame{ + BaseFrame: protocol.BaseFrame{Type: protocol.FrameTypePing, MsgID: 1}, + } + raw, _ := json.Marshal(frame) + + c.handlePing(raw) + + if c.state != stateAuthenticated { + t.Fatalf("PING should not change state") + } +} diff --git a/hub/handler_register_test.go b/hub/handler_register_test.go new file mode 100644 index 0000000..bca1c65 --- /dev/null +++ b/hub/handler_register_test.go @@ -0,0 +1,34 @@ +package hub + +import ( + "encoding/json" + "testing" + + "github.com/gabbla05/KittyProtocol/internal/auth" + "github.com/gabbla05/KittyProtocol/internal/protection" + "github.com/gabbla05/KittyProtocol/protocol" +) + +func TestHandleRegisterSuccess(t *testing.T) { + globalAuth = auth.NewMockAuth() + globalSessions = protection.NewSessionManager() + + c := &clientContext{ + state: stateHelloReceived, + stream: &fakeStream{}, + } + + frame := protocol.AuthFrame{ + BaseFrame: protocol.BaseFrame{Type: protocol.FrameTypeRegister, MsgID: 1}, + User: "alice", + Pass: "secret", + } + raw, _ := json.Marshal(frame) + + c.handleRegister(raw) + + // REGISTER should NOT authenticate user + if c.state != stateHelloReceived { + t.Fatalf("REGISTER should NOT authenticate user") + } +} diff --git a/hub/handler_status_test.go b/hub/handler_status_test.go new file mode 100644 index 0000000..fb1add7 --- /dev/null +++ b/hub/handler_status_test.go @@ -0,0 +1,27 @@ +package hub + +import ( + "encoding/json" + "testing" + + "github.com/gabbla05/KittyProtocol/internal/protection" + "github.com/gabbla05/KittyProtocol/protocol" +) + +func TestHandleGetStatus(t *testing.T) { + globalSessions = protection.NewSessionManager() + globalSessions.Add("bob", &protection.Session{ID: "bob"}) + + c := &clientContext{ + state: stateAuthenticated, + stream: &fakeStream{}, + } + + frame := protocol.GetStatusFrame{ + BaseFrame: protocol.BaseFrame{Type: protocol.FrameTypeGetStatus, MsgID: 1}, + Target: "bob", + } + raw, _ := json.Marshal(frame) + + c.handleGetStatus(raw) +} diff --git a/hub/start.go b/hub/start.go index d910c4c..75acb21 100644 --- a/hub/start.go +++ b/hub/start.go @@ -8,7 +8,9 @@ package hub import ( "context" "database/sql" + "errors" "os" + "strings" "github.com/gabbla05/KittyProtocol/internal/auth" "github.com/gabbla05/KittyProtocol/internal/certmanager" @@ -77,12 +79,23 @@ func Start() { logInfo("🐈 KittyProtocol Hub listening on %s", addr) // Handle SIGINT/SIGTERM for graceful shutdown. - setupSignalHandler(listener) + ctx := setupSignalHandler(listener) // Accept incoming QUIC connections. for { - conn, err := listener.Accept(context.Background()) + conn, err := listener.Accept(ctx) if err != nil { + + // --- NORMAL SERVER SHUTDOWN --- + // When listener.Close() is called, Accept() MUST return an error. + // This is not a failure — it's how QUIC-go signals shutdown. + if errors.Is(err, context.Canceled) || + strings.Contains(err.Error(), "server closed") { + logInfo("Accept loop stopped gracefully: %v", err) + return + } + + // --- REAL ERROR --- logError("Accept error: %v", err) return } From 0a105b46c780845103e166774ee10c33efc4e212 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Brzezi=C5=84ski?= Date: Mon, 25 May 2026 01:17:52 +0000 Subject: [PATCH 22/44] client program start moved to cmd --- TODO | 32 +++++++++---- client/main.go | 114 ++++++++++++++++++++++----------------------- client/start.go | 79 +++++++++++++++++++++++++++++++ cmd/client/main.go | 9 ++++ 4 files changed, 167 insertions(+), 67 deletions(-) create mode 100644 client/start.go create mode 100644 cmd/client/main.go diff --git a/TODO b/TODO index 0639a73..bf9402a 100644 --- a/TODO +++ b/TODO @@ -1,14 +1,16 @@ - przy prezentowaniu gotowego projektu można pokazać że na warstwie protokołu działa operacja /replay która symuluje ten atak -- TESTY OGÓLNIE - LEPIEJ POMYŚLANE, MOŻĘ JAKOŚ LEPIEJ USTRUKTURYZOWAĆ -- ŁADOWANIE KLUCZY KONWERSACJI Z PLIKU! -- brak mockowania!! zrobic w końcu normalne połączenie z bazą danych i tam przechowywanie, a nie tak jak teraz `internal/auth/auth.go` -- sprawdzić czy kody błędów się zgadzają z dokumentacją + + - info dodatkowe: jak nie ma usera to gdy pytamy o jego status, to mimo to pokazuje offline gdy pytamy czy jest - brak zdradzania czy user istnieje -- CURRENT_PROJECT do zmiany -- dać kroki dla dewelopera któ©y ma stestować działanie bazy - jakie komendy, co usunąć itp. -- jaki jest sens registerFrame zamiast po prostu zrobić parse registerframe na authframe skoro to dosłownie ta sama ramka -- forma przechowywania sekretóœ lokalnie - słabe szyfrowanie chyba bo widać że sekrety są te same. Zamiast tego zastanowić się nad hashowaniem +- CURRENT_PROJECT do zmiany i ogólnie inne markdowny też +- dać kroki dla dewelopera który ma stestować działanie bazy - jakie komendy, co usunąć itp. + +- deweloperom dać znać że sekrety są zapisywane lokalnie w katalogu domowym + +CLIENT +====== +- forma przechowywania sekretów lokalnie - słabe szyfrowanie chyba bo widać że sekrety są te same. Zamiast tego zastanowić się nad hashowaniem - !!!!WAŻNE!!! plik ze stałymi (W tym z dokumentacji) aby w kodzie nie używać magicznych liczb, ale konkretnych stałych @@ -17,6 +19,16 @@ - Uważać czy wszędzie gdzie trzeba jest: _ "github.com/lib/pq" !!!!! -- przeneisienie uruchomienia klienta do cmd - możliwość usunięcia konta poprzez /delete lub /remove z opcją \ i wtedy powinno poprosić o uwierzytelnienie (wpisanie hasła do logowania w ramach zatwerdzenia). Po zakończeniu operacji powinno wyjść jak za pomocą /quit + usunąć rekord z userem z bazy danych -- update cross-networkowych rzeczy (przetestowanie i markdown update) \ No newline at end of file + +- update cross-networkowych rzeczy (przetestowanie i markdown update) + +- kolorowa konsola klienta + +- dlaczego [Client: Receive] Parse error: ERR_02: JSON parsing error przy wysyłaniu do samego siebie, powinna być możliwość zablokowania wysyłania do siebie + +- TESTY OGÓLNIE - LEPIEJ POMYŚLANE, MOŻĘ JAKOŚ LEPIEJ USTRUKTURYZOWAĆ + +- ŁADOWANIE KLUCZY KONWERSACJI Z PLIKU! (opcjonalnie na końcu) + +- gotowe pod frontend \ No newline at end of file diff --git a/client/main.go b/client/main.go index 781878c..388ac1b 100644 --- a/client/main.go +++ b/client/main.go @@ -1,76 +1,76 @@ -package main +package client -import ( - "os" - "os/signal" - "syscall" +// import ( +// "os" +// "os/signal" +// "syscall" - "github.com/gabbla05/KittyProtocol/client/api" - "github.com/gabbla05/KittyProtocol/client/app" - "github.com/gabbla05/KittyProtocol/client/ui_cli" -) +// "github.com/gabbla05/KittyProtocol/client/api" +// "github.com/gabbla05/KittyProtocol/client/app" +// "github.com/gabbla05/KittyProtocol/client/ui_cli" +// ) -func main() { - client := api.NewKittyClient() - ui := ui_cli.NewCliUI(client) +// func main() { +// client := api.NewKittyClient() +// ui := ui_cli.NewCliUI(client) - disconnected := make(chan struct{}) +// disconnected := make(chan struct{}) - hubAddr := os.Getenv("KITTY_HUB_ADDR") - if hubAddr == "" { - hubAddr = "127.0.0.1:9999" - } +// hubAddr := os.Getenv("KITTY_HUB_ADDR") +// if hubAddr == "" { +// hubAddr = "127.0.0.1:9999" +// } - ui.Println("[Client] Connecting to Hub:", hubAddr) +// ui.Println("[Client] Connecting to Hub:", hubAddr) - if err := client.Connect(hubAddr); err != nil { - ui.Println("[Client] Connection error:", err) - return - } +// if err := client.Connect(hubAddr); err != nil { +// ui.Println("[Client] Connection error:", err) +// return +// } - if err := client.WaitForHelloOK(); err != nil { - ui.Println("[Client] HELLO failed:", err) - client.Close() - return - } +// if err := client.WaitForHelloOK(); err != nil { +// ui.Println("[Client] HELLO failed:", err) +// client.Close() +// return +// } - // ------------------------------- - // AUTH FLOW (CLI-specific) - // ------------------------------- - if err := ui.RunAuthFlow(client); err == ui_cli.ErrQuitRequested { - client.Close() - return - } +// // ------------------------------- +// // AUTH FLOW (CLI-specific) +// // ------------------------------- +// if err := ui.RunAuthFlow(client); err == ui_cli.ErrQuitRequested { +// client.Close() +// return +// } - // ------------------------------- - // AUTH SUCCESS → start application - // ------------------------------- +// // ------------------------------- +// // AUTH SUCCESS → start application +// // ------------------------------- - application := app.NewApp(client, ui, disconnected) +// application := app.NewApp(client, ui, disconnected) - application.InitSecretStoreForUser(client.User()) +// application.InitSecretStoreForUser(client.User()) - client.RegisterAckHandler(ui) - client.RegisterAppPayloadHandler(application.HandleIncomingPayload) +// client.RegisterAckHandler(ui) +// client.RegisterAppPayloadHandler(application.HandleIncomingPayload) - setupSignalHandler(client) +// setupSignalHandler(client) - client.StartReceiverLoop(disconnected) - client.StartPingLoop() +// client.StartReceiverLoop(disconnected) +// client.StartPingLoop() - ui.RunMainMenu(application) +// ui.RunMainMenu(application) - client.Close() -} +// client.Close() +// } -func setupSignalHandler(client *api.KittyClient) { - sigCh := make(chan os.Signal, 1) - signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM, syscall.SIGQUIT) +// func setupSignalHandler(client *api.KittyClient) { +// sigCh := make(chan os.Signal, 1) +// signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM, syscall.SIGQUIT) - go func() { - <-sigCh - _ = client.SendBye() - client.Close() - os.Exit(0) - }() -} +// go func() { +// <-sigCh +// _ = client.SendBye() +// client.Close() +// os.Exit(0) +// }() +// } diff --git a/client/start.go b/client/start.go new file mode 100644 index 0000000..130d0de --- /dev/null +++ b/client/start.go @@ -0,0 +1,79 @@ +package client + +import ( + "os" + "os/signal" + "syscall" + + "github.com/gabbla05/KittyProtocol/client/api" + "github.com/gabbla05/KittyProtocol/client/app" + "github.com/gabbla05/KittyProtocol/client/ui_cli" +) + +// Start is the main entrypoint for the KittyProtocol CLI client. +// It performs connection, HELLO handshake, AUTH flow, and launches the app. +func Start() { + client := api.NewKittyClient() + ui := ui_cli.NewCliUI(client) + + disconnected := make(chan struct{}) + + hubAddr := os.Getenv("KITTY_HUB_ADDR") + if hubAddr == "" { + hubAddr = "127.0.0.1:9999" + } + + ui.Println("[Client] Connecting to Hub:", hubAddr) + + if err := client.Connect(hubAddr); err != nil { + ui.Println("[Client] Connection error:", err) + return + } + + if err := client.WaitForHelloOK(); err != nil { + ui.Println("[Client] HELLO failed:", err) + client.Close() + return + } + + // ------------------------------- + // AUTH FLOW (CLI-specific) + // ------------------------------- + if err := ui.RunAuthFlow(client); err == ui_cli.ErrQuitRequested { + client.Close() + return + } + + // ------------------------------- + // AUTH SUCCESS → start application + // ------------------------------- + + application := app.NewApp(client, ui, disconnected) + + application.InitSecretStoreForUser(client.User()) + + client.RegisterAckHandler(ui) + client.RegisterAppPayloadHandler(application.HandleIncomingPayload) + + setupSignalHandler(client) + + client.StartReceiverLoop(disconnected) + client.StartPingLoop() + + ui.RunMainMenu(application) + + client.Close() +} + +// setupSignalHandler installs OS signal handlers for graceful shutdown. +func setupSignalHandler(client *api.KittyClient) { + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM, syscall.SIGQUIT) + + go func() { + <-sigCh + _ = client.SendBye() + client.Close() + os.Exit(0) + }() +} diff --git a/cmd/client/main.go b/cmd/client/main.go new file mode 100644 index 0000000..d0ebdad --- /dev/null +++ b/cmd/client/main.go @@ -0,0 +1,9 @@ +package main + +import ( + "github.com/gabbla05/KittyProtocol/client" +) + +func main() { + client.Start() +} From 238c20336decac7c7fc1cacecf63ea18d9be212e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Brzezi=C5=84ski?= Date: Mon, 25 May 2026 01:22:01 +0000 Subject: [PATCH 23/44] litle change to TODO and removed previous main file in client that is no longer needed --- TODO | 5 +++- client/main.go | 76 -------------------------------------------------- 2 files changed, 4 insertions(+), 77 deletions(-) delete mode 100644 client/main.go diff --git a/TODO b/TODO index bf9402a..1e51c8d 100644 --- a/TODO +++ b/TODO @@ -31,4 +31,7 @@ CLIENT - ŁADOWANIE KLUCZY KONWERSACJI Z PLIKU! (opcjonalnie na końcu) -- gotowe pod frontend \ No newline at end of file +- gotowe pod frontend + + +UZUPEŁNIĆ DOKUMENTACJE W GITHUB O MEOWSSENGER I W README DAĆ ŻE EOWSSENGER POWERED BY KTTYPROTOCOL CZY COŚ TAKIEGO \ No newline at end of file diff --git a/client/main.go b/client/main.go deleted file mode 100644 index 388ac1b..0000000 --- a/client/main.go +++ /dev/null @@ -1,76 +0,0 @@ -package client - -// import ( -// "os" -// "os/signal" -// "syscall" - -// "github.com/gabbla05/KittyProtocol/client/api" -// "github.com/gabbla05/KittyProtocol/client/app" -// "github.com/gabbla05/KittyProtocol/client/ui_cli" -// ) - -// func main() { -// client := api.NewKittyClient() -// ui := ui_cli.NewCliUI(client) - -// disconnected := make(chan struct{}) - -// hubAddr := os.Getenv("KITTY_HUB_ADDR") -// if hubAddr == "" { -// hubAddr = "127.0.0.1:9999" -// } - -// ui.Println("[Client] Connecting to Hub:", hubAddr) - -// if err := client.Connect(hubAddr); err != nil { -// ui.Println("[Client] Connection error:", err) -// return -// } - -// if err := client.WaitForHelloOK(); err != nil { -// ui.Println("[Client] HELLO failed:", err) -// client.Close() -// return -// } - -// // ------------------------------- -// // AUTH FLOW (CLI-specific) -// // ------------------------------- -// if err := ui.RunAuthFlow(client); err == ui_cli.ErrQuitRequested { -// client.Close() -// return -// } - -// // ------------------------------- -// // AUTH SUCCESS → start application -// // ------------------------------- - -// application := app.NewApp(client, ui, disconnected) - -// application.InitSecretStoreForUser(client.User()) - -// client.RegisterAckHandler(ui) -// client.RegisterAppPayloadHandler(application.HandleIncomingPayload) - -// setupSignalHandler(client) - -// client.StartReceiverLoop(disconnected) -// client.StartPingLoop() - -// ui.RunMainMenu(application) - -// client.Close() -// } - -// func setupSignalHandler(client *api.KittyClient) { -// sigCh := make(chan os.Signal, 1) -// signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM, syscall.SIGQUIT) - -// go func() { -// <-sigCh -// _ = client.SendBye() -// client.Close() -// os.Exit(0) -// }() -// } From 7a14b8fa10e3e1b6b8c1cfc6f40bd905f7717708 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Brzezi=C5=84ski?= Date: Mon, 25 May 2026 11:32:55 +0000 Subject: [PATCH 24/44] little changes in catalog names --- README.md | 2 +- README.pl.md | 2 +- TODO | 11 ++++++++++- {resources => assets}/img/kitty_logo.png | Bin {documentation => docs}/KittyProtocol-EN.pdf | Bin {documentation => docs}/KittyProtocol.pdf | Bin 6 files changed, 12 insertions(+), 3 deletions(-) rename {resources => assets}/img/kitty_logo.png (100%) rename {documentation => docs}/KittyProtocol-EN.pdf (100%) rename {documentation => docs}/KittyProtocol.pdf (100%) diff --git a/README.md b/README.md index cdaed0e..021c17b 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ * **`Attack Resistance`**: Native support for TLS 1.3 and mechanisms preventing Man‑in‑the‑Middle (MitM) and Replay attacks. ### Documentation: - Full protocol documentation is available here: [KittyProtocol.pdf](documentation/KittyProtocol-EN.pdf) + Full protocol documentation is available here: [KittyProtocol.pdf](docs/KittyProtocol-EN.pdf) --- Authors: diff --git a/README.pl.md b/README.pl.md index f4791a8..380e30c 100644 --- a/README.pl.md +++ b/README.pl.md @@ -15,7 +15,7 @@ * **`Odporność na ataki`**: Natywne wsparcie dla TLS 1.3 oraz mechanizmy zapobiegające atakom typu Man-in-the-Middle (MitM) i Replay. ### Dokumentacja: - Szczegółowa dokumentacja protokołu znajduje się w dokumencie: [KittyProtocol.pdf](documentation/KittyProtocol.pdf) + Szczegółowa dokumentacja protokołu znajduje się w dokumencie: [KittyProtocol.pdf](docs/KittyProtocol.pdf) --- Authors: diff --git a/TODO b/TODO index 1e51c8d..17e06c0 100644 --- a/TODO +++ b/TODO @@ -33,5 +33,14 @@ CLIENT - gotowe pod frontend +- plik cover.out do analizy na przyszłość i do wykrozystania może -UZUPEŁNIĆ DOKUMENTACJE W GITHUB O MEOWSSENGER I W README DAĆ ŻE EOWSSENGER POWERED BY KTTYPROTOCOL CZY COŚ TAKIEGO \ No newline at end of file +UZUPEŁNIĆ DOKUMENTACJE W GITHUB O MEOWSSENGER I W README DAĆ ŻE EOWSSENGER POWERED BY KTTYPROTOCOL CZY COŚ TAKIEGO + +- Azure można jako nowe konto dodać i dostajesz chyba 200$ z darmo na 30 dni to można stestować jak się uda + +- golang-standards - dostosowanie + +- godoc !!! Sporawdzić czy zadziała u nas + +- CI/CD \ No newline at end of file diff --git a/resources/img/kitty_logo.png b/assets/img/kitty_logo.png similarity index 100% rename from resources/img/kitty_logo.png rename to assets/img/kitty_logo.png diff --git a/documentation/KittyProtocol-EN.pdf b/docs/KittyProtocol-EN.pdf similarity index 100% rename from documentation/KittyProtocol-EN.pdf rename to docs/KittyProtocol-EN.pdf diff --git a/documentation/KittyProtocol.pdf b/docs/KittyProtocol.pdf similarity index 100% rename from documentation/KittyProtocol.pdf rename to docs/KittyProtocol.pdf From b8f48531d87d544a0c7bae6e55a4a67bfbb4e2a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Brzezi=C5=84ski?= Date: Mon, 25 May 2026 12:52:30 +0000 Subject: [PATCH 25/44] CI/CD inproject, deleted previous cover.out with coverage command results --- .github/workflows/ci.yml | 1 + .github/workflows/release.yml | 1 + .gitignore | 7 +- build/ci/ci.yml | 70 ++++++++++++++ build/ci/release.yml | 34 +++++++ cover.out | 167 ---------------------------------- 6 files changed, 112 insertions(+), 168 deletions(-) create mode 120000 .github/workflows/ci.yml create mode 120000 .github/workflows/release.yml create mode 100644 build/ci/ci.yml create mode 100644 build/ci/release.yml delete mode 100644 cover.out diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 120000 index 0000000..e317820 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1 @@ +../../build/ci/ci.yml \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 120000 index 0000000..dc53b13 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1 @@ +../../build/ci/release.yml \ No newline at end of file diff --git a/.gitignore b/.gitignore index 60f329d..c03b692 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,9 @@ .env -.structure.txt \ No newline at end of file +.structure.txt + +bin/ + +cover.out +coverage.out \ No newline at end of file diff --git a/build/ci/ci.yml b/build/ci/ci.yml new file mode 100644 index 0000000..05cdb61 --- /dev/null +++ b/build/ci/ci.yml @@ -0,0 +1,70 @@ +name: CI + +on: + push: + branches: [ main, backend/app-chat-logic ] + pull_request: + branches: [ main, backend/app-chat-logic ] + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: '1.22' + cache: true + cache-dependency-path: go.sum + + - name: Go build cache + uses: actions/cache@v4 + with: + path: | + ~/.cache/go-build + key: ${{ runner.os }}-go-build-${{ hashFiles('**/go.sum') }} + + - name: Run golangci-lint + uses: golangci/golangci-lint-action@v7 + with: + version: latest + args: --timeout=5m + + test-and-build: + runs-on: ubuntu-latest + needs: [ lint ] + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: '1.22' + cache: true + cache-dependency-path: go.sum + + - name: Go build cache + uses: actions/cache@v4 + with: + path: | + ~/.cache/go-build + key: ${{ runner.os }}-go-build-${{ hashFiles('**/go.sum') }} + + - name: Download modules + run: go mod download + + - name: Run tests + run: go test ./... -race -coverprofile=coverage.out -covermode=atomic + + - name: Build hub + run: go build -v -o bin/kitty-hub ./cmd/hub + + - name: Build client + run: go build -v -o bin/kitty-client ./cmd/client + + - name: Upload binaries + uses: actions/upload-artifact@v4 + with: + name: kitty-binaries + path: bin/ diff --git a/build/ci/release.yml b/build/ci/release.yml new file mode 100644 index 0000000..6865873 --- /dev/null +++ b/build/ci/release.yml @@ -0,0 +1,34 @@ +name: Release + +on: + push: + tags: + - 'v*.*.*' + +jobs: + build-and-release: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: '1.22' + cache: true + cache-dependency-path: go.sum + + - name: Build hub + run: go build -v -o bin/kitty-hub ./cmd/hub + + - name: Build client + run: go build -v -o bin/kitty-client ./cmd/client + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + files: | + bin/kitty-hub + bin/kitty-client + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/cover.out b/cover.out deleted file mode 100644 index 3932cdd..0000000 --- a/cover.out +++ /dev/null @@ -1,167 +0,0 @@ -mode: set -github.com/gabbla05/KittyProtocol/hub/bootstrap.go:18.16,22.41 2 0 -github.com/gabbla05/KittyProtocol/hub/bootstrap.go:22.41,24.3 1 0 -github.com/gabbla05/KittyProtocol/hub/bootstrap.go:29.50,33.12 3 0 -github.com/gabbla05/KittyProtocol/hub/bootstrap.go:33.12,44.3 5 0 -github.com/gabbla05/KittyProtocol/hub/context.go:43.35,45.22 1 1 -github.com/gabbla05/KittyProtocol/hub/context.go:45.22,49.33 3 1 -github.com/gabbla05/KittyProtocol/hub/context.go:49.33,51.4 1 1 -github.com/gabbla05/KittyProtocol/hub/context.go:53.3,53.18 1 1 -github.com/gabbla05/KittyProtocol/hub/context.go:57.2,57.24 1 1 -github.com/gabbla05/KittyProtocol/hub/context.go:57.24,60.3 2 0 -github.com/gabbla05/KittyProtocol/hub/context.go:65.33,66.22 1 1 -github.com/gabbla05/KittyProtocol/hub/context.go:66.22,68.3 1 1 -github.com/gabbla05/KittyProtocol/hub/dispatcher.go:19.36,22.16 2 1 -github.com/gabbla05/KittyProtocol/hub/dispatcher.go:22.16,25.3 2 0 -github.com/gabbla05/KittyProtocol/hub/dispatcher.go:26.2,39.6 6 1 -github.com/gabbla05/KittyProtocol/hub/dispatcher.go:39.6,41.17 2 1 -github.com/gabbla05/KittyProtocol/hub/dispatcher.go:41.17,50.57 2 1 -github.com/gabbla05/KittyProtocol/hub/dispatcher.go:50.57,54.5 2 1 -github.com/gabbla05/KittyProtocol/hub/dispatcher.go:57.4,58.10 2 0 -github.com/gabbla05/KittyProtocol/hub/dispatcher.go:61.3,65.32 3 1 -github.com/gabbla05/KittyProtocol/hub/dispatcher.go:65.32,69.41 3 1 -github.com/gabbla05/KittyProtocol/hub/dispatcher.go:69.41,72.5 2 0 -github.com/gabbla05/KittyProtocol/hub/dispatcher.go:73.4,73.12 1 1 -github.com/gabbla05/KittyProtocol/hub/dispatcher.go:76.3,79.19 2 1 -github.com/gabbla05/KittyProtocol/hub/dispatcher.go:80.32,81.24 1 1 -github.com/gabbla05/KittyProtocol/hub/dispatcher.go:82.31,83.23 1 1 -github.com/gabbla05/KittyProtocol/hub/dispatcher.go:84.35,85.27 1 0 -github.com/gabbla05/KittyProtocol/hub/dispatcher.go:86.31,87.23 1 0 -github.com/gabbla05/KittyProtocol/hub/dispatcher.go:88.31,89.23 1 1 -github.com/gabbla05/KittyProtocol/hub/dispatcher.go:90.36,91.28 1 0 -github.com/gabbla05/KittyProtocol/hub/dispatcher.go:92.30,94.10 2 0 -github.com/gabbla05/KittyProtocol/hub/dispatcher.go:95.11,96.68 1 0 -github.com/gabbla05/KittyProtocol/hub/errors.go:18.61,29.16 3 1 -github.com/gabbla05/KittyProtocol/hub/errors.go:29.16,32.3 2 0 -github.com/gabbla05/KittyProtocol/hub/errors.go:34.2,34.43 1 1 -github.com/gabbla05/KittyProtocol/hub/errors.go:34.43,36.3 1 0 -github.com/gabbla05/KittyProtocol/hub/fake_stream.go:10.51,13.2 2 1 -github.com/gabbla05/KittyProtocol/hub/fake_stream.go:15.50,15.70 1 0 -github.com/gabbla05/KittyProtocol/hub/fake_stream.go:16.50,16.64 1 0 -github.com/gabbla05/KittyProtocol/hub/handler_auth.go:17.48,18.35 1 1 -github.com/gabbla05/KittyProtocol/hub/handler_auth.go:18.35,21.3 2 1 -github.com/gabbla05/KittyProtocol/hub/handler_auth.go:23.2,24.16 2 1 -github.com/gabbla05/KittyProtocol/hub/handler_auth.go:24.16,27.3 2 0 -github.com/gabbla05/KittyProtocol/hub/handler_auth.go:30.2,30.24 1 1 -github.com/gabbla05/KittyProtocol/hub/handler_auth.go:30.24,33.3 2 1 -github.com/gabbla05/KittyProtocol/hub/handler_auth.go:36.2,36.58 1 1 -github.com/gabbla05/KittyProtocol/hub/handler_auth.go:36.58,39.3 2 1 -github.com/gabbla05/KittyProtocol/hub/handler_auth.go:42.2,42.41 1 1 -github.com/gabbla05/KittyProtocol/hub/handler_auth.go:42.41,45.3 2 0 -github.com/gabbla05/KittyProtocol/hub/handler_auth.go:48.2,65.16 8 1 -github.com/gabbla05/KittyProtocol/hub/handler_auth.go:65.16,68.3 2 0 -github.com/gabbla05/KittyProtocol/hub/handler_auth.go:70.2,70.45 1 1 -github.com/gabbla05/KittyProtocol/hub/handler_auth.go:70.45,72.3 1 0 -github.com/gabbla05/KittyProtocol/hub/handler_bye.go:10.47,11.35 1 1 -github.com/gabbla05/KittyProtocol/hub/handler_bye.go:11.35,14.3 2 0 -github.com/gabbla05/KittyProtocol/hub/handler_bye.go:16.2,16.55 1 1 -github.com/gabbla05/KittyProtocol/hub/handler_bye.go:16.55,19.3 2 0 -github.com/gabbla05/KittyProtocol/hub/handler_bye.go:21.2,25.52 3 1 -github.com/gabbla05/KittyProtocol/hub/handler_bye.go:25.52,27.3 1 0 -github.com/gabbla05/KittyProtocol/hub/handler_bye.go:29.2,30.21 2 1 -github.com/gabbla05/KittyProtocol/hub/handler_data.go:13.39,15.2 1 1 -github.com/gabbla05/KittyProtocol/hub/handler_data.go:17.48,18.35 1 1 -github.com/gabbla05/KittyProtocol/hub/handler_data.go:18.35,21.3 2 1 -github.com/gabbla05/KittyProtocol/hub/handler_data.go:23.2,24.16 2 1 -github.com/gabbla05/KittyProtocol/hub/handler_data.go:24.16,27.3 2 0 -github.com/gabbla05/KittyProtocol/hub/handler_data.go:30.2,33.22 2 1 -github.com/gabbla05/KittyProtocol/hub/handler_data.go:33.22,36.3 2 0 -github.com/gabbla05/KittyProtocol/hub/handler_data.go:39.2,39.32 1 1 -github.com/gabbla05/KittyProtocol/hub/handler_data.go:39.32,42.3 2 0 -github.com/gabbla05/KittyProtocol/hub/handler_data.go:45.2,45.48 1 1 -github.com/gabbla05/KittyProtocol/hub/handler_data.go:45.48,48.3 2 1 -github.com/gabbla05/KittyProtocol/hub/handler_data.go:51.2,54.45 2 1 -github.com/gabbla05/KittyProtocol/hub/handler_data.go:54.45,56.3 1 1 -github.com/gabbla05/KittyProtocol/hub/handler_data.go:59.2,68.16 3 1 -github.com/gabbla05/KittyProtocol/hub/handler_data.go:68.16,71.3 2 0 -github.com/gabbla05/KittyProtocol/hub/handler_data.go:73.2,73.45 1 1 -github.com/gabbla05/KittyProtocol/hub/handler_data.go:73.45,75.3 1 0 -github.com/gabbla05/KittyProtocol/hub/handler_hello.go:13.49,14.26 1 1 -github.com/gabbla05/KittyProtocol/hub/handler_hello.go:14.26,17.3 2 1 -github.com/gabbla05/KittyProtocol/hub/handler_hello.go:19.2,20.16 2 1 -github.com/gabbla05/KittyProtocol/hub/handler_hello.go:20.16,23.3 2 1 -github.com/gabbla05/KittyProtocol/hub/handler_hello.go:25.2,39.16 5 1 -github.com/gabbla05/KittyProtocol/hub/handler_hello.go:39.16,42.3 2 0 -github.com/gabbla05/KittyProtocol/hub/handler_hello.go:44.2,44.45 1 1 -github.com/gabbla05/KittyProtocol/hub/handler_hello.go:44.45,46.3 1 0 -github.com/gabbla05/KittyProtocol/hub/handler_hello.go:49.2,49.49 1 1 -github.com/gabbla05/KittyProtocol/hub/handler_hello.go:49.49,52.3 2 0 -github.com/gabbla05/KittyProtocol/hub/handler_ping.go:10.48,11.35 1 1 -github.com/gabbla05/KittyProtocol/hub/handler_ping.go:11.35,14.3 2 0 -github.com/gabbla05/KittyProtocol/hub/handler_ping.go:16.2,16.56 1 1 -github.com/gabbla05/KittyProtocol/hub/handler_ping.go:16.56,19.3 2 0 -github.com/gabbla05/KittyProtocol/hub/handler_ping.go:21.2,21.11 1 1 -github.com/gabbla05/KittyProtocol/hub/handler_register.go:13.52,14.35 1 1 -github.com/gabbla05/KittyProtocol/hub/handler_register.go:14.35,17.3 2 0 -github.com/gabbla05/KittyProtocol/hub/handler_register.go:19.2,20.16 2 1 -github.com/gabbla05/KittyProtocol/hub/handler_register.go:20.16,23.3 2 0 -github.com/gabbla05/KittyProtocol/hub/handler_register.go:25.2,25.68 1 1 -github.com/gabbla05/KittyProtocol/hub/handler_register.go:25.68,28.3 2 1 -github.com/gabbla05/KittyProtocol/hub/handler_register.go:30.2,41.16 4 0 -github.com/gabbla05/KittyProtocol/hub/handler_register.go:41.16,44.3 2 0 -github.com/gabbla05/KittyProtocol/hub/handler_register.go:46.2,46.45 1 0 -github.com/gabbla05/KittyProtocol/hub/handler_register.go:46.45,48.3 1 0 -github.com/gabbla05/KittyProtocol/hub/handler_status.go:13.53,14.35 1 1 -github.com/gabbla05/KittyProtocol/hub/handler_status.go:14.35,17.3 2 0 -github.com/gabbla05/KittyProtocol/hub/handler_status.go:19.2,20.16 2 1 -github.com/gabbla05/KittyProtocol/hub/handler_status.go:20.16,23.3 2 0 -github.com/gabbla05/KittyProtocol/hub/handler_status.go:25.2,28.37 3 1 -github.com/gabbla05/KittyProtocol/hub/handler_status.go:28.37,30.3 1 1 -github.com/gabbla05/KittyProtocol/hub/handler_status.go:32.2,42.16 3 1 -github.com/gabbla05/KittyProtocol/hub/handler_status.go:42.16,45.3 2 0 -github.com/gabbla05/KittyProtocol/hub/handler_status.go:47.2,47.45 1 1 -github.com/gabbla05/KittyProtocol/hub/handler_status.go:47.45,49.3 1 0 -github.com/gabbla05/KittyProtocol/hub/logger.go:21.42,25.19 3 1 -github.com/gabbla05/KittyProtocol/hub/logger.go:25.19,26.16 1 1 -github.com/gabbla05/KittyProtocol/hub/logger.go:27.15,28.21 1 1 -github.com/gabbla05/KittyProtocol/hub/logger.go:29.15,30.23 1 0 -github.com/gabbla05/KittyProtocol/hub/logger.go:31.16,32.20 1 0 -github.com/gabbla05/KittyProtocol/hub/logger.go:33.11,34.22 1 0 -github.com/gabbla05/KittyProtocol/hub/logger.go:39.2,39.20 1 1 -github.com/gabbla05/KittyProtocol/hub/logger.go:39.20,43.3 2 0 -github.com/gabbla05/KittyProtocol/hub/logger.go:46.2,47.66 1 1 -github.com/gabbla05/KittyProtocol/hub/logger.go:50.40,50.69 1 1 -github.com/gabbla05/KittyProtocol/hub/logger.go:51.40,51.69 1 0 -github.com/gabbla05/KittyProtocol/hub/logger.go:52.40,52.70 1 0 -github.com/gabbla05/KittyProtocol/hub/router.go:17.107,20.9 2 1 -github.com/gabbla05/KittyProtocol/hub/router.go:20.9,24.3 2 1 -github.com/gabbla05/KittyProtocol/hub/router.go:26.2,26.30 1 1 -github.com/gabbla05/KittyProtocol/hub/router.go:26.30,30.3 2 1 -github.com/gabbla05/KittyProtocol/hub/router.go:33.2,50.16 6 1 -github.com/gabbla05/KittyProtocol/hub/router.go:50.16,54.3 3 0 -github.com/gabbla05/KittyProtocol/hub/router.go:56.2,56.55 1 1 -github.com/gabbla05/KittyProtocol/hub/router.go:56.55,60.3 3 0 -github.com/gabbla05/KittyProtocol/hub/router.go:62.2,62.13 1 1 -github.com/gabbla05/KittyProtocol/hub/start.go:30.14,35.16 3 0 -github.com/gabbla05/KittyProtocol/hub/start.go:35.16,38.3 2 0 -github.com/gabbla05/KittyProtocol/hub/start.go:41.2,42.15 2 0 -github.com/gabbla05/KittyProtocol/hub/start.go:42.15,44.3 1 0 -github.com/gabbla05/KittyProtocol/hub/start.go:46.2,47.16 2 0 -github.com/gabbla05/KittyProtocol/hub/start.go:47.16,50.3 2 0 -github.com/gabbla05/KittyProtocol/hub/start.go:51.2,66.16 6 0 -github.com/gabbla05/KittyProtocol/hub/start.go:66.16,68.3 1 0 -github.com/gabbla05/KittyProtocol/hub/start.go:71.2,72.16 2 0 -github.com/gabbla05/KittyProtocol/hub/start.go:72.16,75.3 2 0 -github.com/gabbla05/KittyProtocol/hub/start.go:77.2,83.6 3 0 -github.com/gabbla05/KittyProtocol/hub/start.go:83.6,85.17 2 0 -github.com/gabbla05/KittyProtocol/hub/start.go:85.17,88.4 2 0 -github.com/gabbla05/KittyProtocol/hub/start.go:91.3,91.24 1 0 -github.com/gabbla05/KittyProtocol/hub/start_test-hub.go:19.59,21.54 1 1 -github.com/gabbla05/KittyProtocol/hub/start_test-hub.go:21.54,23.3 1 0 -github.com/gabbla05/KittyProtocol/hub/start_test-hub.go:25.2,26.16 2 1 -github.com/gabbla05/KittyProtocol/hub/start_test-hub.go:26.16,28.3 1 0 -github.com/gabbla05/KittyProtocol/hub/start_test-hub.go:31.2,31.27 1 1 -github.com/gabbla05/KittyProtocol/hub/start_test-hub.go:31.27,33.3 1 0 -github.com/gabbla05/KittyProtocol/hub/start_test-hub.go:35.2,45.16 5 1 -github.com/gabbla05/KittyProtocol/hub/start_test-hub.go:45.16,47.3 1 0 -github.com/gabbla05/KittyProtocol/hub/start_test-hub.go:49.2,52.12 2 1 -github.com/gabbla05/KittyProtocol/hub/start_test-hub.go:52.12,53.7 1 1 -github.com/gabbla05/KittyProtocol/hub/start_test-hub.go:53.7,55.18 2 1 -github.com/gabbla05/KittyProtocol/hub/start_test-hub.go:55.18,57.5 1 1 -github.com/gabbla05/KittyProtocol/hub/start_test-hub.go:58.4,58.25 1 1 -github.com/gabbla05/KittyProtocol/hub/start_test-hub.go:62.2,62.16 1 1 -github.com/gabbla05/KittyProtocol/hub/start_test-hub.go:62.16,66.3 2 1 -github.com/gabbla05/KittyProtocol/hub/start_test-hub.go:68.2,68.44 1 1 -github.com/gabbla05/KittyProtocol/hub/stream_adapter.go:12.58,12.81 1 0 -github.com/gabbla05/KittyProtocol/hub/stream_adapter.go:13.58,13.80 1 0 -github.com/gabbla05/KittyProtocol/hub/stream_adapter.go:14.58,14.80 1 0 From f7c3e83a24980b8557eb3520006bce5128251c7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Brzezi=C5=84ski?= Date: Mon, 25 May 2026 13:17:09 +0000 Subject: [PATCH 26/44] Fix CI/CD: reusable workflows + correct wrappers --- .github/workflows/ci.yml | 12 +++++++++++- .github/workflows/release.yml | 11 ++++++++++- build/ci/ci.yml | 7 ++----- build/ci/release.yml | 6 ++---- 4 files changed, 25 insertions(+), 11 deletions(-) mode change 120000 => 100644 .github/workflows/ci.yml mode change 120000 => 100644 .github/workflows/release.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 120000 index e317820..0000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1 +0,0 @@ -../../build/ci/ci.yml \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..9a62451 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,11 @@ +name: CI + +on: + push: + branches: [ main, backend/app-chat-logic ] + pull_request: + branches: [ main, backend/app-chat-logic ] + +jobs: + ci-core: + uses: ./.github/../ci/ci.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 120000 index dc53b13..0000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1 +0,0 @@ -../../build/ci/release.yml \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..d72cb2e --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,10 @@ +name: Release + +on: + push: + tags: + - 'v*.*.*' + +jobs: + release-core: + uses: ./.github/../ci/release.yml diff --git a/build/ci/ci.yml b/build/ci/ci.yml index 05cdb61..6b10006 100644 --- a/build/ci/ci.yml +++ b/build/ci/ci.yml @@ -1,10 +1,7 @@ -name: CI +name: CI core on: - push: - branches: [ main, backend/app-chat-logic ] - pull_request: - branches: [ main, backend/app-chat-logic ] + workflow_call: {} jobs: lint: diff --git a/build/ci/release.yml b/build/ci/release.yml index 6865873..ba252eb 100644 --- a/build/ci/release.yml +++ b/build/ci/release.yml @@ -1,9 +1,7 @@ -name: Release +name: Release core on: - push: - tags: - - 'v*.*.*' + workflow_call: {} jobs: build-and-release: From a5196a368268a0fcdba79b49796513e67dd1e0df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Brzezi=C5=84ski?= Date: Mon, 25 May 2026 13:29:30 +0000 Subject: [PATCH 27/44] Final CI/CD setup (clean, simple, production-ready), remove unused build/ci directory --- .github/workflows/ci.yml | 63 ++++++++++++++++++++++++++++++-- .github/workflows/release.yml | 35 ++++++++++++++++-- build/ci/ci.yml | 67 ----------------------------------- build/ci/release.yml | 32 ----------------- 4 files changed, 94 insertions(+), 103 deletions(-) delete mode 100644 build/ci/ci.yml delete mode 100644 build/ci/release.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9a62451..05cdb61 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,5 +7,64 @@ on: branches: [ main, backend/app-chat-logic ] jobs: - ci-core: - uses: ./.github/../ci/ci.yml + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: '1.22' + cache: true + cache-dependency-path: go.sum + + - name: Go build cache + uses: actions/cache@v4 + with: + path: | + ~/.cache/go-build + key: ${{ runner.os }}-go-build-${{ hashFiles('**/go.sum') }} + + - name: Run golangci-lint + uses: golangci/golangci-lint-action@v7 + with: + version: latest + args: --timeout=5m + + test-and-build: + runs-on: ubuntu-latest + needs: [ lint ] + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: '1.22' + cache: true + cache-dependency-path: go.sum + + - name: Go build cache + uses: actions/cache@v4 + with: + path: | + ~/.cache/go-build + key: ${{ runner.os }}-go-build-${{ hashFiles('**/go.sum') }} + + - name: Download modules + run: go mod download + + - name: Run tests + run: go test ./... -race -coverprofile=coverage.out -covermode=atomic + + - name: Build hub + run: go build -v -o bin/kitty-hub ./cmd/hub + + - name: Build client + run: go build -v -o bin/kitty-client ./cmd/client + + - name: Upload binaries + uses: actions/upload-artifact@v4 + with: + name: kitty-binaries + path: bin/ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d72cb2e..3344f8c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,5 +6,36 @@ on: - 'v*.*.*' jobs: - release-core: - uses: ./.github/../ci/release.yml + build-and-release: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: '1.22' + cache: true + cache-dependency-path: go.sum + + - name: Go build cache + uses: actions/cache@v4 + with: + path: | + ~/.cache/go-build + key: ${{ runner.os }}-go-build-${{ hashFiles('**/go.sum') }} + + - name: Build hub + run: go build -v -o bin/kitty-hub ./cmd/hub + + - name: Build client + run: go build -v -o bin/kitty-client ./cmd/client + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + files: | + bin/kitty-hub + bin/kitty-client + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/build/ci/ci.yml b/build/ci/ci.yml deleted file mode 100644 index 6b10006..0000000 --- a/build/ci/ci.yml +++ /dev/null @@ -1,67 +0,0 @@ -name: CI core - -on: - workflow_call: {} - -jobs: - lint: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-go@v5 - with: - go-version: '1.22' - cache: true - cache-dependency-path: go.sum - - - name: Go build cache - uses: actions/cache@v4 - with: - path: | - ~/.cache/go-build - key: ${{ runner.os }}-go-build-${{ hashFiles('**/go.sum') }} - - - name: Run golangci-lint - uses: golangci/golangci-lint-action@v7 - with: - version: latest - args: --timeout=5m - - test-and-build: - runs-on: ubuntu-latest - needs: [ lint ] - - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-go@v5 - with: - go-version: '1.22' - cache: true - cache-dependency-path: go.sum - - - name: Go build cache - uses: actions/cache@v4 - with: - path: | - ~/.cache/go-build - key: ${{ runner.os }}-go-build-${{ hashFiles('**/go.sum') }} - - - name: Download modules - run: go mod download - - - name: Run tests - run: go test ./... -race -coverprofile=coverage.out -covermode=atomic - - - name: Build hub - run: go build -v -o bin/kitty-hub ./cmd/hub - - - name: Build client - run: go build -v -o bin/kitty-client ./cmd/client - - - name: Upload binaries - uses: actions/upload-artifact@v4 - with: - name: kitty-binaries - path: bin/ diff --git a/build/ci/release.yml b/build/ci/release.yml deleted file mode 100644 index ba252eb..0000000 --- a/build/ci/release.yml +++ /dev/null @@ -1,32 +0,0 @@ -name: Release core - -on: - workflow_call: {} - -jobs: - build-and-release: - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-go@v5 - with: - go-version: '1.22' - cache: true - cache-dependency-path: go.sum - - - name: Build hub - run: go build -v -o bin/kitty-hub ./cmd/hub - - - name: Build client - run: go build -v -o bin/kitty-client ./cmd/client - - - name: Create GitHub Release - uses: softprops/action-gh-release@v2 - with: - files: | - bin/kitty-hub - bin/kitty-client - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} From a6ba81db43fade84618229b41b85b4734cb73e5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Brzezi=C5=84ski?= Date: Mon, 25 May 2026 15:24:14 +0000 Subject: [PATCH 28/44] client refactor - phase 1 --- client/api/ack.go | 4 +- client/api/auth.go | 20 +++---- client/api/client.go | 40 ++++++++++++-- client/api/close.go | 6 ++- client/api/connect.go | 13 ++--- client/api/constants.go | 18 +++++++ client/api/errors.go | 20 +++++++ client/api/hello.go | 13 +++-- client/api/logger.go | 48 +++++++++++++++++ client/api/ping.go | 2 +- client/api/receive.go | 67 +++++++++++------------ client/api/{replay.go => replay_dev.go} | 11 ++-- client/api/send.go | 61 ++++++++++++++------- client/api/transport.go | 70 +++++++++++++++++++++++++ client/app/app.go | 49 +++++++++++------ client/start.go | 17 ++---- client/ui_cli/logger.go | 22 ++++++++ cmd/client/main.go | 9 ---- cmd/client_cli/main.go | 13 +++++ 19 files changed, 374 insertions(+), 129 deletions(-) create mode 100644 client/api/constants.go create mode 100644 client/api/errors.go create mode 100644 client/api/logger.go rename client/api/{replay.go => replay_dev.go} (56%) create mode 100644 client/api/transport.go create mode 100644 client/ui_cli/logger.go delete mode 100644 cmd/client/main.go create mode 100644 cmd/client_cli/main.go diff --git a/client/api/ack.go b/client/api/ack.go index d3a8e0d..01b7ccf 100644 --- a/client/api/ack.go +++ b/client/api/ack.go @@ -33,11 +33,11 @@ type AckManager struct { timeout time.Duration } -// NewAckManager creates a new manager with a default timeout of 5 seconds. +// NewAckManager creates a new manager with a default timeout. func NewAckManager() *AckManager { return &AckManager{ pending: make(map[int64]chan struct{}), - timeout: 5 * time.Second, + timeout: defaultAckTimeout, } } diff --git a/client/api/auth.go b/client/api/auth.go index 198084f..34c7f47 100644 --- a/client/api/auth.go +++ b/client/api/auth.go @@ -14,12 +14,9 @@ import ( // ----------------------------------------------------------------------------- func (c *KittyClient) SendAuth(user, pass string) error { - c.mu.Lock() - stream := c.stream - c.mu.Unlock() - - if stream == nil { - return errors.New("stream is nil") + stream, err := c.ensureConnected() + if err != nil { + return err } frame := protocol.AuthFrame{ @@ -58,12 +55,9 @@ func (c *KittyClient) WaitForAuthOK() error { // ----------------------------------------------------------------------------- func (c *KittyClient) SendRegister(user, pass string) error { - c.mu.Lock() - stream := c.stream - c.mu.Unlock() - - if stream == nil { - return errors.New("stream is nil") + stream, err := c.ensureConnected() + if err != nil { + return err } frame := protocol.AuthFrame{ @@ -105,7 +99,7 @@ func (c *KittyClient) waitOkOrError() (bool, string) { return false, "NO_STREAM" } - buf := make([]byte, 4096) + buf := make([]byte, defaultRecvBufferSize) n, err := stream.Read(buf) if err != nil { return false, "READ_ERROR" diff --git a/client/api/client.go b/client/api/client.go index 641c1f4..73919f7 100644 --- a/client/api/client.go +++ b/client/api/client.go @@ -5,7 +5,6 @@ import ( "sync" "github.com/gabbla05/KittyProtocol/internal/protection" - "github.com/quic-go/quic-go" ) // ClientState represents the high-level lifecycle state of the client. @@ -16,6 +15,16 @@ type ClientState int // to receive decrypted DATA payloads from KittyClient. type AppPayloadHandler func(sender string, payload []byte) +// ErrorHandler is invoked when the Hub sends an ERROR frame. +type ErrorHandler func(code, desc string) + +// StatusHandler is invoked when the Hub sends a STATUS_RES frame. +type StatusHandler func(target, status string) + +// DisconnectHandler is invoked when the underlying connection is closed +// or the receiver loop terminates with an error. +type DisconnectHandler func(err error) + const ( StateDisconnected ClientState = iota // No QUIC connection StateHandshaking // HELLO sent, waiting for MEOW_OK @@ -36,9 +45,9 @@ type KittyClient struct { mu sync.Mutex state ClientState - // QUIC transport - conn *quic.Conn - stream *quic.Stream + // QUIC transport (wrapped in adapters for testability and GUI-friendliness) + conn ConnAdapter + stream StreamAdapter // Session metadata user string @@ -60,6 +69,11 @@ type KittyClient struct { // Application-level payload handler (chat, etc.) appHandler AppPayloadHandler + + // Event handlers for UI/frontends + errHandler ErrorHandler + statusHandler StatusHandler + disconnectHandler DisconnectHandler } // NewKittyClient creates a new client instance in the Disconnected state. @@ -85,6 +99,24 @@ func (c *KittyClient) RegisterAppPayloadHandler(h AppPayloadHandler) { c.appHandler = h } +func (c *KittyClient) OnError(h ErrorHandler) { + c.mu.Lock() + defer c.mu.Unlock() + c.errHandler = h +} + +func (c *KittyClient) OnStatus(h StatusHandler) { + c.mu.Lock() + defer c.mu.Unlock() + c.statusHandler = h +} + +func (c *KittyClient) OnDisconnected(h DisconnectHandler) { + c.mu.Lock() + defer c.mu.Unlock() + c.disconnectHandler = h +} + func (c *KittyClient) User() string { c.mu.Lock() defer c.mu.Unlock() diff --git a/client/api/close.go b/client/api/close.go index 2f50f49..6ee34a0 100644 --- a/client/api/close.go +++ b/client/api/close.go @@ -3,6 +3,7 @@ package api import ( "github.com/gabbla05/KittyProtocol/internal/cryptoee" "github.com/gabbla05/KittyProtocol/internal/protection" + "github.com/quic-go/quic-go" ) // Close gracefully shuts down the client and securely clears all sensitive data. @@ -39,8 +40,9 @@ func (c *KittyClient) Close() { // Forcefully interrupt any blocking Read/Write if c.stream != nil { - c.stream.CancelRead(0) - c.stream.CancelWrite(0) + // QUIC-specific error code 0 is fine here; semantics are "no specific error". + c.stream.CancelRead(quic.StreamErrorCode(0)) + c.stream.CancelWrite(quic.StreamErrorCode(0)) } // Close stream diff --git a/client/api/connect.go b/client/api/connect.go index 7440249..1240071 100644 --- a/client/api/connect.go +++ b/client/api/connect.go @@ -37,29 +37,30 @@ func (c *KittyClient) Connect(hubAddr string) error { tlsConf := buildTLSConfig() - // QUIC Dial - conn, err := quic.DialAddr(context.Background(), hubAddr, tlsConf, nil) + // QUIC Dial (real transport) + rawConn, err := quic.DialAddr(context.Background(), hubAddr, tlsConf, nil) if err != nil { return err } + conn := newQuicConnAdapter(rawConn) // TOFU certificate verification state := conn.ConnectionState() if len(state.TLS.PeerCertificates) == 0 { - conn.CloseWithError(0, "no server certificate") + _ = conn.CloseWithError(0, "no server certificate") return errors.New("no server certificate") } serverCert := state.TLS.PeerCertificates[0] if err := verifyOrStoreServerCert(serverCert); err != nil { - conn.CloseWithError(0, "certificate verification failed") + _ = conn.CloseWithError(0, "certificate verification failed") return err } - // Open QUIC stream + // Open QUIC stream via adapter stream, err := conn.OpenStreamSync(context.Background()) if err != nil { - conn.CloseWithError(0, "stream open failed") + _ = conn.CloseWithError(0, "stream open failed") return err } diff --git a/client/api/constants.go b/client/api/constants.go new file mode 100644 index 0000000..e395a4a --- /dev/null +++ b/client/api/constants.go @@ -0,0 +1,18 @@ +package api + +import "time" + +// Centralized constants for KittyClient behavior. +// This keeps magic numbers out of the logic files and makes tuning easier. +const ( + // defaultRecvBufferSize is the buffer size used for reading frames + // from the Hub in the receiver loop. + defaultRecvBufferSize = 4096 + + // defaultAckTimeout is the time after which a pending message is + // considered undelivered if no MEOW_OK arrives. + defaultAckTimeout = 5 * time.Second + + // defaultPingInterval controls how often PING frames are sent to the Hub. + defaultPingInterval = 30 * time.Second +) diff --git a/client/api/errors.go b/client/api/errors.go new file mode 100644 index 0000000..bce80c5 --- /dev/null +++ b/client/api/errors.go @@ -0,0 +1,20 @@ +package api + +import "errors" + +var ( + // ErrNotConnected is returned when an operation requires an active + // connection/stream but the client is disconnected. + ErrNotConnected = errors.New("client not connected") + + // ErrNoStream is returned when the underlying stream is nil. + ErrNoStream = errors.New("stream is nil") + + // ErrNoSharedSecret is returned when trying to send or decrypt + // E2EE data without a derived secret for the peer. + ErrNoSharedSecret = errors.New("no shared secret for target") + + // ErrTargetNotSet is returned when an operation requires a target + // but none is configured. + ErrTargetNotSet = errors.New("target not set") +) diff --git a/client/api/hello.go b/client/api/hello.go index 1796f7b..aefd065 100644 --- a/client/api/hello.go +++ b/client/api/hello.go @@ -9,6 +9,14 @@ import ( ) func (c *KittyClient) SendHello() error { + c.mu.Lock() + stream := c.stream + c.mu.Unlock() + + if stream == nil { + return fmt.Errorf("stream is nil") + } + frame := protocol.HelloFrame{ BaseFrame: protocol.BaseFrame{ Type: protocol.FrameTypeHello, @@ -22,8 +30,7 @@ func (c *KittyClient) SendHello() error { return fmt.Errorf("failed to marshal HELLO: %w", err) } - _, err = c.stream.Write(b) - if err != nil { + if _, err := stream.Write(b); err != nil { return fmt.Errorf("failed to send HELLO: %w", err) } @@ -49,7 +56,7 @@ func (c *KittyClient) waitHelloOK() (bool, string) { return false, "NO_STREAM" } - buf := make([]byte, 4096) + buf := make([]byte, defaultRecvBufferSize) n, err := stream.Read(buf) if err != nil { return false, "READ_ERROR" diff --git a/client/api/logger.go b/client/api/logger.go new file mode 100644 index 0000000..d1a0f95 --- /dev/null +++ b/client/api/logger.go @@ -0,0 +1,48 @@ +package api + +import ( + "fmt" + "sync" +) + +type LogLevel int + +const ( + LogDebug LogLevel = iota + LogInfo + LogWarn + LogError +) + +type Logger interface { + Log(level LogLevel, msg string) +} + +type defaultLogger struct{} + +func (defaultLogger) Log(level LogLevel, msg string) { + // Domyślnie nic — API jest UI-agnostic. +} + +type logManager struct { + mu sync.Mutex + logger Logger +} + +var globalLogger = &logManager{ + logger: defaultLogger{}, +} + +func SetLogger(l Logger) { + globalLogger.mu.Lock() + defer globalLogger.mu.Unlock() + globalLogger.logger = l +} + +func log(level LogLevel, format string, args ...any) { + globalLogger.mu.Lock() + l := globalLogger.logger + globalLogger.mu.Unlock() + + l.Log(level, fmt.Sprintf(format, args...)) +} diff --git a/client/api/ping.go b/client/api/ping.go index 6d18f36..7490774 100644 --- a/client/api/ping.go +++ b/client/api/ping.go @@ -28,7 +28,7 @@ func (c *KittyClient) StartPingLoop() { } go func() { - ticker := time.NewTicker(30 * time.Second) + ticker := time.NewTicker(defaultPingInterval) defer ticker.Stop() for { diff --git a/client/api/receive.go b/client/api/receive.go index 1b40644..dbd212e 100644 --- a/client/api/receive.go +++ b/client/api/receive.go @@ -2,7 +2,6 @@ package api import ( "encoding/json" - "fmt" "github.com/gabbla05/KittyProtocol/internal/cryptoee" "github.com/gabbla05/KittyProtocol/protocol" @@ -11,17 +10,15 @@ import ( // StartReceiverLoop launches a background goroutine responsible for reading // all incoming frames from the QUIC stream. This is the only reader for the // stream; all other components must communicate through higher‑level APIs. -// -// The loop terminates when: -// - stopRecv is closed, -// - the QUIC stream returns an error, -// - the disconnected channel is closed (exactly once). func (c *KittyClient) StartReceiverLoop(disconnected chan struct{}) { c.mu.Lock() stream := c.stream replay := c.replay ackMgr := c.ackMgr stopRecv := c.stopRecv + errHandler := c.errHandler + statusHandler := c.statusHandler + disconnectHandler := c.disconnectHandler c.mu.Unlock() if stream == nil { @@ -29,7 +26,7 @@ func (c *KittyClient) StartReceiverLoop(disconnected chan struct{}) { } go func() { - buf := make([]byte, 4096) + buf := make([]byte, defaultRecvBufferSize) for { select { @@ -40,8 +37,11 @@ func (c *KittyClient) StartReceiverLoop(disconnected chan struct{}) { n, err := stream.Read(buf) if err != nil { - fmt.Println("\n[Client: Receive] Connection closed by server:", err) - fmt.Println("[Client: Receive] Returning to disconnected state.") + if disconnectHandler != nil { + disconnectHandler(err) + } else { + log(LogError, "disconnected: %v", err) + } select { case <-disconnected: @@ -53,7 +53,11 @@ func (c *KittyClient) StartReceiverLoop(disconnected chan struct{}) { typeName, msgID, err := protocol.GetFrameType(buf[:n]) if err != nil { - fmt.Println("[Client: Receive] Parse error:", err) + if errHandler != nil { + errHandler("PARSE_ERROR", err.Error()) + } else { + log(LogError, "parse error: %v", err) + } continue } @@ -65,71 +69,62 @@ func (c *KittyClient) StartReceiverLoop(disconnected chan struct{}) { } case protocol.FrameTypeError: - var errFrame protocol.ErrorFrame - if json.Unmarshal(buf[:n], &errFrame) == nil { - fmt.Printf("\n[Client: Receive] Server ERROR %s: %s\n> ", - errFrame.Code, errFrame.Desc) + var ef protocol.ErrorFrame + if json.Unmarshal(buf[:n], &ef) == nil { + if errHandler != nil { + errHandler(ef.Code, ef.Desc) + } else { + log(LogError, "server error %s: %s", ef.Code, ef.Desc) + } } else { - fmt.Println("\n[Client: Receive] Failed to parse ERROR frame\n> ") + log(LogError, "failed to parse ERROR frame") } case protocol.FrameTypeData: var df protocol.DataFrame if json.Unmarshal(buf[:n], &df) != nil { - fmt.Println("\n[Client: Receive] Failed to parse DATA frame\n> ") + log(LogError, "failed to parse DATA frame") continue } - // Replay protection if replay != nil && replay.MarkAndCheck(df.MsgID) { continue } - // Retrieve handler + keys c.mu.Lock() handler := c.appHandler kEnc, kMac, ok := c.getKeysForPeer(df.Sender) c.mu.Unlock() if !ok { - fmt.Printf("\n[Client] No shared secret for sender %s — cannot decrypt.\n> ", - df.Sender) + log(LogWarn, "no shared secret for %s", df.Sender) continue } plaintext, err := cryptoee.DecryptAndVerifyWithKeys( - df.MsgID, - df.Target, - df.Payload, - df.MAC, - kEnc, - kMac, + df.MsgID, df.Target, df.Payload, df.MAC, kEnc, kMac, ) if err != nil { - fmt.Printf("\n[Client: Receive] E2EE error: %v\n> ", err) + log(LogError, "E2EE error: %v", err) continue } if handler != nil { handler(df.Sender, []byte(plaintext)) - } else { - fmt.Printf("\n[Client: Receive] Message from %s: %s\n> ", - df.Sender, string(plaintext)) } case protocol.FrameTypeStatusRes: var sf protocol.StatusResFrame if json.Unmarshal(buf[:n], &sf) != nil { - fmt.Println("\n[Client: Receive] Failed to parse STATUS_RES frame\n> ") + log(LogError, "failed to parse STATUS_RES") continue } - if sf.Target == "" && sf.Status == "no_target" { - fmt.Printf("\n[Client: Receive] Chat ended. No active target.\n> ") - continue + if statusHandler != nil { + statusHandler(sf.Target, sf.Status) + } else { + log(LogInfo, "status: %s is %s", sf.Target, sf.Status) } - - fmt.Printf("\n[Client: Receive] %s is %s\n> ", sf.Target, sf.Status) } } }() diff --git a/client/api/replay.go b/client/api/replay_dev.go similarity index 56% rename from client/api/replay.go rename to client/api/replay_dev.go index 439c426..026343c 100644 --- a/client/api/replay.go +++ b/client/api/replay_dev.go @@ -1,14 +1,13 @@ -package api +//go:build dev +// +build dev -// ==================================================== -// DELETE THIS FILE AND ITS USAGE FOR PRODCUTION BUILDS -// ==================================================== +package api import "fmt" // ReplayLastFrame resends the last raw frame written to the stream. -// This is used exclusively for replay protection testing and should not be -// exposed in production builds. +// This is used exclusively for replay protection testing and is compiled +// only in dev builds. func (c *KittyClient) ReplayLastFrame() error { c.mu.Lock() defer c.mu.Unlock() diff --git a/client/api/send.go b/client/api/send.go index 52ae822..df619c8 100644 --- a/client/api/send.go +++ b/client/api/send.go @@ -16,30 +16,49 @@ func canonicalTarget(t string) string { return strings.ToLower(strings.TrimSpace(t)) } +// ensureConnected returns current stream or an error if the client +// is not in a usable state for sending frames. +func (c *KittyClient) ensureConnected() (StreamAdapter, error) { + c.mu.Lock() + defer c.mu.Unlock() + + if c.stream == nil { + return nil, ErrNoStream + } + if c.state == StateDisconnected { + return nil, ErrNotConnected + } + return c.stream, nil +} + // SendAppFrameEncrypted sends an application-level frame (chat control, text, etc.) // encrypted as a DATA frame. The Hub requires MAC for all DATA frames. func (c *KittyClient) SendAppFrameEncrypted(target string, payload []byte) error { + stream, err := c.ensureConnected() + if err != nil { + return err + } + + if target == "" { + return ErrTargetNotSet + } + c.mu.Lock() - stream := c.stream - ackMgr := c.ackMgr kEnc, kMac, ok := c.getKeysForPeer(target) c.mu.Unlock() if !ok { - return errors.New("no shared secret for target") - } - if stream == nil { - return errors.New("stream is nil") - } - if target == "" { - return errors.New("target not set") + return ErrNoSharedSecret } msgID := time.Now().UnixMilli() + c.mu.Lock() + ackMgr := c.ackMgr if ackMgr != nil { ackMgr.AddPending(msgID) } + c.mu.Unlock() canonTarget := canonicalTarget(target) @@ -80,12 +99,13 @@ func (c *KittyClient) SendAppFrameEncrypted(target string, payload []byte) error // SendGetStatus sends a GET_STATUS frame for a given user. func (c *KittyClient) SendGetStatus(target string) error { - c.mu.Lock() - stream := c.stream - c.mu.Unlock() + stream, err := c.ensureConnected() + if err != nil { + return err + } - if stream == nil { - return errors.New("stream is nil") + if target == "" { + return ErrTargetNotSet } msgID := time.Now().UnixMilli() @@ -109,12 +129,13 @@ func (c *KittyClient) SendGetStatus(target string) error { // SendBye sends a BYE frame to the Hub. func (c *KittyClient) SendBye() error { - c.mu.Lock() - stream := c.stream - c.mu.Unlock() - - if stream == nil { - return errors.New("stream is nil") + stream, err := c.ensureConnected() + if err != nil { + // BYE jest „best-effort” — jeśli nie ma streama, nie robimy dramatu. + if errors.Is(err, ErrNoStream) || errors.Is(err, ErrNotConnected) { + return nil + } + return err } frame := protocol.BaseFrame{ diff --git a/client/api/transport.go b/client/api/transport.go new file mode 100644 index 0000000..e1c3e00 --- /dev/null +++ b/client/api/transport.go @@ -0,0 +1,70 @@ +package api + +import ( + "context" + + "github.com/quic-go/quic-go" +) + +// StreamAdapter abstracts a bidirectional QUIC stream. +// It allows KittyClient to remain transport-agnostic and easily testable. +type StreamAdapter interface { + Read(p []byte) (int, error) + Write(p []byte) (int, error) + Close() error + + // QUIC-specific cancellation hooks are kept here because the current + // transport is QUIC. If we ever swap transport, a new adapter can + // implement these as no-ops. + CancelRead(code quic.StreamErrorCode) + CancelWrite(code quic.StreamErrorCode) +} + +// ConnAdapter abstracts a QUIC connection. +type ConnAdapter interface { + ConnectionState() quic.ConnectionState + OpenStreamSync(ctx context.Context) (StreamAdapter, error) + CloseWithError(code quic.ApplicationErrorCode, msg string) error +} + +// quicConnAdapter is the real QUIC implementation of ConnAdapter. +type quicConnAdapter struct { + conn *quic.Conn +} + +func newQuicConnAdapter(conn *quic.Conn) *quicConnAdapter { + return &quicConnAdapter{conn: conn} +} + +func (a *quicConnAdapter) ConnectionState() quic.ConnectionState { + return a.conn.ConnectionState() +} + +func (a *quicConnAdapter) OpenStreamSync(ctx context.Context) (StreamAdapter, error) { + s, err := a.conn.OpenStreamSync(ctx) + if err != nil { + return nil, err + } + return &quicStreamAdapter{s: s}, nil +} + +func (a *quicConnAdapter) CloseWithError(code quic.ApplicationErrorCode, msg string) error { + return a.conn.CloseWithError(code, msg) +} + +// quicStreamAdapter is the real QUIC implementation of StreamAdapter. +type quicStreamAdapter struct { + s *quic.Stream +} + +func (q *quicStreamAdapter) Read(p []byte) (int, error) { return q.s.Read(p) } +func (q *quicStreamAdapter) Write(p []byte) (int, error) { return q.s.Write(p) } +func (q *quicStreamAdapter) Close() error { return q.s.Close() } + +func (q *quicStreamAdapter) CancelRead(code quic.StreamErrorCode) { + q.s.CancelRead(code) +} + +func (q *quicStreamAdapter) CancelWrite(code quic.StreamErrorCode) { + q.s.CancelWrite(code) +} diff --git a/client/app/app.go b/client/app/app.go index c0a5238..8f01407 100644 --- a/client/app/app.go +++ b/client/app/app.go @@ -20,34 +20,53 @@ type App struct { } func NewApp(c *api.KittyClient, ui UI, disconnected <-chan struct{}) *App { - return &App{ + a := &App{ client: c, ui: ui, disconnected: disconnected, chatState: NewChatState(), - secrets: nil, // initialized after AUTH + secrets: nil, } + + a.attachEventHandlers() + return a +} + +func (a *App) attachEventHandlers() { + c := a.client + + // Decrypted DATA → chat logic + c.RegisterAppPayloadHandler(a.HandleIncomingPayload) + + // ERROR frame + c.OnError(func(code, desc string) { + a.ui.Printf("\n[ERROR] %s: %s\n> ", code, desc) + }) + + // STATUS_RES frame + c.OnStatus(func(target, status string) { + if target == "" && status == "no_target" { + a.ui.Printf("\n[CHAT] Czat zakończony.\n> ") + return + } + a.ui.Printf("\n[STATUS] %s is %s\n> ", target, status) + }) + + // Disconnect event + c.OnDisconnected(func(err error) { + a.ui.Printf("\n[DISCONNECTED] %v\n> ", err) + }) } -// InitSecretStoreForUser must be called AFTER successful AUTH. func (a *App) InitSecretStoreForUser(username string) { path := PathForUser(username) a.secrets = NewSecretStore(path) - // Auto-load all secrets into KittyClient for peer, secret := range a.secrets.All() { _ = a.client.SetSharedSecretForPeer(peer, secret) } } -func (a *App) Client() *api.KittyClient { - return a.client -} - -func (a *App) Secrets() *SecretStore { - return a.secrets -} - -func (a *App) Disconnected() <-chan struct{} { - return a.disconnected -} +func (a *App) Client() *api.KittyClient { return a.client } +func (a *App) Secrets() *SecretStore { return a.secrets } +func (a *App) Disconnected() <-chan struct{} { return a.disconnected } diff --git a/client/start.go b/client/start.go index 130d0de..24df4fe 100644 --- a/client/start.go +++ b/client/start.go @@ -10,12 +10,13 @@ import ( "github.com/gabbla05/KittyProtocol/client/ui_cli" ) -// Start is the main entrypoint for the KittyProtocol CLI client. -// It performs connection, HELLO handshake, AUTH flow, and launches the app. func Start() { client := api.NewKittyClient() ui := ui_cli.NewCliUI(client) + // Ustaw logger CLI + api.SetLogger(ui_cli.CliLogger{}) + disconnected := make(chan struct{}) hubAddr := os.Getenv("KITTY_HUB_ADDR") @@ -36,24 +37,17 @@ func Start() { return } - // ------------------------------- - // AUTH FLOW (CLI-specific) - // ------------------------------- + // AUTH FLOW if err := ui.RunAuthFlow(client); err == ui_cli.ErrQuitRequested { client.Close() return } - // ------------------------------- - // AUTH SUCCESS → start application - // ------------------------------- - + // AUTH SUCCESS application := app.NewApp(client, ui, disconnected) - application.InitSecretStoreForUser(client.User()) client.RegisterAckHandler(ui) - client.RegisterAppPayloadHandler(application.HandleIncomingPayload) setupSignalHandler(client) @@ -65,7 +59,6 @@ func Start() { client.Close() } -// setupSignalHandler installs OS signal handlers for graceful shutdown. func setupSignalHandler(client *api.KittyClient) { sigCh := make(chan os.Signal, 1) signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM, syscall.SIGQUIT) diff --git a/client/ui_cli/logger.go b/client/ui_cli/logger.go new file mode 100644 index 0000000..29d2fa5 --- /dev/null +++ b/client/ui_cli/logger.go @@ -0,0 +1,22 @@ +package ui_cli + +import ( + "fmt" + + "github.com/gabbla05/KittyProtocol/client/api" +) + +type CliLogger struct{} + +func (CliLogger) Log(level api.LogLevel, msg string) { + switch level { + case api.LogError: + fmt.Printf("[ERROR] %s\n", msg) + case api.LogWarn: + fmt.Printf("[WARN] %s\n", msg) + case api.LogInfo: + fmt.Printf("[INFO] %s\n", msg) + case api.LogDebug: + fmt.Printf("[DEBUG] %s\n", msg) + } +} diff --git a/cmd/client/main.go b/cmd/client/main.go deleted file mode 100644 index d0ebdad..0000000 --- a/cmd/client/main.go +++ /dev/null @@ -1,9 +0,0 @@ -package main - -import ( - "github.com/gabbla05/KittyProtocol/client" -) - -func main() { - client.Start() -} diff --git a/cmd/client_cli/main.go b/cmd/client_cli/main.go new file mode 100644 index 0000000..718c8d9 --- /dev/null +++ b/cmd/client_cli/main.go @@ -0,0 +1,13 @@ +package main + +import ( + "github.com/gabbla05/KittyProtocol/client" + "github.com/gabbla05/KittyProtocol/client/api" + "github.com/gabbla05/KittyProtocol/client/ui_cli" +) + +// Entry point for the CLI client application. +func main() { + api.SetLogger(ui_cli.CliLogger{}) + client.Start() +} From ce453fe832ece7772608b75424c3f597d090e42f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Brzezi=C5=84ski?= Date: Mon, 25 May 2026 18:21:44 +0000 Subject: [PATCH 29/44] async prototype for wails works now - 2nd phase of refactor done --- TODO | 6 +- client/api/auth.go | 68 +++---------- client/api/client.go | 139 +++++++++++++++++--------- client/api/connect.go | 17 +--- client/api/e2ee.go | 11 +++ client/api/hello.go | 49 ---------- client/api/receive.go | 184 ++++++++++++++++++++++++++++++----- client/app/app.go | 49 +++++++++- client/app/chat_handlers.go | 43 -------- client/app/chat_logic.go | 74 +++++++++++--- client/app/chat_state.go | 62 ++++++++---- client/app/handle_payload.go | 19 ---- client/start.go | 35 +++++-- client/ui_cli/auth_flow.go | 42 +++++--- client/ui_cli/menu.go | 63 ++++++++---- 15 files changed, 532 insertions(+), 329 deletions(-) delete mode 100644 client/app/chat_handlers.go delete mode 100644 client/app/handle_payload.go diff --git a/TODO b/TODO index 17e06c0..b59bccc 100644 --- a/TODO +++ b/TODO @@ -43,4 +43,8 @@ UZUPEŁNIĆ DOKUMENTACJE W GITHUB O MEOWSSENGER I W README DAĆ ŻE EOWSSENGER P - godoc !!! Sporawdzić czy zadziała u nas -- CI/CD \ No newline at end of file +- CI/CD +- jeszcze w logice klienta -> jak robi quit to robi też /end a nie zostawia czatu tak o bo wtedy błędy się robią +- gwiazdki w cli jak się hasło wpisuje albo nie pokazywać jak się wpisuje w ogóle bo obecnie się wyświetla w konsoli normalnie +- czy komendy typu /fdfsdfdsf nie powinny byćogólnie dostępne, także w gui na przyszłość, żeby gui wołało tylko jakąś komendę np. /status albo /chat itp +- na język polski zmiana \ No newline at end of file diff --git a/client/api/auth.go b/client/api/auth.go index 34c7f47..f193fa1 100644 --- a/client/api/auth.go +++ b/client/api/auth.go @@ -2,15 +2,13 @@ package api import ( "encoding/json" - "errors" - "fmt" "time" "github.com/gabbla05/KittyProtocol/protocol" ) // ----------------------------------------------------------------------------- -// AUTH +// AUTH (asynchronous) // ----------------------------------------------------------------------------- func (c *KittyClient) SendAuth(user, pass string) error { @@ -36,22 +34,19 @@ func (c *KittyClient) SendAuth(user, pass string) error { // Save username c.mu.Lock() c.user = user + c.state = StateAuthenticating c.mu.Unlock() _, err = stream.Write(b) return err } -func (c *KittyClient) WaitForAuthOK() error { - ok, code := c.waitOkOrError() - if !ok { - return errors.New(code) - } - return nil +func (c *KittyClient) AuthResult() <-chan OpResult { + return c.authCh } // ----------------------------------------------------------------------------- -// REGISTER +// REGISTER (asynchronous) // ----------------------------------------------------------------------------- func (c *KittyClient) SendRegister(user, pass string) error { @@ -74,57 +69,22 @@ func (c *KittyClient) SendRegister(user, pass string) error { return err } + c.mu.Lock() + c.state = StateRegistering + c.mu.Unlock() + _, err = stream.Write(b) return err } -func (c *KittyClient) WaitForRegisterOK() error { - ok, code := c.waitOkOrError() - if !ok { - return errors.New(code) - } - return nil +func (c *KittyClient) RegisterResult() <-chan OpResult { + return c.registerCh } // ----------------------------------------------------------------------------- -// Shared helper +// HELLO (asynchronous) // ----------------------------------------------------------------------------- -func (c *KittyClient) waitOkOrError() (bool, string) { - c.mu.Lock() - stream := c.stream - c.mu.Unlock() - - if stream == nil { - return false, "NO_STREAM" - } - - buf := make([]byte, defaultRecvBufferSize) - n, err := stream.Read(buf) - if err != nil { - return false, "READ_ERROR" - } - - typeName, _, err := protocol.GetFrameType(buf[:n]) - if err != nil { - return false, "PARSE_ERROR" - } - - switch typeName { - - case protocol.FrameTypeError: - var errFrame protocol.ErrorFrame - if json.Unmarshal(buf[:n], &errFrame) == nil { - if errFrame.Desc != "" { - return false, fmt.Sprintf("%s: %s", errFrame.Code, errFrame.Desc) - } - return false, errFrame.Code - } - return false, "PARSE_ERROR" - - case protocol.FrameTypeMeowOK: - return true, "" - } - - return false, "UNKNOWN_FRAME" +func (c *KittyClient) HelloResult() <-chan OpResult { + return c.helloCh } diff --git a/client/api/client.go b/client/api/client.go index 73919f7..a06b8e5 100644 --- a/client/api/client.go +++ b/client/api/client.go @@ -7,53 +7,37 @@ import ( "github.com/gabbla05/KittyProtocol/internal/protection" ) -// ClientState represents the high-level lifecycle state of the client. -// It is intentionally coarse-grained: UI layers decide how to interpret it. type ClientState int -// AppPayloadHandler is a callback used by the application layer (client/app) -// to receive decrypted DATA payloads from KittyClient. type AppPayloadHandler func(sender string, payload []byte) - -// ErrorHandler is invoked when the Hub sends an ERROR frame. type ErrorHandler func(code, desc string) - -// StatusHandler is invoked when the Hub sends a STATUS_RES frame. type StatusHandler func(target, status string) - -// DisconnectHandler is invoked when the underlying connection is closed -// or the receiver loop terminates with an error. type DisconnectHandler func(err error) const ( - StateDisconnected ClientState = iota // No QUIC connection - StateHandshaking // HELLO sent, waiting for MEOW_OK - StateAuthenticating // AUTH sent, waiting for MEOW_OK - StateSelectingTarget // Logged in, waiting for UI to choose target - StateEstablished // Ready for encrypted DATA exchange + StateDisconnected ClientState = iota + StateHandshaking + StateAuthenticating + StateRegistering + StateSelectingTarget + StateEstablished ) -// peerKeys holds derived encryption and MAC keys for a single peer. type peerKeys struct { kEnc []byte kMac []byte } -// KittyClient is the core client structure. -// It contains no UI logic and is safe to use from any frontend (CLI, GUI, etc.). type KittyClient struct { mu sync.Mutex state ClientState - // QUIC transport (wrapped in adapters for testability and GUI-friendliness) conn ConnAdapter stream StreamAdapter - // Session metadata user string target string - // Subsystems ackMgr *AckManager replay *protection.ReplayDetector stopPing chan struct{} @@ -61,35 +45,47 @@ type KittyClient struct { ctx context.Context cancel context.CancelFunc - // Debug/testing - lastFrame []byte // last raw frame (used only for replay testing) + lastFrame []byte - // E2EE keys per peer (logical username → keys) peerKeys map[string]peerKeys - // Application-level payload handler (chat, etc.) - appHandler AppPayloadHandler - - // Event handlers for UI/frontends + appHandler AppPayloadHandler errHandler ErrorHandler statusHandler StatusHandler disconnectHandler DisconnectHandler + + helloCh chan OpResult + authCh chan OpResult + registerCh chan OpResult + + chatReqCh chan ChatRequestEvent + chatAcceptCh chan ChatAcceptEvent + chatRefuseCh chan ChatRefuseEvent + chatEndCh chan ChatEndEvent + chatMsgCh chan ChatMessageEvent } -// NewKittyClient creates a new client instance in the Disconnected state. -// It initializes ACK manager, replay detector, and background control channels. func NewKittyClient() *KittyClient { ctx, cancel := context.WithCancel(context.Background()) return &KittyClient{ - state: StateDisconnected, - ackMgr: NewAckManager(), - replay: protection.NewReplayDetector(), - stopPing: make(chan struct{}), - stopRecv: make(chan struct{}), - ctx: ctx, - cancel: cancel, - peerKeys: make(map[string]peerKeys), + state: StateDisconnected, + ackMgr: NewAckManager(), + replay: protection.NewReplayDetector(), + stopPing: make(chan struct{}), + stopRecv: make(chan struct{}), + ctx: ctx, + cancel: cancel, + peerKeys: make(map[string]peerKeys), + helloCh: make(chan OpResult, 1), + authCh: make(chan OpResult, 1), + registerCh: make(chan OpResult, 1), + + chatReqCh: make(chan ChatRequestEvent, 4), + chatAcceptCh: make(chan ChatAcceptEvent, 4), + chatRefuseCh: make(chan ChatRefuseEvent, 4), + chatEndCh: make(chan ChatEndEvent, 4), + chatMsgCh: make(chan ChatMessageEvent, 16), } } @@ -123,15 +119,70 @@ func (c *KittyClient) User() string { return c.user } -// getKeysForPeer returns derived keys for a given peer, if present. -// Caller MUST hold c.mu. func (c *KittyClient) getKeysForPeer(peer string) (kEnc, kMac []byte, ok bool) { - if c.peerKeys == nil { - return nil, nil, false - } pk, exists := c.peerKeys[peer] if !exists { return nil, nil, false } return pk.kEnc, pk.kMac, true } + +type OpResult struct { + OK bool + Code string + Desc string +} + +func (r OpResult) Error() string { + if r.OK { + return "" + } + if r.Desc != "" { + return r.Code + ": " + r.Desc + } + return r.Code +} + +type ChatRequestEvent struct { + From string +} + +type ChatAcceptEvent struct { + From string +} + +type ChatRefuseEvent struct { + From string + Reason string +} + +type ChatEndEvent struct { + From string + Reason string +} + +type ChatMessageEvent struct { + From string + Text string +} + +// Chat event getters +func (c *KittyClient) ChatRequestEvents() <-chan ChatRequestEvent { + return c.chatReqCh +} + +func (c *KittyClient) ChatAcceptEvents() <-chan ChatAcceptEvent { + return c.chatAcceptCh +} + +func (c *KittyClient) ChatRefuseEvents() <-chan ChatRefuseEvent { + return c.chatRefuseCh +} + +func (c *KittyClient) ChatEndEvents() <-chan ChatEndEvent { + return c.chatEndCh +} + +func (c *KittyClient) ChatMessageEvents() <-chan ChatMessageEvent { + return c.chatMsgCh +} diff --git a/client/api/connect.go b/client/api/connect.go index 1240071..92f7579 100644 --- a/client/api/connect.go +++ b/client/api/connect.go @@ -13,7 +13,7 @@ import ( // BEHAVIOR: // - If the client was previously connected, Connect() implicitly closes the old connection. // - Performs TLS 1.3 setup, QUIC dial, TOFU certificate verification, and stream opening. -// - Sends the HELLO frame immediately after the stream is opened. +// - Sends the HELLO frame immediately after the stream is opened (non‑blocking). // - Does NOT start receiver or ping loops — the caller must start them manually. // // STATE TRANSITIONS: @@ -28,8 +28,6 @@ func (c *KittyClient) Connect(hubAddr string) error { c.Close() // Recreate control channels for background loops. - // Close() closes stopPing/stopRecv; they must be reinitialized - // before starting new receiver/ping goroutines. c.mu.Lock() c.stopPing = make(chan struct{}) c.stopRecv = make(chan struct{}) @@ -76,7 +74,7 @@ func (c *KittyClient) Connect(hubAddr string) error { c.ackMgr = NewAckManager() c.mu.Unlock() - // Send HELLO immediately + // Send HELLO immediately (async handshake) if err := c.SendHello(); err != nil { return err } @@ -86,17 +84,6 @@ func (c *KittyClient) Connect(hubAddr string) error { } // Disconnect closes the QUIC connection and stream. -// This is a convenience wrapper around KittyClient.Close(). func (c *KittyClient) Disconnect() { c.Close() } - -// WaitForHelloOK waits for MEOW_OK after HELLO and transitions to StateAuthenticating. -func (c *KittyClient) WaitForHelloOK() error { - ok, code := c.waitHelloOK() - if ok { - c.setState(StateAuthenticating) - return nil - } - return errors.New(code) -} diff --git a/client/api/e2ee.go b/client/api/e2ee.go index 8d623f0..e11d730 100644 --- a/client/api/e2ee.go +++ b/client/api/e2ee.go @@ -38,3 +38,14 @@ func (c *KittyClient) SetSharedSecretForPeer(peer string, secret []byte) error { } return nil } + +func (c *KittyClient) HasSharedSecret(peer string) bool { + c.mu.Lock() + defer c.mu.Unlock() + + if c.peerKeys == nil { + return false + } + _, ok := c.peerKeys[peer] + return ok +} diff --git a/client/api/hello.go b/client/api/hello.go index aefd065..1b74f0c 100644 --- a/client/api/hello.go +++ b/client/api/hello.go @@ -36,52 +36,3 @@ func (c *KittyClient) SendHello() error { return nil } - -// waitHelloOK waits for MEOW_OK or ERROR after HELLO. -// Returns (success, errorCode). -// -// BLOCKING BEHAVIOR: -// - This call blocks until the Hub responds or the stream errors. -// - It does not enforce a timeout; QUIC idle timeout applies. -// -// PROTOCOL: -// - Expected responses: MEOW_OK or ERROR. -// - Any other frame type is treated as UNKNOWN_FRAME. -func (c *KittyClient) waitHelloOK() (bool, string) { - c.mu.Lock() - stream := c.stream - c.mu.Unlock() - - if stream == nil { - return false, "NO_STREAM" - } - - buf := make([]byte, defaultRecvBufferSize) - n, err := stream.Read(buf) - if err != nil { - return false, "READ_ERROR" - } - - typeName, _, err := protocol.GetFrameType(buf[:n]) - if err != nil { - return false, "PARSE_ERROR" - } - - switch typeName { - case protocol.FrameTypeError: - var errFrame protocol.ErrorFrame - if json.Unmarshal(buf[:n], &errFrame) == nil { - return false, errFrame.Code - } - return false, "PARSE_ERROR" - - case protocol.FrameTypeMeowOK: - var okFrame protocol.MeowOkFrame - if json.Unmarshal(buf[:n], &okFrame) != nil { - return false, "PARSE_ERROR" - } - return true, "" - } - - return false, "UNKNOWN_FRAME" -} diff --git a/client/api/receive.go b/client/api/receive.go index dbd212e..adeeca8 100644 --- a/client/api/receive.go +++ b/client/api/receive.go @@ -7,18 +7,41 @@ import ( "github.com/gabbla05/KittyProtocol/protocol" ) -// StartReceiverLoop launches a background goroutine responsible for reading -// all incoming frames from the QUIC stream. This is the only reader for the -// stream; all other components must communicate through higher‑level APIs. +type chatFrameProbe struct { + Type string `json:"type"` + From string `json:"from"` + To string `json:"to"` + Payload json.RawMessage `json:"payload,omitempty"` +} + +type chatRefusePayload struct { + Reason string `json:"reason,omitempty"` +} + +type chatEndPayload struct { + Reason string `json:"reason,omitempty"` +} + +type textMessagePayload struct { + Text string `json:"text"` +} + func (c *KittyClient) StartReceiverLoop(disconnected chan struct{}) { c.mu.Lock() stream := c.stream replay := c.replay ackMgr := c.ackMgr stopRecv := c.stopRecv - errHandler := c.errHandler - statusHandler := c.statusHandler - disconnectHandler := c.disconnectHandler + + helloCh := c.helloCh + authCh := c.authCh + registerCh := c.registerCh + + chatReqCh := c.chatReqCh + chatAcceptCh := c.chatAcceptCh + chatRefuseCh := c.chatRefuseCh + chatEndCh := c.chatEndCh + chatMsgCh := c.chatMsgCh c.mu.Unlock() if stream == nil { @@ -37,8 +60,13 @@ func (c *KittyClient) StartReceiverLoop(disconnected chan struct{}) { n, err := stream.Read(buf) if err != nil { - if disconnectHandler != nil { - disconnectHandler(err) + // dynamiczny disconnectHandler + c.mu.Lock() + dh := c.disconnectHandler + c.mu.Unlock() + + if dh != nil { + dh(err) } else { log(LogError, "disconnected: %v", err) } @@ -51,10 +79,16 @@ func (c *KittyClient) StartReceiverLoop(disconnected chan struct{}) { return } - typeName, msgID, err := protocol.GetFrameType(buf[:n]) + frameBytes := buf[:n] + + typeName, msgID, err := protocol.GetFrameType(frameBytes) if err != nil { - if errHandler != nil { - errHandler("PARSE_ERROR", err.Error()) + c.mu.Lock() + eh := c.errHandler + c.mu.Unlock() + + if eh != nil { + eh("PARSE_ERROR", err.Error()) } else { log(LogError, "parse error: %v", err) } @@ -63,26 +97,74 @@ func (c *KittyClient) StartReceiverLoop(disconnected chan struct{}) { switch typeName { + // ============================================================ + // MEOW_OK — odpowiedź na HELLO / AUTH / REGISTER + // ============================================================ case protocol.FrameTypeMeowOK: - if ackMgr != nil { - ackMgr.NotifyDelivered(msgID) + c.mu.Lock() + currentState := c.state + c.mu.Unlock() + + switch currentState { + + case StateHandshaking: + helloCh <- OpResult{OK: true} + c.setState(StateAuthenticating) + + case StateAuthenticating: + authCh <- OpResult{OK: true} + c.setState(StateSelectingTarget) + + case StateRegistering: + registerCh <- OpResult{OK: true} + c.setState(StateAuthenticating) + + default: + if ackMgr != nil { + ackMgr.NotifyDelivered(msgID) + } } + // ============================================================ + // ERROR — odpowiedź na HELLO / AUTH / REGISTER + // ============================================================ case protocol.FrameTypeError: var ef protocol.ErrorFrame - if json.Unmarshal(buf[:n], &ef) == nil { - if errHandler != nil { - errHandler(ef.Code, ef.Desc) + if json.Unmarshal(frameBytes, &ef) != nil { + log(LogError, "failed to parse ERROR frame") + continue + } + + c.mu.Lock() + currentState := c.state + eh := c.errHandler + c.mu.Unlock() + + switch currentState { + + case StateHandshaking: + helloCh <- OpResult{OK: false, Code: ef.Code, Desc: ef.Desc} + + case StateAuthenticating: + authCh <- OpResult{OK: false, Code: ef.Code, Desc: ef.Desc} + + case StateRegistering: + registerCh <- OpResult{OK: false, Code: ef.Code, Desc: ef.Desc} + + default: + if eh != nil { + eh(ef.Code, ef.Desc) } else { log(LogError, "server error %s: %s", ef.Code, ef.Desc) } - } else { - log(LogError, "failed to parse ERROR frame") } + // ============================================================ + // DATA — E2EE chat payload (lub inne dane aplikacyjne) + // ============================================================ case protocol.FrameTypeData: var df protocol.DataFrame - if json.Unmarshal(buf[:n], &df) != nil { + if json.Unmarshal(frameBytes, &df) != nil { log(LogError, "failed to parse DATA frame") continue } @@ -92,8 +174,8 @@ func (c *KittyClient) StartReceiverLoop(disconnected chan struct{}) { } c.mu.Lock() - handler := c.appHandler kEnc, kMac, ok := c.getKeysForPeer(df.Sender) + appHandler := c.appHandler c.mu.Unlock() if !ok { @@ -109,19 +191,71 @@ func (c *KittyClient) StartReceiverLoop(disconnected chan struct{}) { continue } - if handler != nil { - handler(df.Sender, []byte(plaintext)) + // Spróbuj zinterpretować jako ChatFrame + var probe chatFrameProbe + if err := json.Unmarshal([]byte(plaintext), &probe); err == nil && probe.Type != "" { + switch probe.Type { + + case "CHAT_REQUEST": + if chatReqCh != nil { + chatReqCh <- ChatRequestEvent{From: probe.From} + } + + case "CHAT_ACCEPT": + if chatAcceptCh != nil { + chatAcceptCh <- ChatAcceptEvent{From: probe.From} + } + + case "CHAT_REFUSE": + var p chatRefusePayload + _ = json.Unmarshal(probe.Payload, &p) + if chatRefuseCh != nil { + chatRefuseCh <- ChatRefuseEvent{From: probe.From, Reason: p.Reason} + } + + case "CHAT_END": + var p chatEndPayload + _ = json.Unmarshal(probe.Payload, &p) + if chatEndCh != nil { + chatEndCh <- ChatEndEvent{From: probe.From, Reason: p.Reason} + } + + case "TEXT_MESSAGE": + var p textMessagePayload + _ = json.Unmarshal(probe.Payload, &p) + if chatMsgCh != nil { + chatMsgCh <- ChatMessageEvent{From: probe.From, Text: p.Text} + } + + default: + log(LogWarn, "unknown chat frame type: %s", probe.Type) + } + + // Chat‑frame obsłużony + continue + } + + // Fallback: inne dane aplikacyjne + if appHandler != nil { + appHandler(df.Sender, []byte(plaintext)) } + // ============================================================ + // STATUS_RES + // ============================================================ case protocol.FrameTypeStatusRes: var sf protocol.StatusResFrame - if json.Unmarshal(buf[:n], &sf) != nil { + if json.Unmarshal(frameBytes, &sf) != nil { log(LogError, "failed to parse STATUS_RES") continue } - if statusHandler != nil { - statusHandler(sf.Target, sf.Status) + c.mu.Lock() + sh := c.statusHandler + c.mu.Unlock() + + if sh != nil { + sh(sf.Target, sf.Status) } else { log(LogInfo, "status: %s is %s", sf.Target, sf.Status) } diff --git a/client/app/app.go b/client/app/app.go index 8f01407..f88756c 100644 --- a/client/app/app.go +++ b/client/app/app.go @@ -29,17 +29,25 @@ func NewApp(c *api.KittyClient, ui UI, disconnected <-chan struct{}) *App { } a.attachEventHandlers() + go a.handleChatEvents() + return a } func (a *App) attachEventHandlers() { c := a.client - // Decrypted DATA → chat logic - c.RegisterAppPayloadHandler(a.HandleIncomingPayload) - // ERROR frame c.OnError(func(code, desc string) { + // Jeśli w trakcie aktywnego czatu dostaniemy ERR_15, + // potraktuj to jako „peer zniknął” i zamknij lokalnie czat. + if code == "ERR_15" { + if active, _ := a.chatState.IsActive(); active { + a.chatState.EndChat() + a.ui.Printf("\n[CHAT] Czat zakończony (peer unavailable: %s).\n> ", desc) + return + } + } a.ui.Printf("\n[ERROR] %s: %s\n> ", code, desc) }) @@ -54,6 +62,8 @@ func (a *App) attachEventHandlers() { // Disconnect event c.OnDisconnected(func(err error) { + // Przy rozłączeniu zawsze czyścimy stan czatu lokalnie. + a.chatState.EndChat() a.ui.Printf("\n[DISCONNECTED] %v\n> ", err) }) } @@ -70,3 +80,36 @@ func (a *App) InitSecretStoreForUser(username string) { func (a *App) Client() *api.KittyClient { return a.client } func (a *App) Secrets() *SecretStore { return a.secrets } func (a *App) Disconnected() <-chan struct{} { return a.disconnected } + +// Udostępniamy ChatState dla UI (np. do obsługi /quit). +func (a *App) ChatState() *ChatState { + return a.chatState +} + +func (a *App) handleChatEvents() { + for { + select { + case ev := <-a.client.ChatRequestEvents(): + a.chatState.SetPendingRequest(ev.From) + a.ui.Printf( + "\n[CHAT] %s chce z Tobą rozmawiać. Użyj /accept %s lub /refuse %s\n> ", + ev.From, ev.From, ev.From, + ) + + case ev := <-a.client.ChatAcceptEvents(): + a.chatState.SetActive(ev.From) + a.ui.Printf("\n[CHAT] %s zaakceptował czat.\n> ", ev.From) + + case ev := <-a.client.ChatRefuseEvents(): + a.chatState.ClearPendingRequest() + a.ui.Printf("\n[CHAT] %s odrzucił czat: %s\n> ", ev.From, ev.Reason) + + case ev := <-a.client.ChatEndEvents(): + a.chatState.EndChat() + a.ui.Printf("\n[CHAT] %s zakończył czat: %s\n> ", ev.From, ev.Reason) + + case ev := <-a.client.ChatMessageEvents(): + a.ui.Printf("\n[%s] %s\n> ", ev.From, ev.Text) + } + } +} diff --git a/client/app/chat_handlers.go b/client/app/chat_handlers.go deleted file mode 100644 index ab819f1..0000000 --- a/client/app/chat_handlers.go +++ /dev/null @@ -1,43 +0,0 @@ -package app - -import ( - "encoding/json" -) - -func (a *App) HandleIncomingChatFrame(frame ChatFrame) { - switch frame.Type { - - case ChatRequest: - // Jeśli jesteśmy w czacie → ignorujemy - if a.chatState.Active { - a.ui.Printf("\n[CHAT] Otrzymano CHAT_REQUEST od %s, ale czat jest już aktywny.\n> ", frame.From) - return - } - - a.chatState.SetPendingRequest(frame.From) - a.ui.Printf("\n[CHAT REQUEST] %s chce z Tobą rozmawiać.\nUżyj: /accept %s lub /refuse %s\n> ", - frame.From, frame.From, frame.From) - - case ChatAccept: - a.chatState.SetActive(frame.From) - a.ui.Printf("\n[CHAT ACCEPTED] %s zaakceptował czat.\n> ", frame.From) - - case ChatRefuse: - var p ChatRefusePayload - _ = json.Unmarshal(frame.Payload, &p) - a.chatState.ClearPendingRequest() - a.ui.Printf("\n[CHAT REFUSED] %s odrzucił czat: %s\n> ", frame.From, p.Reason) - - case ChatEnd: - a.chatState.EndChat() - a.ui.Printf("\n[CHAT ENDED] %s zakończył czat.\n> ", frame.From) - - case TextMessage: - var p TextMessagePayload - _ = json.Unmarshal(frame.Payload, &p) - a.ui.Printf("\n[%s]: %s\n> ", frame.From, p.Text) - - default: - a.ui.Printf("\n[CHAT] Nieznany typ ramki: %s\n> ", frame.Type) - } -} diff --git a/client/app/chat_logic.go b/client/app/chat_logic.go index 52de312..1e92290 100644 --- a/client/app/chat_logic.go +++ b/client/app/chat_logic.go @@ -11,8 +11,17 @@ func (a *App) StartChatRequest(target string) error { if target == "" { return errors.New("target cannot be empty") } - if a.chatState.Active { - return errors.New("chat already active") + + if active, peer := a.chatState.IsActive(); active { + return fmt.Errorf("chat already active with %s", peer) + } + + if pending, from := a.chatState.HasAnyPending(); pending { + return fmt.Errorf("you have a pending request from %s — resolve it first", from) + } + + if !a.client.HasSharedSecret(target) { + return fmt.Errorf("no shared secret for %s", target) } frame := NewChatRequest(a.client.User(), target) @@ -20,45 +29,81 @@ func (a *App) StartChatRequest(target string) error { } func (a *App) AcceptChat(from string) error { + if from == "" { + return errors.New("from cannot be empty") + } + + if !a.chatState.HasPendingFrom(from) { + return fmt.Errorf("no pending chat request from %s", from) + } + frame := NewChatAccept(a.client.User(), from) + + if err := a.sendAppFrame(frame); err != nil { + return err + } + + // Responder wchodzi w stan Active lokalnie a.chatState.SetActive(from) - return a.sendAppFrame(frame) + return nil } func (a *App) RefuseChat(from, reason string) error { + if from == "" { + return errors.New("from cannot be empty") + } + + if !a.chatState.HasPendingFrom(from) { + return fmt.Errorf("no pending chat request from %s", from) + } + frame := NewChatRefuse(a.client.User(), from, reason) + + if err := a.sendAppFrame(frame); err != nil { + return err + } + a.chatState.ClearPendingRequest() - return a.sendAppFrame(frame) + return nil } func (a *App) EndChat(reason string) error { - if !a.chatState.Active { + active, peer := a.chatState.IsActive() + if !active { return errors.New("no active chat") } + if peer == "" { + return errors.New("no active target") + } - target := a.chatState.ActiveTarget - frame := NewChatEnd(a.client.User(), target, reason) + frame := NewChatEnd(a.client.User(), peer, reason) + + if err := a.sendAppFrame(frame); err != nil { + return err + } a.chatState.EndChat() - return a.sendAppFrame(frame) + return nil } func (a *App) SendTextMessage(text string) error { - if !a.chatState.Active { - return errors.New("chat not active") - } if text == "" { return errors.New("text cannot be empty") } - target := a.chatState.ActiveTarget - frame := NewTextMessage(a.client.User(), target, text) + active, peer := a.chatState.IsActive() + if !active { + return errors.New("chat not active") + } + if peer == "" { + return errors.New("no active target") + } + frame := NewTextMessage(a.client.User(), peer, text) return a.sendAppFrame(frame) } func (a *App) sendAppFrame(frame ChatFrame) error { - // Canonicalize target to match transport‑layer expectations frame.To = strings.ToLower(strings.TrimSpace(frame.To)) data, err := json.Marshal(frame) @@ -66,6 +111,5 @@ func (a *App) sendAppFrame(frame ChatFrame) error { return fmt.Errorf("marshal chat frame: %w", err) } - // ALWAYS encrypted — Hub requires MAC return a.client.SendAppFrameEncrypted(frame.To, data) } diff --git a/client/app/chat_state.go b/client/app/chat_state.go index 8701d5d..7aa6afa 100644 --- a/client/app/chat_state.go +++ b/client/app/chat_state.go @@ -2,58 +2,78 @@ package app import "sync" -// ChatState holds the current chat state on the client side. -// It is fully independent from the transport layer. type ChatState struct { mu sync.Mutex - // Whether a chat session is currently active (after CHAT_ACCEPT). - Active bool - - // Logical username of the current chat peer. + Active bool ActiveTarget string - // If someone sent us a CHAT_REQUEST, this stores the sender username. - PendingRequestFrom string + Pending bool + PendingFrom string } -// NewChatState creates an empty chat state. func NewChatState() *ChatState { return &ChatState{} } -// SetActive marks a chat as active with the given target. -func (s *ChatState) SetActive(target string) { +// Incoming CHAT_REQUEST +func (s *ChatState) SetPendingRequest(from string) { s.mu.Lock() defer s.mu.Unlock() - s.Active = true - s.ActiveTarget = target - s.PendingRequestFrom = "" + s.Pending = true + s.PendingFrom = from } -// SetPendingRequest records an incoming chat request. -func (s *ChatState) SetPendingRequest(from string) { +// User accepted chat +func (s *ChatState) SetActive(peer string) { s.mu.Lock() defer s.mu.Unlock() - s.PendingRequestFrom = from + s.Active = true + s.ActiveTarget = peer + + s.Pending = false + s.PendingFrom = "" } -// ClearPendingRequest clears any pending chat request. +// User refused chat func (s *ChatState) ClearPendingRequest() { s.mu.Lock() defer s.mu.Unlock() - s.PendingRequestFrom = "" + s.Pending = false + s.PendingFrom = "" } -// EndChat ends the current chat and clears state. +// Chat ended func (s *ChatState) EndChat() { s.mu.Lock() defer s.mu.Unlock() s.Active = false s.ActiveTarget = "" - s.PendingRequestFrom = "" + + s.Pending = false + s.PendingFrom = "" +} + +// --- bezpieczne gettery / checki --- + +func (s *ChatState) IsActive() (bool, string) { + s.mu.Lock() + defer s.mu.Unlock() + return s.Active, s.ActiveTarget +} + +func (s *ChatState) HasPendingFrom(user string) bool { + s.mu.Lock() + defer s.mu.Unlock() + return s.Pending && s.PendingFrom == user +} + +func (s *ChatState) HasAnyPending() (bool, string) { + s.mu.Lock() + defer s.mu.Unlock() + return s.Pending, s.PendingFrom } diff --git a/client/app/handle_payload.go b/client/app/handle_payload.go deleted file mode 100644 index 59ef149..0000000 --- a/client/app/handle_payload.go +++ /dev/null @@ -1,19 +0,0 @@ -package app - -import ( - "encoding/json" -) - -// HandleIncomingPayload is called by KittyClient (via callback) -// whenever a decrypted DATA payload arrives. -func (a *App) HandleIncomingPayload(sender string, payload []byte) { - // Try to decode as ChatFrame - var cf ChatFrame - if err := json.Unmarshal(payload, &cf); err == nil && cf.Type != "" { - a.HandleIncomingChatFrame(cf) - return - } - - // Fallback: plain text message - a.ui.Printf("\n[%s]: %s\n> ", sender, string(payload)) -} diff --git a/client/start.go b/client/start.go index 24df4fe..84bc12c 100644 --- a/client/start.go +++ b/client/start.go @@ -4,6 +4,7 @@ import ( "os" "os/signal" "syscall" + "time" "github.com/gabbla05/KittyProtocol/client/api" "github.com/gabbla05/KittyProtocol/client/app" @@ -14,7 +15,7 @@ func Start() { client := api.NewKittyClient() ui := ui_cli.NewCliUI(client) - // Ustaw logger CLI + // Logger CLI api.SetLogger(ui_cli.CliLogger{}) disconnected := make(chan struct{}) @@ -26,24 +27,44 @@ func Start() { ui.Println("[Client] Connecting to Hub:", hubAddr) + // CONNECT if err := client.Connect(hubAddr); err != nil { ui.Println("[Client] Connection error:", err) return } - if err := client.WaitForHelloOK(); err != nil { - ui.Println("[Client] HELLO failed:", err) + // ---------------------------------------------------- + // START RECEIVER LOOP BEFORE WAITING FOR HELLO + // ---------------------------------------------------- + client.StartReceiverLoop(disconnected) + + // ---------------------------------------------------- + // ASYNC HELLO + // ---------------------------------------------------- + select { + case res := <-client.HelloResult(): + if !res.OK { + ui.Println("[Client] HELLO failed:", res.Error()) + client.Close() + return + } + case <-time.After(5 * time.Second): + ui.Println("[Client] HELLO timeout") client.Close() return } - // AUTH FLOW - if err := ui.RunAuthFlow(client); err == ui_cli.ErrQuitRequested { + // ---------------------------------------------------- + // AUTH FLOW (CLI-specific) + // ---------------------------------------------------- + if err := ui.RunAuthFlowAsync(client); err == ui_cli.ErrQuitRequested { client.Close() return } - // AUTH SUCCESS + // ---------------------------------------------------- + // AUTH SUCCESS → start application + // ---------------------------------------------------- application := app.NewApp(client, ui, disconnected) application.InitSecretStoreForUser(client.User()) @@ -51,7 +72,7 @@ func Start() { setupSignalHandler(client) - client.StartReceiverLoop(disconnected) + // Ping loop dopiero po AUTH client.StartPingLoop() ui.RunMainMenu(application) diff --git a/client/ui_cli/auth_flow.go b/client/ui_cli/auth_flow.go index 4869ee4..d005842 100644 --- a/client/ui_cli/auth_flow.go +++ b/client/ui_cli/auth_flow.go @@ -3,15 +3,15 @@ package ui_cli import ( "errors" "strings" + "time" "github.com/gabbla05/KittyProtocol/client/api" ) var ErrQuitRequested = errors.New("quit requested") -// RunAuthFlow handles /login and /register before entering main menu. -// This is CLI-specific and will be replaced by GUI in the future. -func (ui *CliUI) RunAuthFlow(client *api.KittyClient) error { +// Async AUTH/REGISTER flow +func (ui *CliUI) RunAuthFlowAsync(client *api.KittyClient) error { for { ui.Println("Wybierz opcję:") ui.Println(" /login") @@ -26,6 +26,9 @@ func (ui *CliUI) RunAuthFlow(client *api.KittyClient) error { client.Close() return ErrQuitRequested + // ---------------------------------------------------- + // REGISTER (async) + // ---------------------------------------------------- case "/register": user, pass := ui.ReadCredentials() @@ -34,13 +37,22 @@ func (ui *CliUI) RunAuthFlow(client *api.KittyClient) error { continue } - if err := client.WaitForRegisterOK(); err != nil { - ui.Println("[Client] REGISTER failed:", err) + select { + case res := <-client.RegisterResult(): + if !res.OK { + ui.Println("[Client] REGISTER failed:", res.Error()) + continue + } + ui.Println("[Client] REGISTER OK — możesz się teraz zalogować.") + + case <-time.After(5 * time.Second): + ui.Println("[Client] REGISTER timeout") continue } - ui.Println("[Client] REGISTER OK — możesz się teraz zalogować.") - + // ---------------------------------------------------- + // LOGIN (async) + // ---------------------------------------------------- case "/login": user, pass := ui.ReadCredentials() @@ -49,14 +61,20 @@ func (ui *CliUI) RunAuthFlow(client *api.KittyClient) error { continue } - if err := client.WaitForAuthOK(); err != nil { - ui.Println("[Client] AUTH failed:", err) + select { + case res := <-client.AuthResult(): + if !res.OK { + ui.Println("[Client] AUTH failed:", res.Error()) + continue + } + ui.Println("[Client] AUTH OK — zalogowano.") + return nil + + case <-time.After(5 * time.Second): + ui.Println("[Client] AUTH timeout") continue } - ui.Println("[Client] AUTH OK — zalogowano.") - return nil - default: ui.Println("Nieznana komenda.") } diff --git a/client/ui_cli/menu.go b/client/ui_cli/menu.go index 9498dde..6ce6964 100644 --- a/client/ui_cli/menu.go +++ b/client/ui_cli/menu.go @@ -2,13 +2,14 @@ package ui_cli import ( "bytes" + "errors" "os" "strings" + "github.com/gabbla05/KittyProtocol/client/api" "github.com/gabbla05/KittyProtocol/client/app" ) -// RunMainMenu displays the main command loop for CLI. func (ui *CliUI) RunMainMenu(a *app.App) { for { select { @@ -26,12 +27,21 @@ func (ui *CliUI) RunMainMenu(a *app.App) { switch { - // Exit + // ---------------------------------------------------- + // QUIT + // ---------------------------------------------------- case line == "/quit": + if active, peer := a.ChatState().IsActive(); active && peer != "" { + if err := a.EndChat("user quit client"); err != nil { + ui.Println("[CHAT ERROR]", err) + } + } _ = a.Client().SendBye() return - // Presence status + // ---------------------------------------------------- + // STATUS + // ---------------------------------------------------- case strings.HasPrefix(line, "/status "): user := strings.TrimSpace(strings.TrimPrefix(line, "/status ")) user = strings.ToLower(user) @@ -41,7 +51,9 @@ func (ui *CliUI) RunMainMenu(a *app.App) { } _ = a.Client().SendGetStatus(user) - // Configure shared secret for a peer (E2EE) + // ---------------------------------------------------- + // SECRET (local only) + // ---------------------------------------------------- case strings.HasPrefix(line, "/secret "): args := strings.Fields(line) if len(args) < 2 { @@ -75,7 +87,9 @@ func (ui *CliUI) RunMainMenu(a *app.App) { ui.Printf("[E2EE] Shared secret configured for %s.\n", user) - // Start chat + // ---------------------------------------------------- + // CHAT REQUEST (async) + // ---------------------------------------------------- case strings.HasPrefix(line, "/chat "): user := strings.TrimSpace(strings.TrimPrefix(line, "/chat ")) user = strings.ToLower(user) @@ -85,12 +99,19 @@ func (ui *CliUI) RunMainMenu(a *app.App) { } if err := a.StartChatRequest(user); err != nil { - ui.Println("Błąd:", err) + // Ładny komunikat dla braku sekretu + if errors.Is(err, api.ErrNoSharedSecret) || strings.Contains(err.Error(), "no shared secret") { + ui.Printf("[CHAT] Brak wspólnego sekretu z %s. Użyj /secret %s.\n", user, user) + } else { + ui.Println("[CHAT ERROR]", err) + } } else { ui.Printf("[CHAT] Wysłano CHAT_REQUEST do %s.\n", user) } - // Accept chat request + // ---------------------------------------------------- + // ACCEPT CHAT + // ---------------------------------------------------- case strings.HasPrefix(line, "/accept "): user := strings.TrimSpace(strings.TrimPrefix(line, "/accept ")) user = strings.ToLower(user) @@ -100,12 +121,12 @@ func (ui *CliUI) RunMainMenu(a *app.App) { } if err := a.AcceptChat(user); err != nil { - ui.Println("Błąd:", err) - } else { - ui.Printf("[CHAT] Zaakceptowano czat z %s.\n", user) + ui.Println("[CHAT ERROR]", err) } - // Refuse chat request + // ---------------------------------------------------- + // REFUSE CHAT + // ---------------------------------------------------- case strings.HasPrefix(line, "/refuse "): user := strings.TrimSpace(strings.TrimPrefix(line, "/refuse ")) user = strings.ToLower(user) @@ -115,12 +136,12 @@ func (ui *CliUI) RunMainMenu(a *app.App) { } if err := a.RefuseChat(user, "user refused"); err != nil { - ui.Println("Błąd:", err) - } else { - ui.Printf("[CHAT] Odrzucono czat z %s.\n", user) + ui.Println("[CHAT ERROR]", err) } - // Send message in active chat + // ---------------------------------------------------- + // SEND MESSAGE + // ---------------------------------------------------- case strings.HasPrefix(line, "/msg "): text := strings.TrimSpace(strings.TrimPrefix(line, "/msg ")) if text == "" { @@ -128,15 +149,15 @@ func (ui *CliUI) RunMainMenu(a *app.App) { } if err := a.SendTextMessage(text); err != nil { - ui.Println("Błąd:", err) + ui.Println("[CHAT ERROR]", err) } - // End chat + // ---------------------------------------------------- + // END CHAT + // ---------------------------------------------------- case line == "/end": if err := a.EndChat("user ended chat"); err != nil { - ui.Println("Błąd:", err) - } else { - ui.Println("[CHAT] Zakończono czat.") + ui.Println("[CHAT ERROR]", err) } default: @@ -148,7 +169,7 @@ func (ui *CliUI) RunMainMenu(a *app.App) { func (ui *CliUI) printMenu() { ui.Println("Dostępne komendy:") ui.Println(" /status ") - ui.Println(" /secret # configure shared secret for peer") + ui.Println(" /secret ") ui.Println(" /chat ") ui.Println(" /accept ") ui.Println(" /refuse ") From fa4571da48916a98e861b245dd6188e6a665bf4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Brzezi=C5=84ski?= Date: Mon, 25 May 2026 20:24:00 +0000 Subject: [PATCH 30/44] chatting with yourself fix issue --- TODO | 17 ++++++++++++++--- client/app/chat_logic.go | 22 ++++++++++++++++++++++ 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/TODO b/TODO index b59bccc..18363df 100644 --- a/TODO +++ b/TODO @@ -19,7 +19,7 @@ CLIENT - Uważać czy wszędzie gdzie trzeba jest: _ "github.com/lib/pq" !!!!! -- możliwość usunięcia konta poprzez /delete lub /remove z opcją \ i wtedy powinno poprosić o uwierzytelnienie (wpisanie hasła do logowania w ramach zatwerdzenia). Po zakończeniu operacji powinno wyjść jak za pomocą /quit + usunąć rekord z userem z bazy danych +- możliwość usunięcia konta poprzez /delete lub /remove z opcją \ i wtedy powinno poprosić o uwierzytelnienie (wpisanie hasła do logowania w ramach zatwerdzenia). Do tej operacji trzeba być zalogowanym to po zakończeniu operacji powinno wyjść jak za pomocą /quit + usunąć rekord z userem z bazy danych - update cross-networkowych rzeczy (przetestowanie i markdown update) @@ -44,7 +44,18 @@ UZUPEŁNIĆ DOKUMENTACJE W GITHUB O MEOWSSENGER I W README DAĆ ŻE EOWSSENGER P - godoc !!! Sporawdzić czy zadziała u nas - CI/CD -- jeszcze w logice klienta -> jak robi quit to robi też /end a nie zostawia czatu tak o bo wtedy błędy się robią - gwiazdki w cli jak się hasło wpisuje albo nie pokazywać jak się wpisuje w ogóle bo obecnie się wyświetla w konsoli normalnie - czy komendy typu /fdfsdfdsf nie powinny byćogólnie dostępne, także w gui na przyszłość, żeby gui wołało tylko jakąś komendę np. /status albo /chat itp -- na język polski zmiana \ No newline at end of file +- na język angielski zmiana wszystkiego w kodzie + + +===================================================================================================================================== +Jest jeszcze kilka rzeczy do zrobienia w tym projekcie. Daję listę, którą fajnie byłoby zrealizować: +- dlaczego [Client: Receive] Parse error: ERR_02: JSON parsing error przy wysyłaniu do samego siebie, powinna być możliwość zablokowania wysyłania do siebie (mam logi do takiej sytuacji) +- forma przechowywania sekretów lokalnie - słabe szyfrowanie chyba bo widać że sekrety są te same. Zamiast tego zastanowić się nad hashowaniem +- jaka jest obecnie SENSOWNOŚĆ CERTYFIKATÓW!. zastanowić się nad tym +- możliwość usunięcia konta poprzez /delete lub /remove z opcją \ i wtedy powinno poprosić o uwierzytelnienie (wpisanie hasła do logowania w ramach zatwerdzenia). Do tej operacji trzeba być zalogowanym to po zakończeniu operacji powinno wyjść jak za pomocą /quit + usunąć rekord z userem z bazy danych +- kolorowa konsola klienta w wersji cli +- testy jednostkowe z bardzo dobrym pokryciem +- gwiazdki w cli jak się hasło wpisuje albo nie pokazywać jak się wpisuje w ogóle bo obecnie się wyświetla w konsoli normalnie +- czy komendy typu /command nie powinny być ogólnie dostępne, także w gui na przyszłość, żeby gui wołało tylko jakąś komendę np. /status albo /chat itp pytanie do zasaatnowienia i odpowiedzi. Wiem że sporo tego, ale możesz to podzielić namniejsze etapy którymi będziemy się zajmować teraz po kolei \ No newline at end of file diff --git a/client/app/chat_logic.go b/client/app/chat_logic.go index 1e92290..acba395 100644 --- a/client/app/chat_logic.go +++ b/client/app/chat_logic.go @@ -12,6 +12,10 @@ func (a *App) StartChatRequest(target string) error { return errors.New("target cannot be empty") } + if target == a.client.User() { + return errors.New("cannot chat with yourself") + } + if active, peer := a.chatState.IsActive(); active { return fmt.Errorf("chat already active with %s", peer) } @@ -33,6 +37,10 @@ func (a *App) AcceptChat(from string) error { return errors.New("from cannot be empty") } + if from == a.client.User() { + return errors.New("cannot chat with yourself") + } + if !a.chatState.HasPendingFrom(from) { return fmt.Errorf("no pending chat request from %s", from) } @@ -53,6 +61,10 @@ func (a *App) RefuseChat(from, reason string) error { return errors.New("from cannot be empty") } + if from == a.client.User() { + return errors.New("cannot chat with yourself") + } + if !a.chatState.HasPendingFrom(from) { return fmt.Errorf("no pending chat request from %s", from) } @@ -76,6 +88,10 @@ func (a *App) EndChat(reason string) error { return errors.New("no active target") } + if peer == a.client.User() { + return errors.New("cannot chat with yourself") + } + frame := NewChatEnd(a.client.User(), peer, reason) if err := a.sendAppFrame(frame); err != nil { @@ -92,13 +108,19 @@ func (a *App) SendTextMessage(text string) error { } active, peer := a.chatState.IsActive() + if !active { return errors.New("chat not active") } + if peer == "" { return errors.New("no active target") } + if peer == a.client.User() { + return errors.New("cannot chat with yourself") + } + frame := NewTextMessage(a.client.User(), peer, text) return a.sendAppFrame(frame) } From 46adb86cfd469c7978fed789f66dbd0dd3114692 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Brzezi=C5=84ski?= Date: Mon, 25 May 2026 20:52:17 +0000 Subject: [PATCH 31/44] better encrypting on local device --- client/app/app.go | 4 +- client/app/secret_store.go | 125 ++++++++++++++++++++++++++++++++----- client/start.go | 12 +++- client/ui_cli/auth_flow.go | 6 +- 4 files changed, 126 insertions(+), 21 deletions(-) diff --git a/client/app/app.go b/client/app/app.go index f88756c..a92303c 100644 --- a/client/app/app.go +++ b/client/app/app.go @@ -68,9 +68,9 @@ func (a *App) attachEventHandlers() { }) } -func (a *App) InitSecretStoreForUser(username string) { +func (a *App) InitSecretStoreForUser(username string, masterKey []byte) { path := PathForUser(username) - a.secrets = NewSecretStore(path) + a.secrets = NewSecretStore(path, masterKey) for peer, secret := range a.secrets.All() { _ = a.client.SetSharedSecretForPeer(peer, secret) diff --git a/client/app/secret_store.go b/client/app/secret_store.go index ca6509f..620d2d6 100644 --- a/client/app/secret_store.go +++ b/client/app/secret_store.go @@ -1,44 +1,130 @@ package app import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/sha256" "encoding/base64" "encoding/json" "errors" + "io" "os" "path/filepath" "sync" ) // SecretStore manages per-peer shared secrets persisted on disk. -// Each Kitty user has its own directory: ~/.kitty//secrets.json +// Each Kitty user has its own directory: ~/.kitty//secrets.json.enc +// +// Plik na dysku jest CAŁY zaszyfrowany AES-GCM kluczem wyprowadzonym +// z masterKey (np. hasła użytkownika). type SecretStore struct { - mu sync.Mutex - path string - secrets map[string][]byte + mu sync.Mutex + path string + masterKey []byte + secrets map[string][]byte } type diskSecrets struct { Peers map[string]string `json:"peers"` // peer -> base64(secret) } +// deriveKey normalizuje masterKey do 32 bajtów (AES-256) przez SHA-256. +// Jeśli masterKey jest hasłem, to jest to prosty KDF. +// W przyszłości można to podmienić na PBKDF2/Argon2. +func deriveKey(masterKey []byte) []byte { + sum := sha256.Sum256(masterKey) + return sum[:] +} + +// encrypt encryptuje plaintext przy użyciu AES-GCM(masterKey). +// Zwraca: base64( nonce || ciphertext ). +func encrypt(masterKey, plaintext []byte) (string, error) { + if len(masterKey) == 0 { + return "", errors.New("master key is empty") + } + + key := deriveKey(masterKey) + + block, err := aes.NewCipher(key) + if err != nil { + return "", err + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + + nonce := make([]byte, gcm.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return "", err + } + + ciphertext := gcm.Seal(nil, nonce, plaintext, nil) + + out := append(nonce, ciphertext...) + return base64.StdEncoding.EncodeToString(out), nil +} + +// decrypt odszyfrowuje base64( nonce || ciphertext ) przy użyciu AES-GCM(masterKey). +func decrypt(masterKey []byte, enc string) ([]byte, error) { + if len(masterKey) == 0 { + return nil, errors.New("master key is empty") + } + + raw, err := base64.StdEncoding.DecodeString(enc) + if err != nil { + return nil, err + } + + key := deriveKey(masterKey) + + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + + if len(raw) < gcm.NonceSize() { + return nil, errors.New("ciphertext too short") + } + + nonce := raw[:gcm.NonceSize()] + ciphertext := raw[gcm.NonceSize():] + + plaintext, err := gcm.Open(nil, nonce, ciphertext, nil) + if err != nil { + return nil, err + } + return plaintext, nil +} + // NewSecretStore creates a SecretStore bound to the given file path. -// If the file exists, it is loaded; otherwise an empty store is created. -func NewSecretStore(path string) *SecretStore { +// masterKey musi być stały dla danego użytkownika (np. hasło logowania). +// Jeśli plik istnieje, jest odszyfrowywany; w przeciwnym razie tworzony jest pusty store. +func NewSecretStore(path string, masterKey []byte) *SecretStore { s := &SecretStore{ - path: path, - secrets: make(map[string][]byte), + path: path, + masterKey: append([]byte(nil), masterKey...), + secrets: make(map[string][]byte), } _ = s.load() return s } -// PathForUser returns ~/.kitty//secrets.json +// PathForUser returns ~/.kitty//secrets.json.enc func PathForUser(kittyUser string) string { home, err := os.UserHomeDir() if err != nil || home == "" { - return filepath.Join(".", "kitty", kittyUser, "secrets.json") + return filepath.Join(".", "kitty", kittyUser, "secrets.json.enc") } - return filepath.Join(home, ".kitty", kittyUser, "secrets.json") + return filepath.Join(home, ".kitty", kittyUser, "secrets.json.enc") } func (s *SecretStore) Get(peer string) ([]byte, bool) { @@ -84,8 +170,14 @@ func (s *SecretStore) load() error { return err } + // odszyfruj cały plik + plaintext, err := decrypt(s.masterKey, string(data)) + if err != nil { + return err + } + var ds diskSecrets - if err := json.Unmarshal(data, &ds); err != nil { + if err := json.Unmarshal(plaintext, &ds); err != nil { return err } @@ -115,13 +207,18 @@ func (s *SecretStore) saveLocked() error { ds.Peers[peer] = base64.StdEncoding.EncodeToString(secret) } - data, err := json.MarshalIndent(ds, "", " ") + plaintext, err := json.MarshalIndent(ds, "", " ") + if err != nil { + return err + } + + enc, err := encrypt(s.masterKey, plaintext) if err != nil { return err } tmp := s.path + ".tmp" - if err := os.WriteFile(tmp, data, 0o600); err != nil { + if err := os.WriteFile(tmp, []byte(enc), 0o600); err != nil { return err } diff --git a/client/start.go b/client/start.go index 84bc12c..4d701f8 100644 --- a/client/start.go +++ b/client/start.go @@ -57,7 +57,13 @@ func Start() { // ---------------------------------------------------- // AUTH FLOW (CLI-specific) // ---------------------------------------------------- - if err := ui.RunAuthFlowAsync(client); err == ui_cli.ErrQuitRequested { + pass, err := ui.RunAuthFlowAsync(client) + if err == ui_cli.ErrQuitRequested { + client.Close() + return + } + if err != nil { + ui.Println("[Client] AUTH error:", err) client.Close() return } @@ -66,7 +72,9 @@ func Start() { // AUTH SUCCESS → start application // ---------------------------------------------------- application := app.NewApp(client, ui, disconnected) - application.InitSecretStoreForUser(client.User()) + + // ⬇️ NOWOŚĆ: przekazujemy masterKey = hasło użytkownika + application.InitSecretStoreForUser(client.User(), []byte(pass)) client.RegisterAckHandler(ui) diff --git a/client/ui_cli/auth_flow.go b/client/ui_cli/auth_flow.go index d005842..1866db1 100644 --- a/client/ui_cli/auth_flow.go +++ b/client/ui_cli/auth_flow.go @@ -11,7 +11,7 @@ import ( var ErrQuitRequested = errors.New("quit requested") // Async AUTH/REGISTER flow -func (ui *CliUI) RunAuthFlowAsync(client *api.KittyClient) error { +func (ui *CliUI) RunAuthFlowAsync(client *api.KittyClient) (string, error) { for { ui.Println("Wybierz opcję:") ui.Println(" /login") @@ -24,7 +24,7 @@ func (ui *CliUI) RunAuthFlowAsync(client *api.KittyClient) error { case "/quit": client.Close() - return ErrQuitRequested + return "", ErrQuitRequested // ---------------------------------------------------- // REGISTER (async) @@ -68,7 +68,7 @@ func (ui *CliUI) RunAuthFlowAsync(client *api.KittyClient) error { continue } ui.Println("[Client] AUTH OK — zalogowano.") - return nil + return pass, nil case <-time.After(5 * time.Second): ui.Println("[Client] AUTH timeout") From 13ba5b4ba2f9a1d33178727f6f9a3db36156b264 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Brzezi=C5=84ski?= Date: Mon, 25 May 2026 21:15:45 +0000 Subject: [PATCH 32/44] color client cli chat --- client/app/app.go | 20 +++++++++++------- client/ui_cli/menu.go | 47 +++++++++++++++++++++-------------------- client/ui_cli/ui_cli.go | 37 +++++++++++++++++++++----------- 3 files changed, 61 insertions(+), 43 deletions(-) diff --git a/client/app/app.go b/client/app/app.go index a92303c..5f49cd7 100644 --- a/client/app/app.go +++ b/client/app/app.go @@ -9,6 +9,7 @@ type UI interface { ReadSharedSecret() []byte Println(v ...any) Printf(format string, v ...any) + Prompt() } type App struct { @@ -91,25 +92,28 @@ func (a *App) handleChatEvents() { select { case ev := <-a.client.ChatRequestEvents(): a.chatState.SetPendingRequest(ev.From) - a.ui.Printf( - "\n[CHAT] %s chce z Tobą rozmawiać. Użyj /accept %s lub /refuse %s\n> ", - ev.From, ev.From, ev.From, - ) + a.ui.Printf("\n[CHAT] %s chce z Tobą rozmawiać. Użyj /accept %s lub /refuse %s\n", + ev.From, ev.From, ev.From) + a.ui.Prompt() case ev := <-a.client.ChatAcceptEvents(): a.chatState.SetActive(ev.From) - a.ui.Printf("\n[CHAT] %s zaakceptował czat.\n> ", ev.From) + a.ui.Printf("\n[CHAT] %s zaakceptował czat.\n", ev.From) + a.ui.Prompt() case ev := <-a.client.ChatRefuseEvents(): a.chatState.ClearPendingRequest() - a.ui.Printf("\n[CHAT] %s odrzucił czat: %s\n> ", ev.From, ev.Reason) + a.ui.Printf("\n[CHAT] %s odrzucił czat: %s\n", ev.From, ev.Reason) + a.ui.Prompt() case ev := <-a.client.ChatEndEvents(): a.chatState.EndChat() - a.ui.Printf("\n[CHAT] %s zakończył czat: %s\n> ", ev.From, ev.Reason) + a.ui.Printf("\n[CHAT] %s zakończył czat: %s\n", ev.From, ev.Reason) + a.ui.Prompt() case ev := <-a.client.ChatMessageEvents(): - a.ui.Printf("\n[%s] %s\n> ", ev.From, ev.Text) + a.ui.Printf("\n[%s] %s\n", ev.From, ev.Text) + a.ui.Prompt() } } } diff --git a/client/ui_cli/menu.go b/client/ui_cli/menu.go index 6ce6964..c0cb46c 100644 --- a/client/ui_cli/menu.go +++ b/client/ui_cli/menu.go @@ -14,12 +14,14 @@ func (ui *CliUI) RunMainMenu(a *app.App) { for { select { case <-a.Disconnected(): - ui.Println("[Client] Rozłączono z serwerem. Zamykanie aplikacji.") + ui.Println(ColorRed + "[Client] Rozłączono z serwerem. Zamykanie aplikacji." + ColorReset) return default: } ui.printMenu() + ui.Prompt() + line := strings.TrimSpace(ui.ReadLine()) if line == "" { continue @@ -33,7 +35,7 @@ func (ui *CliUI) RunMainMenu(a *app.App) { case line == "/quit": if active, peer := a.ChatState().IsActive(); active && peer != "" { if err := a.EndChat("user quit client"); err != nil { - ui.Println("[CHAT ERROR]", err) + ui.Println(ColorRed+"[CHAT ERROR]"+ColorReset, err) } } _ = a.Client().SendBye() @@ -46,18 +48,18 @@ func (ui *CliUI) RunMainMenu(a *app.App) { user := strings.TrimSpace(strings.TrimPrefix(line, "/status ")) user = strings.ToLower(user) if user == "" { - ui.Println("Usage: /status ") + ui.Println(ColorYellow + "Usage: /status " + ColorReset) continue } _ = a.Client().SendGetStatus(user) // ---------------------------------------------------- - // SECRET (local only) + // SECRET // ---------------------------------------------------- case strings.HasPrefix(line, "/secret "): args := strings.Fields(line) if len(args) < 2 { - ui.Println("Usage: /secret [file:]") + ui.Println(ColorYellow + "Usage: /secret [file:]" + ColorReset) continue } @@ -68,7 +70,7 @@ func (ui *CliUI) RunMainMenu(a *app.App) { path := strings.TrimPrefix(args[2], "file:") data, err := os.ReadFile(path) if err != nil { - ui.Printf("[E2EE] Failed to read secret file: %v\n", err) + ui.Printf(ColorRed+"[E2EE] Failed to read secret file: %v\n"+ColorReset, err) continue } secret = bytes.TrimSpace(data) @@ -77,36 +79,35 @@ func (ui *CliUI) RunMainMenu(a *app.App) { } if err := a.Client().SetSharedSecretForPeer(user, secret); err != nil { - ui.Println("[E2EE] Error deriving keys:", err) + ui.Println(ColorRed+"[E2EE] Error deriving keys:"+ColorReset, err) continue } if err := a.Secrets().Set(user, secret); err != nil { - ui.Println("[E2EE] Error saving secret:", err) + ui.Println(ColorRed+"[E2EE] Error saving secret:"+ColorReset, err) continue } - ui.Printf("[E2EE] Shared secret configured for %s.\n", user) + ui.Printf(ColorGreen+"[E2EE] Shared secret configured for %s.\n"+ColorReset, user) // ---------------------------------------------------- - // CHAT REQUEST (async) + // CHAT REQUEST // ---------------------------------------------------- case strings.HasPrefix(line, "/chat "): user := strings.TrimSpace(strings.TrimPrefix(line, "/chat ")) user = strings.ToLower(user) if user == "" { - ui.Println("Usage: /chat ") + ui.Println(ColorYellow + "Usage: /chat " + ColorReset) continue } if err := a.StartChatRequest(user); err != nil { - // Ładny komunikat dla braku sekretu if errors.Is(err, api.ErrNoSharedSecret) || strings.Contains(err.Error(), "no shared secret") { - ui.Printf("[CHAT] Brak wspólnego sekretu z %s. Użyj /secret %s.\n", user, user) + ui.Printf(ColorBlue+"[CHAT] Brak wspólnego sekretu z %s. Użyj /secret %s.\n"+ColorReset, user, user) } else { - ui.Println("[CHAT ERROR]", err) + ui.Println(ColorRed+"[CHAT ERROR]"+ColorReset, err) } } else { - ui.Printf("[CHAT] Wysłano CHAT_REQUEST do %s.\n", user) + ui.Printf(ColorBlue+"[CHAT] Wysłano CHAT_REQUEST do %s.\n"+ColorReset, user) } // ---------------------------------------------------- @@ -116,12 +117,12 @@ func (ui *CliUI) RunMainMenu(a *app.App) { user := strings.TrimSpace(strings.TrimPrefix(line, "/accept ")) user = strings.ToLower(user) if user == "" { - ui.Println("Usage: /accept ") + ui.Println(ColorYellow + "Usage: /accept " + ColorReset) continue } if err := a.AcceptChat(user); err != nil { - ui.Println("[CHAT ERROR]", err) + ui.Println(ColorRed+"[CHAT ERROR]"+ColorReset, err) } // ---------------------------------------------------- @@ -131,12 +132,12 @@ func (ui *CliUI) RunMainMenu(a *app.App) { user := strings.TrimSpace(strings.TrimPrefix(line, "/refuse ")) user = strings.ToLower(user) if user == "" { - ui.Println("Usage: /refuse ") + ui.Println(ColorYellow + "Usage: /refuse " + ColorReset) continue } if err := a.RefuseChat(user, "user refused"); err != nil { - ui.Println("[CHAT ERROR]", err) + ui.Println(ColorRed+"[CHAT ERROR]"+ColorReset, err) } // ---------------------------------------------------- @@ -149,7 +150,7 @@ func (ui *CliUI) RunMainMenu(a *app.App) { } if err := a.SendTextMessage(text); err != nil { - ui.Println("[CHAT ERROR]", err) + ui.Println(ColorRed+"[CHAT ERROR]"+ColorReset, err) } // ---------------------------------------------------- @@ -157,17 +158,17 @@ func (ui *CliUI) RunMainMenu(a *app.App) { // ---------------------------------------------------- case line == "/end": if err := a.EndChat("user ended chat"); err != nil { - ui.Println("[CHAT ERROR]", err) + ui.Println(ColorRed+"[CHAT ERROR]"+ColorReset, err) } default: - ui.Println("Nieznana komenda.") + ui.Println(ColorYellow + "Nieznana komenda." + ColorReset) } } } func (ui *CliUI) printMenu() { - ui.Println("Dostępne komendy:") + ui.Println(ColorGreen + "Dostępne komendy:" + ColorReset) ui.Println(" /status ") ui.Println(" /secret ") ui.Println(" /chat ") diff --git a/client/ui_cli/ui_cli.go b/client/ui_cli/ui_cli.go index 2c5e08c..0f4ed89 100644 --- a/client/ui_cli/ui_cli.go +++ b/client/ui_cli/ui_cli.go @@ -14,8 +14,22 @@ import ( "github.com/gabbla05/KittyProtocol/client/api" ) +const ( + ColorReset = "\033[0m" + ColorRed = "\033[31m" + ColorGreen = "\033[32m" + ColorBlue = "\033[36m" + ColorPink = "\033[95m" + ColorYellow = "\033[33m" + + Prompt = ColorPink + "(=^._.^=) > " + ColorReset +) + +func (ui *CliUI) Prompt() { + fmt.Print(Prompt) +} + // CliUI implements the UI interface required by the App layer. -// It is intentionally minimal and synchronous. type CliUI struct { client *api.KittyClient reader *bufio.Reader @@ -42,12 +56,12 @@ func (ui *CliUI) Println(v ...any) { fmt.Println(v...) } -// Print prints a line to stdout withou \n at the end. +// Print prints without newline. func (ui *CliUI) Print(v ...any) { fmt.Print(v...) } -// Printf prints a formatted line to stdout. +// Printf prints formatted text. func (ui *CliUI) Printf(format string, v ...any) { fmt.Printf(format, v...) } @@ -56,38 +70,37 @@ func (ui *CliUI) Printf(format string, v ...any) { // ReadCredentials prompts the user for login and password. func (ui *CliUI) ReadCredentials() (string, string) { - fmt.Print("Login: ") + fmt.Print(ColorBlue + "Login: " + ColorReset) user, _ := ui.reader.ReadString('\n') - fmt.Print("Hasło: ") + fmt.Print(ColorBlue + "Hasło: " + ColorReset) pass, _ := ui.reader.ReadString('\n') return strings.TrimSpace(user), strings.TrimSpace(pass) } // ReadSharedSecret prompts the user for the E2EE shared secret. -// It enforces non-empty input. func (ui *CliUI) ReadSharedSecret() []byte { for { - fmt.Print("Wspólny sekret (K_AB) dla tej rozmowy: ") + fmt.Print(ColorYellow + "Wspólny sekret (K_AB): " + ColorReset) secret, _ := ui.reader.ReadString('\n') secret = strings.TrimSpace(secret) if secret != "" { return []byte(secret) } - fmt.Println("[UI] Sekret nie może być pusty.") + fmt.Println(ColorRed + "[UI] Sekret nie może być pusty." + ColorReset) } } // --- ACK event handlers --- -// OnDelivered is called when the AckManager reports successful delivery. func (ui *CliUI) OnDelivered(msgID int64) { - fmt.Printf("\n[Delivered] msg_id=%d\n> ", msgID) + fmt.Printf(ColorGreen+"\n[Delivered] msg_id=%d\n"+ColorReset, msgID) + ui.Prompt() } -// OnTimeout is called when the AckManager reports a delivery timeout. func (ui *CliUI) OnTimeout(msgID int64) { - fmt.Printf("\n[Timeout] msg_id=%d not delivered\n> ", msgID) + fmt.Printf(ColorRed+"\n[Timeout] msg_id=%d not delivered\n"+ColorReset, msgID) + ui.Prompt() } From 2e85f38c9f7b23ade6ea6f9ed82afd6ce4827239 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Brzezi=C5=84ski?= Date: Tue, 26 May 2026 03:22:00 +0000 Subject: [PATCH 33/44] visual effects --- client/app/app.go | 14 +++++++++----- client/start.go | 3 ++- client/ui_cli/auth_flow.go | 12 ++++++++---- client/ui_cli/banner.go | 32 ++++++++++++++++++++++++++++++++ client/ui_cli/menu.go | 26 ++++++++++++++------------ client/ui_cli/ui_cli.go | 20 +++++++++++++------- go.mod | 5 +++-- go.sum | 6 ++++-- markdowns/CURRENT_PROJECT.md | 2 +- 9 files changed, 86 insertions(+), 34 deletions(-) create mode 100644 client/ui_cli/banner.go diff --git a/client/app/app.go b/client/app/app.go index 5f49cd7..e45ef68 100644 --- a/client/app/app.go +++ b/client/app/app.go @@ -45,11 +45,13 @@ func (a *App) attachEventHandlers() { if code == "ERR_15" { if active, _ := a.chatState.IsActive(); active { a.chatState.EndChat() - a.ui.Printf("\n[CHAT] Czat zakończony (peer unavailable: %s).\n> ", desc) + a.ui.Printf("\n[CHAT] Czat zakończony (peer unavailable: %s).\n", desc) + a.ui.Prompt() return } } - a.ui.Printf("\n[ERROR] %s: %s\n> ", code, desc) + a.ui.Printf("\n[ERROR] %s: %s\n", code, desc) + a.ui.Prompt() }) // STATUS_RES frame @@ -58,14 +60,16 @@ func (a *App) attachEventHandlers() { a.ui.Printf("\n[CHAT] Czat zakończony.\n> ") return } - a.ui.Printf("\n[STATUS] %s is %s\n> ", target, status) + a.ui.Printf("\n[STATUS] %s is %s\n", target, status) + a.ui.Prompt() }) // Disconnect event c.OnDisconnected(func(err error) { // Przy rozłączeniu zawsze czyścimy stan czatu lokalnie. a.chatState.EndChat() - a.ui.Printf("\n[DISCONNECTED] %v\n> ", err) + a.ui.Printf("\n[DISCONNECTED] %v\n", err) + a.ui.Prompt() }) } @@ -82,7 +86,7 @@ func (a *App) Client() *api.KittyClient { return a.client } func (a *App) Secrets() *SecretStore { return a.secrets } func (a *App) Disconnected() <-chan struct{} { return a.disconnected } -// Udostępniamy ChatState dla UI (np. do obsługi /quit). +// Udostępniamy ChatState dla UI (np. do obsługi /logout). func (a *App) ChatState() *ChatState { return a.chatState } diff --git a/client/start.go b/client/start.go index 4d701f8..b906f10 100644 --- a/client/start.go +++ b/client/start.go @@ -25,7 +25,8 @@ func Start() { hubAddr = "127.0.0.1:9999" } - ui.Println("[Client] Connecting to Hub:", hubAddr) + ui_cli.PrintBanner() + ui.Println("\n[Client] Connecting to Hub:", hubAddr) // CONNECT if err := client.Connect(hubAddr); err != nil { diff --git a/client/ui_cli/auth_flow.go b/client/ui_cli/auth_flow.go index 1866db1..d9eb0dd 100644 --- a/client/ui_cli/auth_flow.go +++ b/client/ui_cli/auth_flow.go @@ -13,10 +13,14 @@ var ErrQuitRequested = errors.New("quit requested") // Async AUTH/REGISTER flow func (ui *CliUI) RunAuthFlowAsync(client *api.KittyClient) (string, error) { for { - ui.Println("Wybierz opcję:") - ui.Println(" /login") - ui.Println(" /register") - ui.Println(" /quit") + ui.Println(ColorBlue + "\n ==================" + ColorReset) + ui.Println(ColorBlue + " | Wybierz opcję: |") + ui.Println(" | |") + ui.Println(" | " + ColorGreen + "->" + ColorReset + " /login " + ColorBlue + "|") + ui.Println(" | " + ColorGreen + "->" + ColorReset + " /register " + ColorBlue + "|") + ui.Println(" | " + ColorGreen + "->" + ColorReset + " /quit " + ColorBlue + "|") + ui.Println(" ==================\n" + ColorReset) + ui.Prompt() cmd := strings.TrimSpace(ui.ReadLine()) diff --git a/client/ui_cli/banner.go b/client/ui_cli/banner.go new file mode 100644 index 0000000..12977e2 --- /dev/null +++ b/client/ui_cli/banner.go @@ -0,0 +1,32 @@ +package ui_cli + +import "fmt" + +func PrintBanner() { + p1 := "\x1b[38;5;218m" + p2 := "\x1b[38;5;213m" + p3 := "\x1b[38;5;212m" + reset := "\x1b[0m" + + cat := []string{ + p1 + "", + p1 + " \\`*-. ", + p1 + " ) _`-. ", + p1 + " . : `. . ", + p1 + " ^ ^ : _ ' \\ ", + p1 + "░█▄ ▄█░█▀▀░█▀█░█░░░█░ ; *` _. `*-._ ", + p1 + "░█░▀░█░█▀▀░█░█░█▄▀▄█░ `-.-' `-. ", + p2 + "░▀░░░▀░▀▀▀░▀▀▀░▀░░░▀░ ; ` `. ", + p2 + "░█▀▀░█▀▀░█▀▀░█▀█░█▀▀░█▀▀░█▀▄ :. . \\ ", + p3 + "░▀▀█░▀▀█░█▀▀░█░█░█░█░█▀▀░█▀▄ . \\ . : .-' . ", + p3 + "░▀▀▀░▀▀▀░▀▀▀░▀░▀░▀▀▀░▀▀▀░▀░▀ ' `+.; ; ' : ", + p1 + " _ _ __ : ' | ; ;-. " + reset, + p1 + "__ _____ _ _ __(_)___ _ _ / | / \\ ; ' : :`-: _.`* ;" + reset, + p1 + "\\ V / -_) '_(_-< / _ \\ ' \\ | | _ | () | .*' / .*' ; .*`- +' `*' " + reset, + p1 + " \\_/\\___|_| /__/_\\___/_||_| |_| (_) \\__/ `*-* `*-* `*-*' " + reset, + } + + for _, line := range cat { + fmt.Println(line) + } +} diff --git a/client/ui_cli/menu.go b/client/ui_cli/menu.go index c0cb46c..f2d1e6c 100644 --- a/client/ui_cli/menu.go +++ b/client/ui_cli/menu.go @@ -30,11 +30,11 @@ func (ui *CliUI) RunMainMenu(a *app.App) { switch { // ---------------------------------------------------- - // QUIT + // LOGOUT // ---------------------------------------------------- - case line == "/quit": + case line == "/logout": if active, peer := a.ChatState().IsActive(); active && peer != "" { - if err := a.EndChat("user quit client"); err != nil { + if err := a.EndChat("user logout client"); err != nil { ui.Println(ColorRed+"[CHAT ERROR]"+ColorReset, err) } } @@ -168,13 +168,15 @@ func (ui *CliUI) RunMainMenu(a *app.App) { } func (ui *CliUI) printMenu() { - ui.Println(ColorGreen + "Dostępne komendy:" + ColorReset) - ui.Println(" /status ") - ui.Println(" /secret ") - ui.Println(" /chat ") - ui.Println(" /accept ") - ui.Println(" /refuse ") - ui.Println(" /msg ") - ui.Println(" /end") - ui.Println(" /quit") + ui.Println(ColorBlue + "\n ======================" + ColorReset) + ui.Println(ColorBlue + " | " + ColorGreen + "Dostępne komendy: " + ColorBlue + "|") + ui.Println(" | " + ColorGreen + "->" + ColorReset + " /status " + ColorBlue + "|") + ui.Println(" | " + ColorGreen + "->" + ColorReset + " /secret " + ColorBlue + "|") + ui.Println(" | " + ColorGreen + "->" + ColorReset + " /chat " + ColorBlue + "|") + ui.Println(" | " + ColorGreen + "->" + ColorReset + " /accept " + ColorBlue + "|") + ui.Println(" | " + ColorGreen + "->" + ColorReset + " /refuse " + ColorBlue + "|") + ui.Println(" | " + ColorGreen + "->" + ColorReset + " /msg " + ColorBlue + "|") + ui.Println(" | " + ColorGreen + "->" + ColorReset + " /end " + ColorBlue + "|") + ui.Println(" | " + ColorGreen + "->" + ColorReset + " /logout " + ColorBlue + "|") + ui.Println(ColorBlue + " ======================\n" + ColorReset) } diff --git a/client/ui_cli/ui_cli.go b/client/ui_cli/ui_cli.go index 0f4ed89..3bda321 100644 --- a/client/ui_cli/ui_cli.go +++ b/client/ui_cli/ui_cli.go @@ -11,6 +11,8 @@ import ( "os" "strings" + "golang.org/x/term" + "github.com/gabbla05/KittyProtocol/client/api" ) @@ -18,8 +20,8 @@ const ( ColorReset = "\033[0m" ColorRed = "\033[31m" ColorGreen = "\033[32m" - ColorBlue = "\033[36m" - ColorPink = "\033[95m" + ColorBlue = "\033[34m" + ColorPink = "\x1b[38;5;213m" ColorYellow = "\033[33m" Prompt = ColorPink + "(=^._.^=) > " + ColorReset @@ -70,19 +72,23 @@ func (ui *CliUI) Printf(format string, v ...any) { // ReadCredentials prompts the user for login and password. func (ui *CliUI) ReadCredentials() (string, string) { - fmt.Print(ColorBlue + "Login: " + ColorReset) + fmt.Print(ColorBlue + " -> Login: " + ColorReset) user, _ := ui.reader.ReadString('\n') + user = strings.TrimSpace(user) - fmt.Print(ColorBlue + "Hasło: " + ColorReset) - pass, _ := ui.reader.ReadString('\n') + fmt.Print(ColorBlue + " -> Hasło: " + ColorReset) + // czytamy hasło bez echa + bytePass, _ := term.ReadPassword(int(os.Stdin.Fd())) + fmt.Println() // nowa linia po wpisaniu hasła + pass := strings.TrimSpace(string(bytePass)) - return strings.TrimSpace(user), strings.TrimSpace(pass) + return user, pass } // ReadSharedSecret prompts the user for the E2EE shared secret. func (ui *CliUI) ReadSharedSecret() []byte { for { - fmt.Print(ColorYellow + "Wspólny sekret (K_AB): " + ColorReset) + fmt.Print(ColorYellow + " -> Wspólny sekret (K_AB): " + ColorReset) secret, _ := ui.reader.ReadString('\n') secret = strings.TrimSpace(secret) diff --git a/go.mod b/go.mod index df58ec9..ec0ff91 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/gabbla05/KittyProtocol -go 1.24 +go 1.25.0 require ( github.com/DATA-DOG/go-sqlmock v1.5.2 @@ -8,9 +8,10 @@ require ( github.com/lib/pq v1.12.3 github.com/quic-go/quic-go v0.59.0 golang.org/x/crypto v0.41.0 + golang.org/x/term v0.43.0 ) require ( golang.org/x/net v0.43.0 // indirect - golang.org/x/sys v0.35.0 // indirect + golang.org/x/sys v0.44.0 // indirect ) diff --git a/go.sum b/go.sum index e2d5b18..e2067ae 100644 --- a/go.sum +++ b/go.sum @@ -19,7 +19,9 @@ golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= -golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= -golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= +golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= +golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/markdowns/CURRENT_PROJECT.md b/markdowns/CURRENT_PROJECT.md index 4ace488..a33371b 100644 --- a/markdowns/CURRENT_PROJECT.md +++ b/markdowns/CURRENT_PROJECT.md @@ -79,7 +79,7 @@ ### Komendy: - `/status ` – wysyła GET_STATUS. - `/chat ` – wchodzi w tryb czatu. - - `/quit` – wysyła BYE i kończy. + - `/logout` – wylogowuje się, kończy sesję i zamyka program klienta. --- From e5006ddb361757a01bc05dd444489e006aa6bf03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Brzezi=C5=84ski?= Date: Tue, 26 May 2026 12:52:09 +0000 Subject: [PATCH 34/44] added Kittyprotocol annotation to the banner. Changed error desc in hub for messaging to offline user --- TODO | 3 ++- client/ui_cli/banner.go | 25 ++++++++++++------------- hub/router.go | 2 +- 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/TODO b/TODO index 18363df..369ef24 100644 --- a/TODO +++ b/TODO @@ -58,4 +58,5 @@ Jest jeszcze kilka rzeczy do zrobienia w tym projekcie. Daję listę, którą fa - kolorowa konsola klienta w wersji cli - testy jednostkowe z bardzo dobrym pokryciem - gwiazdki w cli jak się hasło wpisuje albo nie pokazywać jak się wpisuje w ogóle bo obecnie się wyświetla w konsoli normalnie -- czy komendy typu /command nie powinny być ogólnie dostępne, także w gui na przyszłość, żeby gui wołało tylko jakąś komendę np. /status albo /chat itp pytanie do zasaatnowienia i odpowiedzi. Wiem że sporo tego, ale możesz to podzielić namniejsze etapy którymi będziemy się zajmować teraz po kolei \ No newline at end of file +- czy komendy typu /command nie powinny być ogólnie dostępne, także w gui na przyszłość, żeby gui wołało tylko jakąś komendę np. /status albo /chat itp pytanie do zasaatnowienia i odpowiedzi. Wiem że sporo tego, ale możesz to podzielić namniejsze etapy którymi będziemy się zajmować teraz po kolei +- authtimer powinien się zaczynać po wybraniu opcji logowania/rejestracji a nie po wystartowaniu aplikacji. Powiniensię restartować przy każdorazowym wybraniu opcji /login bo w ogóle ten timer fl arejestracji chyba nie bardzo ma sens \ No newline at end of file diff --git a/client/ui_cli/banner.go b/client/ui_cli/banner.go index 12977e2..355fb20 100644 --- a/client/ui_cli/banner.go +++ b/client/ui_cli/banner.go @@ -9,21 +9,20 @@ func PrintBanner() { reset := "\x1b[0m" cat := []string{ - p1 + "", p1 + " \\`*-. ", p1 + " ) _`-. ", - p1 + " . : `. . ", - p1 + " ^ ^ : _ ' \\ ", - p1 + "░█▄ ▄█░█▀▀░█▀█░█░░░█░ ; *` _. `*-._ ", - p1 + "░█░▀░█░█▀▀░█░█░█▄▀▄█░ `-.-' `-. ", - p2 + "░▀░░░▀░▀▀▀░▀▀▀░▀░░░▀░ ; ` `. ", - p2 + "░█▀▀░█▀▀░█▀▀░█▀█░█▀▀░█▀▀░█▀▄ :. . \\ ", - p3 + "░▀▀█░▀▀█░█▀▀░█░█░█░█░█▀▀░█▀▄ . \\ . : .-' . ", - p3 + "░▀▀▀░▀▀▀░▀▀▀░▀░▀░▀▀▀░▀▀▀░▀░▀ ' `+.; ; ' : ", - p1 + " _ _ __ : ' | ; ;-. " + reset, - p1 + "__ _____ _ _ __(_)___ _ _ / | / \\ ; ' : :`-: _.`* ;" + reset, - p1 + "\\ V / -_) '_(_-< / _ \\ ' \\ | | _ | () | .*' / .*' ; .*`- +' `*' " + reset, - p1 + " \\_/\\___|_| /__/_\\___/_||_| |_| (_) \\__/ `*-* `*-* `*-*' " + reset, + p1 + " ^ ^ . : `. . ", + p1 + "░█▄ ▄█░█▀▀░█▀█░█░░░█░ : _ ' \\ ", + p1 + "░█░▀░█░█▀▀░█░█░█▄▀▄█░ ; *` _. `*-._ ", + p2 + "░▀░░░▀░▀▀▀░▀▀▀░▀░░░▀░ `-.-' `-. ", + p2 + "░█▀▀░█▀▀░█▀▀░█▀█░█▀▀░█▀▀░█▀▄ ; ` `. ", + p3 + "░▀▀█░▀▀█░█▀▀░█░█░█░█░█▀▀░█▀▄ :. . \\ ", + p3 + "░▀▀▀░▀▀▀░▀▀▀░▀░▀░▀▀▀░▀▀▀░▀░▀ . \\ . : .-' . ", + p1 + "Powered by KITTYPROTOCOL ' `+.; ; ' : ", + p1 + " _ _ __ : ' | ; ;-.", + p1 + "__ _____ _ _ __(_)___ _ _ / | / \\ ; ' : :`-: _.`* ;", + p1 + "\\ V / -_) '_(_-< / _ \\ ' \\ | | _ | () | .*' / .*' ; .*`- +' `*'", + p1 + " \\_/\\___|_| /__/_\\___/_||_| |_| (_) \\__/ `*-* `*-* `*-*'" + reset, } for _, line := range cat { diff --git a/hub/router.go b/hub/router.go index a36398f..dbcd153 100644 --- a/hub/router.go +++ b/hub/router.go @@ -19,7 +19,7 @@ func routeData(frame protocol.DataFrame, sender *protection.Session, senderStrea // router.go — poprawiony fragment if !ok { // ERR_15 — Unknown Target - sendError(senderStream, protocol.ErrUnknownTarget, "Unknown target user") + sendError(senderStream, protocol.ErrUnknownTarget, "Unknown target user or user is offline") return false } From 926442bd77b95bf52d7f9e54eec04afda840d67c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Brzezi=C5=84ski?= Date: Tue, 26 May 2026 13:13:19 +0000 Subject: [PATCH 35/44] client ack refactor --- client/api/ack.go | 47 ++++++++++++++++++++++++++++++++++------------- 1 file changed, 34 insertions(+), 13 deletions(-) diff --git a/client/api/ack.go b/client/api/ack.go index 01b7ccf..75b4468 100644 --- a/client/api/ack.go +++ b/client/api/ack.go @@ -6,14 +6,15 @@ import ( ) // AckEventHandler defines callbacks for delivery acknowledgment events. -// UI layers (CLI, GUI) or application components may subscribe to receive -// notifications about message delivery or timeout. +// UI layers (CLI, GUI) or higher-level application components may subscribe +// to receive notifications about message delivery or timeout. // // CONTRACT: // - OnDelivered(msgID) is called exactly once when MEOW_OK arrives. // - OnTimeout(msgID) is called exactly once when the timeout expires. // - A message will NEVER trigger both events. -// - Handlers are invoked synchronously in the caller goroutine. +// - Handlers are invoked synchronously in the goroutine that processes +// the event (no extra goroutines are spawned per handler). type AckEventHandler interface { OnDelivered(msgID int64) OnTimeout(msgID int64) @@ -41,8 +42,11 @@ func NewAckManager() *AckManager { } } -// RegisterHandler registers a UI or client component to receive ACK events. -// Handlers are invoked synchronously in the caller goroutine. +// RegisterHandler registers a component to receive ACK events. +// Handlers are invoked synchronously in the event-processing goroutine. +// +// It is safe to call RegisterHandler before or after messages are added, +// but handlers registered after AddPending() may miss earlier events. func (a *AckManager) RegisterHandler(h AckEventHandler) { a.mu.Lock() defer a.mu.Unlock() @@ -54,12 +58,13 @@ func (a *AckManager) RegisterHandler(h AckEventHandler) { // BEHAVIOR: // - If MEOW_OK arrives → NotifyDelivered() closes the channel. // - If timeout expires → OnTimeout() is invoked. -// - Only one of these events will fire. +// - Only one of these events will fire for a given msgID. func (a *AckManager) AddPending(msgID int64) { ch := make(chan struct{}) a.mu.Lock() a.pending[msgID] = ch + timeout := a.timeout a.mu.Unlock() go func() { @@ -68,13 +73,18 @@ func (a *AckManager) AddPending(msgID int64) { // Delivered — nothing more to do. return - case <-time.After(a.timeout): + case <-time.After(timeout): a.mu.Lock() + // Check if still pending (may have been delivered just before timeout). if _, ok := a.pending[msgID]; ok { delete(a.pending, msgID) - for _, h := range a.handlers { + handlers := append([]AckEventHandler(nil), a.handlers...) + a.mu.Unlock() + + for _, h := range handlers { h.OnTimeout(msgID) } + return } a.mu.Unlock() } @@ -83,24 +93,35 @@ func (a *AckManager) AddPending(msgID int64) { // NotifyDelivered is called when MEOW_OK arrives. // It removes the pending entry and notifies all handlers. +// +// If the message is no longer pending (e.g. timeout already fired), +// this call is a no-op. func (a *AckManager) NotifyDelivered(msgID int64) { a.mu.Lock() ch, ok := a.pending[msgID] if ok { delete(a.pending, msgID) } + handlers := append([]AckEventHandler(nil), a.handlers...) a.mu.Unlock() - if ok { - close(ch) - for _, h := range a.handlers { - h.OnDelivered(msgID) - } + if !ok { + return + } + + // Closing the channel unblocks the timeout goroutine (if still waiting). + close(ch) + + for _, h := range handlers { + h.OnDelivered(msgID) } } // RegisterAckHandler is the public API exposed by KittyClient. // It simply forwards the handler to the underlying AckManager. +// +// This keeps the AckManager internal while exposing a stable interface +// for UI layers (CLI, Wails GUI, tests, etc.). func (c *KittyClient) RegisterAckHandler(h AckEventHandler) { c.mu.Lock() defer c.mu.Unlock() From f263c6b08d595df2e3e09b8a8e665cfec1faed88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Brzezi=C5=84ski?= Date: Tue, 26 May 2026 13:15:57 +0000 Subject: [PATCH 36/44] connection fileset refactor --- client/api/connect.go | 29 +++++++++-------------------- client/api/tls.go | 40 ++++++++++++++++++++-------------------- client/api/tofu.go | 5 +++++ 3 files changed, 34 insertions(+), 40 deletions(-) diff --git a/client/api/connect.go b/client/api/connect.go index 92f7579..04fa75e 100644 --- a/client/api/connect.go +++ b/client/api/connect.go @@ -12,7 +12,8 @@ import ( // // BEHAVIOR: // - If the client was previously connected, Connect() implicitly closes the old connection. -// - Performs TLS 1.3 setup, QUIC dial, TOFU certificate verification, and stream opening. +// - Performs TLS 1.3 setup, QUIC dial, and stream opening. +// - Certificate validation and TOFU pinning are handled inside buildTLSConfig() via VerifyConnection. // - Sends the HELLO frame immediately after the stream is opened (non‑blocking). // - Does NOT start receiver or ping loops — the caller must start them manually. // @@ -24,7 +25,7 @@ func (c *KittyClient) Connect(hubAddr string) error { return errors.New("hub address is empty") } - // Ensure clean state if reconnecting + // Ensure clean state if reconnecting. c.Close() // Recreate control channels for background loops. @@ -35,46 +36,32 @@ func (c *KittyClient) Connect(hubAddr string) error { tlsConf := buildTLSConfig() - // QUIC Dial (real transport) + // QUIC dial with hardened TLS configuration. rawConn, err := quic.DialAddr(context.Background(), hubAddr, tlsConf, nil) if err != nil { return err } conn := newQuicConnAdapter(rawConn) - // TOFU certificate verification - state := conn.ConnectionState() - if len(state.TLS.PeerCertificates) == 0 { - _ = conn.CloseWithError(0, "no server certificate") - return errors.New("no server certificate") - } - - serverCert := state.TLS.PeerCertificates[0] - if err := verifyOrStoreServerCert(serverCert); err != nil { - _ = conn.CloseWithError(0, "certificate verification failed") - return err - } - - // Open QUIC stream via adapter + // Open a bidirectional QUIC stream. stream, err := conn.OpenStreamSync(context.Background()) if err != nil { _ = conn.CloseWithError(0, "stream open failed") return err } - // Save connection + stream + // Save connection and stream, reset session-level state. c.mu.Lock() c.conn = conn c.stream = stream - // Reset session‑level state c.target = "" c.lastFrame = nil c.replay = protection.NewReplayDetector() c.ackMgr = NewAckManager() c.mu.Unlock() - // Send HELLO immediately (async handshake) + // Send HELLO immediately (async handshake). if err := c.SendHello(); err != nil { return err } @@ -84,6 +71,8 @@ func (c *KittyClient) Connect(hubAddr string) error { } // Disconnect closes the QUIC connection and stream. +// +// This is a convenience wrapper around Close() to keep the public API explicit. func (c *KittyClient) Disconnect() { c.Close() } diff --git a/client/api/tls.go b/client/api/tls.go index d63fe72..ffd7d6c 100644 --- a/client/api/tls.go +++ b/client/api/tls.go @@ -10,29 +10,28 @@ import ( "time" ) -// buildTLSConfig returns a hardened TLS 1.3 configuration. +// buildTLSConfig returns a hardened TLS 1.3 configuration for KittyClient. // // SECURITY MODEL -// - TLS 1.3 only -// - QUIC-specific ALPN enforced -// - TOFU certificate validation -// - Session resumption disabled -// - Strong curves only -// - Strong TLS 1.3 cipher suites only +// - TLS 1.3 only. +// - QUIC-specific ALPN enforced. +// - TOFU (Trust On First Use) certificate validation. +// - Session resumption disabled. +// - Strong curves only. +// - Strong TLS 1.3 cipher suites only. // // CERTIFICATE VALIDATION -// Default PKI verification is intentionally disabled because -// the application uses TOFU (Trust On First Use). -// -// The first successfully seen certificate is stored locally. -// Future connections must present the same certificate. +// - Standard PKI verification (CA/hostname) is intentionally disabled. +// - The application uses TOFU instead: the first successfully seen certificate +// is stored locally and pinned; future connections must present the same +// certificate or the connection is rejected. func buildTLSConfig() *tls.Config { return &tls.Config{ - // TLS 1.3 only + // TLS 1.3 only. MinVersion: tls.VersionTLS13, MaxVersion: tls.VersionTLS13, - // QUIC ALPN + // QUIC ALPN. NextProtos: []string{ "kitty-quic-v1", }, @@ -42,16 +41,16 @@ func buildTLSConfig() *tls.Config { SessionTicketsDisabled: true, // Explicitly disable renegotiation. - // (TLS 1.3 already removed it.) + // (TLS 1.3 already removed it, but we keep this explicit.) Renegotiation: tls.RenegotiateNever, - // Strong ECDHE curves only + // Strong ECDHE curves only. CurvePreferences: []tls.CurveID{ tls.X25519, tls.CurveP256, }, - // Explicit TLS 1.3 cipher selection + // Explicit TLS 1.3 cipher selection. CipherSuites: []uint16{ tls.TLS_AES_128_GCM_SHA256, tls.TLS_AES_256_GCM_SHA384, @@ -59,7 +58,7 @@ func buildTLSConfig() *tls.Config { }, // Disable standard PKI verification. - // We validate manually via TOFU. + // We validate manually via TOFU in VerifyConnection. InsecureSkipVerify: true, // Modern verification hook. @@ -84,8 +83,9 @@ func buildTLSConfig() *tls.Config { // validateServerCertificate performs minimal cryptographic sanity checks. // // NOTE: -// This does NOT perform CA/hostname validation. -// TOFU replaces the traditional PKI trust model. +// - This does NOT perform CA/hostname validation. +// - TOFU replaces the traditional PKI trust model. +// - The goal here is to reject obviously invalid or expired certificates. func validateServerCertificate(cert *x509.Certificate) error { if err := cert.CheckSignatureFrom(cert); err != nil { return errors.New("invalid self-signed certificate") diff --git a/client/api/tofu.go b/client/api/tofu.go index 6b820e6..de7155f 100644 --- a/client/api/tofu.go +++ b/client/api/tofu.go @@ -29,6 +29,11 @@ const ( // - If no pinned certificate exists → store the presented certificate. // - If a pinned certificate exists → compare DER bytes. // - Any mismatch results in an error (possible MITM). +// +// SECURITY NOTES: +// - The very first connection is implicitly trusted (TOFU). +// - Subsequent connections are protected against MITM as long as the +// pinned certificate file remains uncompromised. func verifyOrStoreServerCert(cert *x509.Certificate) error { if cert == nil { return errors.New("no server certificate presented") From d6ff7ce4095a9ef4727492395abf47ff106d7bbe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Brzezi=C5=84ski?= Date: Tue, 26 May 2026 13:20:53 +0000 Subject: [PATCH 37/44] api auth refactor --- client/api/auth.go | 87 +++++++++++++++++++++++++-------------------- client/api/close.go | 32 +++++++++-------- client/api/hello.go | 15 ++++---- 3 files changed, 75 insertions(+), 59 deletions(-) diff --git a/client/api/auth.go b/client/api/auth.go index f193fa1..cec7051 100644 --- a/client/api/auth.go +++ b/client/api/auth.go @@ -8,48 +8,58 @@ import ( ) // ----------------------------------------------------------------------------- -// AUTH (asynchronous) +// AUTH / REGISTER (asynchronous) // ----------------------------------------------------------------------------- +// SendAuth sends an AUTH frame with the provided credentials. +// +// BEHAVIOR: +// - Requires an established QUIC connection (ensureConnected). +// - Updates internal client state to StateAuthenticating. +// - Stores the username on the client instance. +// - The result is delivered asynchronously via AuthResult(). func (c *KittyClient) SendAuth(user, pass string) error { - stream, err := c.ensureConnected() - if err != nil { - return err - } - - frame := protocol.AuthFrame{ - BaseFrame: protocol.BaseFrame{ - Type: protocol.FrameTypeAuth, - MsgID: time.Now().UnixMilli(), - }, - User: user, - Pass: pass, - } - - b, err := json.Marshal(frame) - if err != nil { - return err - } - - // Save username - c.mu.Lock() - c.user = user - c.state = StateAuthenticating - c.mu.Unlock() - - _, err = stream.Write(b) - return err + return c.sendAuthLikeFrame(protocol.FrameTypeAuth, user, pass, StateAuthenticating, func() { + c.user = user + }) } +// AuthResult returns a read-only channel that delivers the result +// of the last AUTH operation. func (c *KittyClient) AuthResult() <-chan OpResult { return c.authCh } -// ----------------------------------------------------------------------------- -// REGISTER (asynchronous) -// ----------------------------------------------------------------------------- - +// SendRegister sends a REGISTER frame with the provided credentials. +// +// BEHAVIOR: +// - Requires an established QUIC connection (ensureConnected). +// - Updates internal client state to StateRegistering. +// - The result is delivered asynchronously via RegisterResult(). func (c *KittyClient) SendRegister(user, pass string) error { + return c.sendAuthLikeFrame(protocol.FrameTypeRegister, user, pass, StateRegistering, nil) +} + +// RegisterResult returns a read-only channel that delivers the result +// of the last REGISTER operation. +func (c *KittyClient) RegisterResult() <-chan OpResult { + return c.registerCh +} + +// sendAuthLikeFrame is a shared helper for AUTH and REGISTER flows. +// +// PARAMETERS: +// - frameType: protocol.FrameTypeAuth or protocol.FrameTypeRegister. +// - user, pass: credentials to send. +// - nextState: client state to set before sending. +// - beforeUnlock: optional hook executed under lock before releasing it +// (e.g. to store username). +func (c *KittyClient) sendAuthLikeFrame( + frameType string, + user, pass string, + nextState ClientState, + beforeUnlock func(), +) error { stream, err := c.ensureConnected() if err != nil { return err @@ -57,7 +67,7 @@ func (c *KittyClient) SendRegister(user, pass string) error { frame := protocol.AuthFrame{ BaseFrame: protocol.BaseFrame{ - Type: protocol.FrameTypeRegister, + Type: frameType, MsgID: time.Now().UnixMilli(), }, User: user, @@ -70,21 +80,22 @@ func (c *KittyClient) SendRegister(user, pass string) error { } c.mu.Lock() - c.state = StateRegistering + if beforeUnlock != nil { + beforeUnlock() + } + c.state = nextState c.mu.Unlock() _, err = stream.Write(b) return err } -func (c *KittyClient) RegisterResult() <-chan OpResult { - return c.registerCh -} - // ----------------------------------------------------------------------------- // HELLO (asynchronous) // ----------------------------------------------------------------------------- +// HelloResult returns a read-only channel that delivers the result +// of the initial HELLO handshake. func (c *KittyClient) HelloResult() <-chan OpResult { return c.helloCh } diff --git a/client/api/close.go b/client/api/close.go index 6ee34a0..c93eb84 100644 --- a/client/api/close.go +++ b/client/api/close.go @@ -8,20 +8,22 @@ import ( // Close gracefully shuts down the client and securely clears all sensitive data. // -// Behavior: -// - stops ping and receiver loops, -// - closes the QUIC stream and connection, -// - cancels the internal context, -// - zeroizes all per‑peer E2EE keys, -// - resets replay detector and ACK manager, -// - clears session metadata. +// BEHAVIOR: +// - Stops ping and receiver loops. +// - Cancels the internal context. +// - Forcefully interrupts any blocking Read/Write on the stream. +// - Closes the QUIC stream and connection. +// - Zeroizes all per‑peer E2EE keys in memory. +// - Resets replay detector and ACK manager. +// - Clears session metadata and transitions to StateDisconnected. // -// This method is idempotent. +// THREAD SAFETY: +// - Close is safe to call multiple times (idempotent). func (c *KittyClient) Close() { c.mu.Lock() defer c.mu.Unlock() - // Stop background loops + // Stop background loops (idempotent close of channels). select { case <-c.stopPing: default: @@ -33,31 +35,31 @@ func (c *KittyClient) Close() { close(c.stopRecv) } - // Cancel context + // Cancel context (if any). if c.cancel != nil { c.cancel() } - // Forcefully interrupt any blocking Read/Write + // Forcefully interrupt any blocking Read/Write on the stream. if c.stream != nil { // QUIC-specific error code 0 is fine here; semantics are "no specific error". c.stream.CancelRead(quic.StreamErrorCode(0)) c.stream.CancelWrite(quic.StreamErrorCode(0)) } - // Close stream + // Close stream. if c.stream != nil { _ = c.stream.Close() c.stream = nil } - // Close connection + // Close connection. if c.conn != nil { _ = c.conn.CloseWithError(0, "client closed") c.conn = nil } - // Zeroize all per‑peer E2EE keys + // Zeroize all per‑peer E2EE keys. for peer, pk := range c.peerKeys { if pk.kEnc != nil { cryptoee.Zeroize(pk.kEnc) @@ -69,7 +71,7 @@ func (c *KittyClient) Close() { } c.peerKeys = nil - // Reset session state + // Reset session state. c.user = "" c.target = "" c.lastFrame = nil diff --git a/client/api/hello.go b/client/api/hello.go index 1b74f0c..3169692 100644 --- a/client/api/hello.go +++ b/client/api/hello.go @@ -8,13 +8,16 @@ import ( "github.com/gabbla05/KittyProtocol/protocol" ) +// SendHello sends the initial HELLO frame on the control stream. +// +// BEHAVIOR: +// - Requires an established QUIC connection (ensureConnected). +// - Does not block on any response; the result is delivered via HelloResult(). +// - Intended to be called immediately after Connect(). func (c *KittyClient) SendHello() error { - c.mu.Lock() - stream := c.stream - c.mu.Unlock() - - if stream == nil { - return fmt.Errorf("stream is nil") + stream, err := c.ensureConnected() + if err != nil { + return fmt.Errorf("cannot send HELLO: %w", err) } frame := protocol.HelloFrame{ From 5f4f4a3ab9be0c2920f008dd90a5ec7d3d143dd5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Brzezi=C5=84ski?= Date: Tue, 26 May 2026 13:58:07 +0000 Subject: [PATCH 38/44] send/receive flow refactor --- TODO | 3 +- client/api/connect.go | 3 +- client/api/receive.go | 543 ++++++++++++++++++----------------- client/api/receive_chat.go | 59 ++++ client/api/receive_data.go | 53 ++++ client/api/receive_error.go | 34 +++ client/api/receive_loop.go | 97 +++++++ client/api/receive_meow.go | 33 +++ client/api/receive_status.go | 27 ++ client/api/receive_types.go | 24 ++ client/api/send.go | 127 +------- client/api/send_bye.go | 29 ++ client/api/send_data.go | 77 +++++ client/api/send_status.go | 31 ++ 14 files changed, 754 insertions(+), 386 deletions(-) create mode 100644 client/api/receive_chat.go create mode 100644 client/api/receive_data.go create mode 100644 client/api/receive_error.go create mode 100644 client/api/receive_loop.go create mode 100644 client/api/receive_meow.go create mode 100644 client/api/receive_status.go create mode 100644 client/api/receive_types.go create mode 100644 client/api/send_bye.go create mode 100644 client/api/send_data.go create mode 100644 client/api/send_status.go diff --git a/TODO b/TODO index 369ef24..75ed30e 100644 --- a/TODO +++ b/TODO @@ -59,4 +59,5 @@ Jest jeszcze kilka rzeczy do zrobienia w tym projekcie. Daję listę, którą fa - testy jednostkowe z bardzo dobrym pokryciem - gwiazdki w cli jak się hasło wpisuje albo nie pokazywać jak się wpisuje w ogóle bo obecnie się wyświetla w konsoli normalnie - czy komendy typu /command nie powinny być ogólnie dostępne, także w gui na przyszłość, żeby gui wołało tylko jakąś komendę np. /status albo /chat itp pytanie do zasaatnowienia i odpowiedzi. Wiem że sporo tego, ale możesz to podzielić namniejsze etapy którymi będziemy się zajmować teraz po kolei -- authtimer powinien się zaczynać po wybraniu opcji logowania/rejestracji a nie po wystartowaniu aplikacji. Powiniensię restartować przy każdorazowym wybraniu opcji /login bo w ogóle ten timer fl arejestracji chyba nie bardzo ma sens \ No newline at end of file +- authtimer powinien się zaczynać po wybraniu opcji logowania/rejestracji a nie po wystartowaniu aplikacji. Powiniensię restartować przy każdorazowym wybraniu opcji /login bo w ogóle ten timer fl arejestracji chyba nie bardzo ma sens +- komenda /menu zamiast każdorazowo wyświetlać i psuć flow \ No newline at end of file diff --git a/client/api/connect.go b/client/api/connect.go index 04fa75e..23457a4 100644 --- a/client/api/connect.go +++ b/client/api/connect.go @@ -61,12 +61,13 @@ func (c *KittyClient) Connect(hubAddr string) error { c.ackMgr = NewAckManager() c.mu.Unlock() + c.setState(StateHandshaking) + // Send HELLO immediately (async handshake). if err := c.SendHello(); err != nil { return err } - c.setState(StateHandshaking) return nil } diff --git a/client/api/receive.go b/client/api/receive.go index adeeca8..2421e0a 100644 --- a/client/api/receive.go +++ b/client/api/receive.go @@ -1,265 +1,282 @@ +// // receive.go package api -import ( - "encoding/json" - - "github.com/gabbla05/KittyProtocol/internal/cryptoee" - "github.com/gabbla05/KittyProtocol/protocol" -) - -type chatFrameProbe struct { - Type string `json:"type"` - From string `json:"from"` - To string `json:"to"` - Payload json.RawMessage `json:"payload,omitempty"` -} - -type chatRefusePayload struct { - Reason string `json:"reason,omitempty"` -} - -type chatEndPayload struct { - Reason string `json:"reason,omitempty"` -} - -type textMessagePayload struct { - Text string `json:"text"` -} - -func (c *KittyClient) StartReceiverLoop(disconnected chan struct{}) { - c.mu.Lock() - stream := c.stream - replay := c.replay - ackMgr := c.ackMgr - stopRecv := c.stopRecv - - helloCh := c.helloCh - authCh := c.authCh - registerCh := c.registerCh - - chatReqCh := c.chatReqCh - chatAcceptCh := c.chatAcceptCh - chatRefuseCh := c.chatRefuseCh - chatEndCh := c.chatEndCh - chatMsgCh := c.chatMsgCh - c.mu.Unlock() - - if stream == nil { - return - } - - go func() { - buf := make([]byte, defaultRecvBufferSize) - - for { - select { - case <-stopRecv: - return - default: - } - - n, err := stream.Read(buf) - if err != nil { - // dynamiczny disconnectHandler - c.mu.Lock() - dh := c.disconnectHandler - c.mu.Unlock() - - if dh != nil { - dh(err) - } else { - log(LogError, "disconnected: %v", err) - } - - select { - case <-disconnected: - default: - close(disconnected) - } - return - } - - frameBytes := buf[:n] - - typeName, msgID, err := protocol.GetFrameType(frameBytes) - if err != nil { - c.mu.Lock() - eh := c.errHandler - c.mu.Unlock() - - if eh != nil { - eh("PARSE_ERROR", err.Error()) - } else { - log(LogError, "parse error: %v", err) - } - continue - } - - switch typeName { - - // ============================================================ - // MEOW_OK — odpowiedź na HELLO / AUTH / REGISTER - // ============================================================ - case protocol.FrameTypeMeowOK: - c.mu.Lock() - currentState := c.state - c.mu.Unlock() - - switch currentState { - - case StateHandshaking: - helloCh <- OpResult{OK: true} - c.setState(StateAuthenticating) - - case StateAuthenticating: - authCh <- OpResult{OK: true} - c.setState(StateSelectingTarget) - - case StateRegistering: - registerCh <- OpResult{OK: true} - c.setState(StateAuthenticating) - - default: - if ackMgr != nil { - ackMgr.NotifyDelivered(msgID) - } - } - - // ============================================================ - // ERROR — odpowiedź na HELLO / AUTH / REGISTER - // ============================================================ - case protocol.FrameTypeError: - var ef protocol.ErrorFrame - if json.Unmarshal(frameBytes, &ef) != nil { - log(LogError, "failed to parse ERROR frame") - continue - } - - c.mu.Lock() - currentState := c.state - eh := c.errHandler - c.mu.Unlock() - - switch currentState { - - case StateHandshaking: - helloCh <- OpResult{OK: false, Code: ef.Code, Desc: ef.Desc} - - case StateAuthenticating: - authCh <- OpResult{OK: false, Code: ef.Code, Desc: ef.Desc} - - case StateRegistering: - registerCh <- OpResult{OK: false, Code: ef.Code, Desc: ef.Desc} - - default: - if eh != nil { - eh(ef.Code, ef.Desc) - } else { - log(LogError, "server error %s: %s", ef.Code, ef.Desc) - } - } - - // ============================================================ - // DATA — E2EE chat payload (lub inne dane aplikacyjne) - // ============================================================ - case protocol.FrameTypeData: - var df protocol.DataFrame - if json.Unmarshal(frameBytes, &df) != nil { - log(LogError, "failed to parse DATA frame") - continue - } - - if replay != nil && replay.MarkAndCheck(df.MsgID) { - continue - } - - c.mu.Lock() - kEnc, kMac, ok := c.getKeysForPeer(df.Sender) - appHandler := c.appHandler - c.mu.Unlock() - - if !ok { - log(LogWarn, "no shared secret for %s", df.Sender) - continue - } - - plaintext, err := cryptoee.DecryptAndVerifyWithKeys( - df.MsgID, df.Target, df.Payload, df.MAC, kEnc, kMac, - ) - if err != nil { - log(LogError, "E2EE error: %v", err) - continue - } - - // Spróbuj zinterpretować jako ChatFrame - var probe chatFrameProbe - if err := json.Unmarshal([]byte(plaintext), &probe); err == nil && probe.Type != "" { - switch probe.Type { - - case "CHAT_REQUEST": - if chatReqCh != nil { - chatReqCh <- ChatRequestEvent{From: probe.From} - } - - case "CHAT_ACCEPT": - if chatAcceptCh != nil { - chatAcceptCh <- ChatAcceptEvent{From: probe.From} - } - - case "CHAT_REFUSE": - var p chatRefusePayload - _ = json.Unmarshal(probe.Payload, &p) - if chatRefuseCh != nil { - chatRefuseCh <- ChatRefuseEvent{From: probe.From, Reason: p.Reason} - } - - case "CHAT_END": - var p chatEndPayload - _ = json.Unmarshal(probe.Payload, &p) - if chatEndCh != nil { - chatEndCh <- ChatEndEvent{From: probe.From, Reason: p.Reason} - } - - case "TEXT_MESSAGE": - var p textMessagePayload - _ = json.Unmarshal(probe.Payload, &p) - if chatMsgCh != nil { - chatMsgCh <- ChatMessageEvent{From: probe.From, Text: p.Text} - } - - default: - log(LogWarn, "unknown chat frame type: %s", probe.Type) - } - - // Chat‑frame obsłużony - continue - } - - // Fallback: inne dane aplikacyjne - if appHandler != nil { - appHandler(df.Sender, []byte(plaintext)) - } - - // ============================================================ - // STATUS_RES - // ============================================================ - case protocol.FrameTypeStatusRes: - var sf protocol.StatusResFrame - if json.Unmarshal(frameBytes, &sf) != nil { - log(LogError, "failed to parse STATUS_RES") - continue - } - - c.mu.Lock() - sh := c.statusHandler - c.mu.Unlock() - - if sh != nil { - sh(sf.Target, sf.Status) - } else { - log(LogInfo, "status: %s is %s", sf.Target, sf.Status) - } - } - } - }() -} +// import ( +// "encoding/json" + +// "github.com/gabbla05/KittyProtocol/internal/cryptoee" +// "github.com/gabbla05/KittyProtocol/protocol" +// ) + +// // chatFrameProbe is a lightweight probe structure used to detect +// // whether decrypted DATA payload is a chat control/message frame. +// type chatFrameProbe struct { +// Type string `json:"type"` +// From string `json:"from"` +// To string `json:"to"` +// Payload json.RawMessage `json:"payload,omitempty"` +// } + +// type chatRefusePayload struct { +// Reason string `json:"reason,omitempty"` +// } + +// type chatEndPayload struct { +// Reason string `json:"reason,omitempty"` +// } + +// type textMessagePayload struct { +// Text string `json:"text"` +// } + +// // StartReceiverLoop starts a background goroutine that continuously reads +// // frames from the QUIC stream and dispatches them to appropriate handlers. +// // +// // RESPONSIBILITIES: +// // - Frame type detection and basic parsing. +// // - State-machine transitions for HELLO / AUTH / REGISTER. +// // - Dispatching ACK events to AckManager. +// // - Decrypting and interpreting DATA frames (chat / app payloads). +// // - Emitting high-level events on dedicated channels. +// // +// // THREAD SAFETY: +// // - Captures required references under lock before starting the goroutine. +// // - Uses internal locking only when accessing mutable client state. +// func (c *KittyClient) StartReceiverLoop(disconnected chan struct{}) { +// c.mu.Lock() +// stream := c.stream +// replay := c.replay +// ackMgr := c.ackMgr +// stopRecv := c.stopRecv + +// helloCh := c.helloCh +// authCh := c.authCh +// registerCh := c.registerCh + +// chatReqCh := c.chatReqCh +// chatAcceptCh := c.chatAcceptCh +// chatRefuseCh := c.chatRefuseCh +// chatEndCh := c.chatEndCh +// chatMsgCh := c.chatMsgCh +// c.mu.Unlock() + +// if stream == nil { +// return +// } + +// go func() { +// buf := make([]byte, defaultRecvBufferSize) + +// for { +// select { +// case <-stopRecv: +// return +// default: +// } + +// n, err := stream.Read(buf) +// if err != nil { +// // Dynamic disconnect handler (UI / app can override). +// c.mu.Lock() +// dh := c.disconnectHandler +// c.mu.Unlock() + +// if dh != nil { +// dh(err) +// } else { +// log(LogError, "disconnected: %v", err) +// } + +// select { +// case <-disconnected: +// default: +// close(disconnected) +// } +// return +// } + +// frameBytes := buf[:n] + +// typeName, msgID, err := protocol.GetFrameType(frameBytes) +// if err != nil { +// c.mu.Lock() +// eh := c.errHandler +// c.mu.Unlock() + +// if eh != nil { +// eh("PARSE_ERROR", err.Error()) +// } else { +// log(LogError, "parse error: %v", err) +// } +// continue +// } + +// switch typeName { + +// // ------------------------------------------------------------ +// // MEOW_OK — generic success response (HELLO / AUTH / REGISTER / DATA) +// // ------------------------------------------------------------ +// case protocol.FrameTypeMeowOK: +// c.mu.Lock() +// currentState := c.state +// c.mu.Unlock() + +// switch currentState { + +// case StateHandshaking: +// helloCh <- OpResult{OK: true} +// c.setState(StateAuthenticating) + +// case StateAuthenticating: +// authCh <- OpResult{OK: true} +// c.setState(StateSelectingTarget) + +// case StateRegistering: +// registerCh <- OpResult{OK: true} +// c.setState(StateAuthenticating) + +// default: +// if ackMgr != nil { +// ackMgr.NotifyDelivered(msgID) +// } +// } + +// // ------------------------------------------------------------ +// // ERROR — error response (HELLO / AUTH / REGISTER / generic) +// // ------------------------------------------------------------ +// case protocol.FrameTypeError: +// var ef protocol.ErrorFrame +// if json.Unmarshal(frameBytes, &ef) != nil { +// log(LogError, "failed to parse ERROR frame") +// continue +// } + +// c.mu.Lock() +// currentState := c.state +// eh := c.errHandler +// c.mu.Unlock() + +// switch currentState { + +// case StateHandshaking: +// helloCh <- OpResult{OK: false, Code: ef.Code, Desc: ef.Desc} + +// case StateAuthenticating: +// authCh <- OpResult{OK: false, Code: ef.Code, Desc: ef.Desc} + +// case StateRegistering: +// registerCh <- OpResult{OK: false, Code: ef.Code, Desc: ef.Desc} + +// default: +// if eh != nil { +// eh(ef.Code, ef.Desc) +// } else { +// log(LogError, "server error %s: %s", ef.Code, ef.Desc) +// } +// } + +// // ------------------------------------------------------------ +// // DATA — E2EE chat payload or other application data +// // ------------------------------------------------------------ +// case protocol.FrameTypeData: +// var df protocol.DataFrame +// if json.Unmarshal(frameBytes, &df) != nil { +// log(LogError, "failed to parse DATA frame") +// continue +// } + +// // Replay protection +// if replay != nil && replay.MarkAndCheck(df.MsgID) { +// continue +// } + +// c.mu.Lock() +// kEnc, kMac, ok := c.getKeysForPeer(df.Sender) +// appHandler := c.appHandler +// c.mu.Unlock() + +// if !ok { +// log(LogWarn, "no shared secret for %s", df.Sender) +// continue +// } + +// plaintext, err := cryptoee.DecryptAndVerifyWithKeys( +// df.MsgID, df.Target, df.Payload, df.MAC, kEnc, kMac, +// ) +// if err != nil { +// log(LogError, "E2EE error: %v", err) +// continue +// } + +// // Try to interpret as a chat control/message frame. +// var probe chatFrameProbe +// if err := json.Unmarshal([]byte(plaintext), &probe); err == nil && probe.Type != "" { +// switch probe.Type { + +// case "CHAT_REQUEST": +// if chatReqCh != nil { +// chatReqCh <- ChatRequestEvent{From: probe.From} +// } + +// case "CHAT_ACCEPT": +// if chatAcceptCh != nil { +// chatAcceptCh <- ChatAcceptEvent{From: probe.From} +// } + +// case "CHAT_REFUSE": +// var p chatRefusePayload +// _ = json.Unmarshal(probe.Payload, &p) +// if chatRefuseCh != nil { +// chatRefuseCh <- ChatRefuseEvent{From: probe.From, Reason: p.Reason} +// } + +// case "CHAT_END": +// var p chatEndPayload +// _ = json.Unmarshal(probe.Payload, &p) +// if chatEndCh != nil { +// chatEndCh <- ChatEndEvent{From: probe.From, Reason: p.Reason} +// } + +// case "TEXT_MESSAGE": +// var p textMessagePayload +// _ = json.Unmarshal(probe.Payload, &p) +// if chatMsgCh != nil { +// chatMsgCh <- ChatMessageEvent{From: probe.From, Text: p.Text} +// } + +// default: +// log(LogWarn, "unknown chat frame type: %s", probe.Type) +// } + +// // Chat frame fully handled, proceed to next frame. +// continue +// } + +// // Fallback: raw application data delivered to appHandler. +// if appHandler != nil { +// appHandler(df.Sender, []byte(plaintext)) +// } + +// // ------------------------------------------------------------ +// // STATUS_RES — response to GET_STATUS +// // ------------------------------------------------------------ +// case protocol.FrameTypeStatusRes: +// var sf protocol.StatusResFrame +// if json.Unmarshal(frameBytes, &sf) != nil { +// log(LogError, "failed to parse STATUS_RES") +// continue +// } + +// c.mu.Lock() +// sh := c.statusHandler +// c.mu.Unlock() + +// if sh != nil { +// sh(sf.Target, sf.Status) +// } else { +// log(LogInfo, "status: %s is %s", sf.Target, sf.Status) +// } +// } +// } +// }() +// } diff --git a/client/api/receive_chat.go b/client/api/receive_chat.go new file mode 100644 index 0000000..467a7c4 --- /dev/null +++ b/client/api/receive_chat.go @@ -0,0 +1,59 @@ +package api + +import "encoding/json" + +// handleChatPayload attempts to interpret the decrypted DATA payload as a +// chat control frame (CHAT_REQUEST / CHAT_ACCEPT / CHAT_REFUSE / CHAT_END / TEXT_MESSAGE). +// It returns true if the payload was recognized and dispatched as a chat event. +func (c *KittyClient) handleChatPayload(sender string, plaintext []byte) bool { + var probe chatFrameProbe + if err := json.Unmarshal(plaintext, &probe); err != nil || probe.Type == "" { + return false + } + + c.mu.Lock() + chatReqCh := c.chatReqCh + chatAcceptCh := c.chatAcceptCh + chatRefuseCh := c.chatRefuseCh + chatEndCh := c.chatEndCh + chatMsgCh := c.chatMsgCh + c.mu.Unlock() + + switch probe.Type { + case "CHAT_REQUEST": + if chatReqCh != nil { + chatReqCh <- ChatRequestEvent{From: probe.From} + } + + case "CHAT_ACCEPT": + if chatAcceptCh != nil { + chatAcceptCh <- ChatAcceptEvent{From: probe.From} + } + + case "CHAT_REFUSE": + var p chatRefusePayload + _ = json.Unmarshal(probe.Payload, &p) + if chatRefuseCh != nil { + chatRefuseCh <- ChatRefuseEvent{From: probe.From, Reason: p.Reason} + } + + case "CHAT_END": + var p chatEndPayload + _ = json.Unmarshal(probe.Payload, &p) + if chatEndCh != nil { + chatEndCh <- ChatEndEvent{From: probe.From, Reason: p.Reason} + } + + case "TEXT_MESSAGE": + var p textMessagePayload + _ = json.Unmarshal(probe.Payload, &p) + if chatMsgCh != nil { + chatMsgCh <- ChatMessageEvent{From: probe.From, Text: p.Text} + } + + default: + log(LogWarn, "unknown chat frame type: %s", probe.Type) + } + + return true +} diff --git a/client/api/receive_data.go b/client/api/receive_data.go new file mode 100644 index 0000000..d051cc8 --- /dev/null +++ b/client/api/receive_data.go @@ -0,0 +1,53 @@ +package api + +import ( + "encoding/json" + + "github.com/gabbla05/KittyProtocol/internal/cryptoee" + "github.com/gabbla05/KittyProtocol/protocol" +) + +// handleDataFrame processes a DATA frame: verifies replay protection, +// decrypts and authenticates the payload, then dispatches either chat +// control events or generic application payloads. +func (c *KittyClient) handleDataFrame(frameBytes []byte) { + var df protocol.DataFrame + if json.Unmarshal(frameBytes, &df) != nil { + log(LogError, "failed to parse DATA frame") + return + } + + c.mu.Lock() + replay := c.replay + kEnc, kMac, ok := c.getKeysForPeer(df.Sender) + appHandler := c.appHandler + c.mu.Unlock() + + if replay != nil && replay.MarkAndCheck(df.MsgID) { + // Replay detected — silently drop. + return + } + + if !ok { + log(LogWarn, "no shared secret for %s", df.Sender) + return + } + + plaintext, err := cryptoee.DecryptAndVerifyWithKeys( + df.MsgID, df.Target, df.Payload, df.MAC, kEnc, kMac, + ) + if err != nil { + log(LogError, "E2EE error: %v", err) + return + } + + // Try to interpret as chat control frame first. + if handled := c.handleChatPayload(df.Sender, []byte(plaintext)); handled { + return + } + + // Fallback: generic application payload. + if appHandler != nil { + appHandler(df.Sender, []byte(plaintext)) + } +} diff --git a/client/api/receive_error.go b/client/api/receive_error.go new file mode 100644 index 0000000..7c7ba6b --- /dev/null +++ b/client/api/receive_error.go @@ -0,0 +1,34 @@ +package api + +import "github.com/gabbla05/KittyProtocol/protocol" + +// handleErrorFrame processes an ERROR frame. For handshake-related states +// it forwards the error to the appropriate result channel. For established +// sessions it forwards the error to the registered ErrorHandler, if any. +func (c *KittyClient) handleErrorFrame(ef protocol.ErrorFrame) { + c.mu.Lock() + currentState := c.state + helloCh := c.helloCh + authCh := c.authCh + registerCh := c.registerCh + eh := c.errHandler + c.mu.Unlock() + + switch currentState { + case StateHandshaking: + helloCh <- OpResult{OK: false, Code: ef.Code, Desc: ef.Desc} + + case StateAuthenticating: + authCh <- OpResult{OK: false, Code: ef.Code, Desc: ef.Desc} + + case StateRegistering: + registerCh <- OpResult{OK: false, Code: ef.Code, Desc: ef.Desc} + + default: + if eh != nil { + eh(ef.Code, ef.Desc) + } else { + log(LogError, "server error %s: %s", ef.Code, ef.Desc) + } + } +} diff --git a/client/api/receive_loop.go b/client/api/receive_loop.go new file mode 100644 index 0000000..02b7046 --- /dev/null +++ b/client/api/receive_loop.go @@ -0,0 +1,97 @@ +package api + +import ( + "encoding/json" + + "github.com/gabbla05/KittyProtocol/protocol" +) + +// StartReceiverLoop starts a background goroutine that continuously reads +// frames from the QUIC stream, parses them and dispatches to type-specific +// handlers. It closes the provided 'disconnected' channel exactly once when +// the underlying stream is broken or the loop terminates. +func (c *KittyClient) StartReceiverLoop(disconnected chan struct{}) { + c.mu.Lock() + stream := c.stream + stopRecv := c.stopRecv + c.mu.Unlock() + + if stream == nil { + return + } + + go func() { + buf := make([]byte, defaultRecvBufferSize) + + for { + select { + case <-stopRecv: + return + default: + } + + n, err := stream.Read(buf) + if err != nil { + c.handleDisconnect(err, disconnected) + return + } + + frameBytes := buf[:n] + + typeName, msgID, err := protocol.GetFrameType(frameBytes) + if err != nil { + c.handleParseError(err) + continue + } + + switch typeName { + case protocol.FrameTypeMeowOK: + c.handleMeowOK(msgID) + + case protocol.FrameTypeError: + var ef protocol.ErrorFrame + if json.Unmarshal(frameBytes, &ef) != nil { + log(LogError, "failed to parse ERROR frame") + continue + } + c.handleErrorFrame(ef) + + case protocol.FrameTypeData: + c.handleDataFrame(frameBytes) + + case protocol.FrameTypeStatusRes: + c.handleStatusResFrame(frameBytes) + } + } + }() +} + +func (c *KittyClient) handleDisconnect(err error, disconnected chan struct{}) { + c.mu.Lock() + dh := c.disconnectHandler + c.mu.Unlock() + + if dh != nil { + dh(err) + } else { + log(LogError, "disconnected: %v", err) + } + + select { + case <-disconnected: + default: + close(disconnected) + } +} + +func (c *KittyClient) handleParseError(err error) { + c.mu.Lock() + eh := c.errHandler + c.mu.Unlock() + + if eh != nil { + eh("PARSE_ERROR", err.Error()) + } else { + log(LogError, "parse error: %v", err) + } +} diff --git a/client/api/receive_meow.go b/client/api/receive_meow.go new file mode 100644 index 0000000..7f33258 --- /dev/null +++ b/client/api/receive_meow.go @@ -0,0 +1,33 @@ +package api + +// handleMeowOK processes a MEOW_OK frame. Depending on the current client +// state it completes the HELLO / AUTH / REGISTER handshake or forwards +// the ACK to the AckManager for application-level messages. +func (c *KittyClient) handleMeowOK(msgID int64) { + c.mu.Lock() + currentState := c.state + helloCh := c.helloCh + authCh := c.authCh + registerCh := c.registerCh + ackMgr := c.ackMgr + c.mu.Unlock() + + switch currentState { + case StateHandshaking: + helloCh <- OpResult{OK: true} + c.setState(StateAuthenticating) + + case StateAuthenticating: + authCh <- OpResult{OK: true} + c.setState(StateSelectingTarget) + + case StateRegistering: + registerCh <- OpResult{OK: true} + c.setState(StateAuthenticating) + + default: + if ackMgr != nil { + ackMgr.NotifyDelivered(msgID) + } + } +} diff --git a/client/api/receive_status.go b/client/api/receive_status.go new file mode 100644 index 0000000..5f7279f --- /dev/null +++ b/client/api/receive_status.go @@ -0,0 +1,27 @@ +package api + +import ( + "encoding/json" + + "github.com/gabbla05/KittyProtocol/protocol" +) + +// handleStatusResFrame processes a STATUS_RES frame and forwards the +// status information either to a registered StatusHandler or to the log. +func (c *KittyClient) handleStatusResFrame(frameBytes []byte) { + var sf protocol.StatusResFrame + if json.Unmarshal(frameBytes, &sf) != nil { + log(LogError, "failed to parse STATUS_RES frame") + return + } + + c.mu.Lock() + sh := c.statusHandler + c.mu.Unlock() + + if sh != nil { + sh(sf.Target, sf.Status) + } else { + log(LogInfo, "status: %s is %s", sf.Target, sf.Status) + } +} diff --git a/client/api/receive_types.go b/client/api/receive_types.go new file mode 100644 index 0000000..1657f0c --- /dev/null +++ b/client/api/receive_types.go @@ -0,0 +1,24 @@ +package api + +import "encoding/json" + +// chatFrameProbe is a lightweight probe structure used to detect +// whether a decrypted DATA payload is a chat control frame. +type chatFrameProbe struct { + Type string `json:"type"` + From string `json:"from"` + To string `json:"to"` + Payload json.RawMessage `json:"payload,omitempty"` +} + +type chatRefusePayload struct { + Reason string `json:"reason,omitempty"` +} + +type chatEndPayload struct { + Reason string `json:"reason,omitempty"` +} + +type textMessagePayload struct { + Text string `json:"text"` +} diff --git a/client/api/send.go b/client/api/send.go index df619c8..4a41c04 100644 --- a/client/api/send.go +++ b/client/api/send.go @@ -2,22 +2,17 @@ package api import ( "encoding/json" - "errors" "strings" - "time" - - "github.com/gabbla05/KittyProtocol/internal/cryptoee" - "github.com/gabbla05/KittyProtocol/protocol" ) -// canonicalTarget normalizes the target username to a stable form. +// canonicalTarget normalizes a username into a canonical form. // This must match the Hub's canonicalization logic. func canonicalTarget(t string) string { return strings.ToLower(strings.TrimSpace(t)) } -// ensureConnected returns current stream or an error if the client -// is not in a usable state for sending frames. +// ensureConnected returns the active QUIC stream or an error if the client +// is not in a valid state for sending frames. func (c *KittyClient) ensureConnected() (StreamAdapter, error) { c.mu.Lock() defer c.mu.Unlock() @@ -31,123 +26,13 @@ func (c *KittyClient) ensureConnected() (StreamAdapter, error) { return c.stream, nil } -// SendAppFrameEncrypted sends an application-level frame (chat control, text, etc.) -// encrypted as a DATA frame. The Hub requires MAC for all DATA frames. -func (c *KittyClient) SendAppFrameEncrypted(target string, payload []byte) error { - stream, err := c.ensureConnected() - if err != nil { - return err - } - - if target == "" { - return ErrTargetNotSet - } - - c.mu.Lock() - kEnc, kMac, ok := c.getKeysForPeer(target) - c.mu.Unlock() - - if !ok { - return ErrNoSharedSecret - } - - msgID := time.Now().UnixMilli() - - c.mu.Lock() - ackMgr := c.ackMgr - if ackMgr != nil { - ackMgr.AddPending(msgID) - } - c.mu.Unlock() - - canonTarget := canonicalTarget(target) - - payloadB64, macB64, err := cryptoee.EncryptAndMACWithKeys( - msgID, - canonTarget, - string(payload), - kEnc, - kMac, - ) - if err != nil { - return err - } - - frame := protocol.DataFrame{ - BaseFrame: protocol.BaseFrame{ - Type: protocol.FrameTypeData, - MsgID: msgID, - }, - Target: canonTarget, - Payload: payloadB64, - MAC: macB64, - } - - b, err := json.Marshal(frame) - if err != nil { - return err - } - - // Store last raw frame for replay testing (dev-only helper). - c.mu.Lock() - c.lastFrame = b - c.mu.Unlock() - - _, err = stream.Write(b) - return err -} - -// SendGetStatus sends a GET_STATUS frame for a given user. -func (c *KittyClient) SendGetStatus(target string) error { - stream, err := c.ensureConnected() - if err != nil { - return err - } - - if target == "" { - return ErrTargetNotSet - } - - msgID := time.Now().UnixMilli() - - frame := protocol.GetStatusFrame{ - BaseFrame: protocol.BaseFrame{ - Type: protocol.FrameTypeGetStatus, - MsgID: msgID, - }, - Target: canonicalTarget(target), - } - +// sendFrame marshals the given frame to JSON and writes it to the stream. +// This helper centralizes JSON encoding and write logic to avoid duplication. +func (c *KittyClient) sendFrame(stream StreamAdapter, frame any) error { b, err := json.Marshal(frame) if err != nil { return err } - - _, err = stream.Write(b) - return err -} - -// SendBye sends a BYE frame to the Hub. -func (c *KittyClient) SendBye() error { - stream, err := c.ensureConnected() - if err != nil { - // BYE jest „best-effort” — jeśli nie ma streama, nie robimy dramatu. - if errors.Is(err, ErrNoStream) || errors.Is(err, ErrNotConnected) { - return nil - } - return err - } - - frame := protocol.BaseFrame{ - Type: protocol.FrameTypeBye, - MsgID: time.Now().UnixMilli(), - } - - b, err := json.Marshal(frame) - if err != nil { - return err - } - _, err = stream.Write(b) return err } diff --git a/client/api/send_bye.go b/client/api/send_bye.go new file mode 100644 index 0000000..0b767fa --- /dev/null +++ b/client/api/send_bye.go @@ -0,0 +1,29 @@ +package api + +import ( + "errors" + "time" + + "github.com/gabbla05/KittyProtocol/protocol" +) + +// SendBye sends a BYE frame to the Hub. +// +// BYE is best-effort: if the client is already disconnected or has no stream, +// the method returns nil without treating it as an error. +func (c *KittyClient) SendBye() error { + stream, err := c.ensureConnected() + if err != nil { + if errors.Is(err, ErrNoStream) || errors.Is(err, ErrNotConnected) { + return nil + } + return err + } + + frame := protocol.BaseFrame{ + Type: protocol.FrameTypeBye, + MsgID: time.Now().UnixMilli(), + } + + return c.sendFrame(stream, frame) +} diff --git a/client/api/send_data.go b/client/api/send_data.go new file mode 100644 index 0000000..0912555 --- /dev/null +++ b/client/api/send_data.go @@ -0,0 +1,77 @@ +package api + +import ( + "encoding/json" + "time" + + "github.com/gabbla05/KittyProtocol/internal/cryptoee" + "github.com/gabbla05/KittyProtocol/protocol" +) + +// SendAppFrameEncrypted sends an application-level frame (chat control, text, etc.) +// encrypted as a DATA frame. The Hub requires MAC for all DATA frames. +// +// SECURITY: +// - Uses per-peer E2EE keys derived from a shared secret. +// - Payload is encrypted and authenticated (MAC) via internal/cryptoee. +func (c *KittyClient) SendAppFrameEncrypted(target string, payload []byte) error { + stream, err := c.ensureConnected() + if err != nil { + return err + } + + if target == "" { + return ErrTargetNotSet + } + + c.mu.Lock() + kEnc, kMac, ok := c.getKeysForPeer(target) + ackMgr := c.ackMgr + c.mu.Unlock() + + if !ok { + return ErrNoSharedSecret + } + + msgID := time.Now().UnixMilli() + + if ackMgr != nil { + ackMgr.AddPending(msgID) + } + + canonTarget := canonicalTarget(target) + + payloadB64, macB64, err := cryptoee.EncryptAndMACWithKeys( + msgID, + canonTarget, + string(payload), + kEnc, + kMac, + ) + if err != nil { + return err + } + + frame := protocol.DataFrame{ + BaseFrame: protocol.BaseFrame{ + Type: protocol.FrameTypeData, + MsgID: msgID, + }, + Target: canonTarget, + Payload: payloadB64, + MAC: macB64, + } + + // Store last raw frame for replay testing (dev-only helper). + b, err := json.Marshal(frame) + if err != nil { + return err + } + + c.mu.Lock() + c.lastFrame = b + c.mu.Unlock() + + _, err = stream.Write(b) + return err +} diff --git a/client/api/send_status.go b/client/api/send_status.go new file mode 100644 index 0000000..78fb73b --- /dev/null +++ b/client/api/send_status.go @@ -0,0 +1,31 @@ +package api + +import ( + "time" + + "github.com/gabbla05/KittyProtocol/protocol" +) + +// SendGetStatus sends a GET_STATUS frame for a given user. +func (c *KittyClient) SendGetStatus(target string) error { + stream, err := c.ensureConnected() + if err != nil { + return err + } + + if target == "" { + return ErrTargetNotSet + } + + msgID := time.Now().UnixMilli() + + frame := protocol.GetStatusFrame{ + BaseFrame: protocol.BaseFrame{ + Type: protocol.FrameTypeGetStatus, + MsgID: msgID, + }, + Target: canonicalTarget(target), + } + + return c.sendFrame(stream, frame) +} From 691a6516c97c933773db1113983edf1086d681af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Brzezi=C5=84ski?= Date: Tue, 26 May 2026 14:04:38 +0000 Subject: [PATCH 39/44] client file refactor --- client/api/client.go | 188 ------------------------------- client/api/client_chat_events.go | 74 ++++++++++++ client/api/client_core.go | 121 ++++++++++++++++++++ client/api/client_handlers.go | 52 +++++++++ 4 files changed, 247 insertions(+), 188 deletions(-) delete mode 100644 client/api/client.go create mode 100644 client/api/client_chat_events.go create mode 100644 client/api/client_core.go create mode 100644 client/api/client_handlers.go diff --git a/client/api/client.go b/client/api/client.go deleted file mode 100644 index a06b8e5..0000000 --- a/client/api/client.go +++ /dev/null @@ -1,188 +0,0 @@ -package api - -import ( - "context" - "sync" - - "github.com/gabbla05/KittyProtocol/internal/protection" -) - -type ClientState int - -type AppPayloadHandler func(sender string, payload []byte) -type ErrorHandler func(code, desc string) -type StatusHandler func(target, status string) -type DisconnectHandler func(err error) - -const ( - StateDisconnected ClientState = iota - StateHandshaking - StateAuthenticating - StateRegistering - StateSelectingTarget - StateEstablished -) - -type peerKeys struct { - kEnc []byte - kMac []byte -} - -type KittyClient struct { - mu sync.Mutex - state ClientState - - conn ConnAdapter - stream StreamAdapter - - user string - target string - - ackMgr *AckManager - replay *protection.ReplayDetector - stopPing chan struct{} - stopRecv chan struct{} - ctx context.Context - cancel context.CancelFunc - - lastFrame []byte - - peerKeys map[string]peerKeys - - appHandler AppPayloadHandler - errHandler ErrorHandler - statusHandler StatusHandler - disconnectHandler DisconnectHandler - - helloCh chan OpResult - authCh chan OpResult - registerCh chan OpResult - - chatReqCh chan ChatRequestEvent - chatAcceptCh chan ChatAcceptEvent - chatRefuseCh chan ChatRefuseEvent - chatEndCh chan ChatEndEvent - chatMsgCh chan ChatMessageEvent -} - -func NewKittyClient() *KittyClient { - ctx, cancel := context.WithCancel(context.Background()) - - return &KittyClient{ - state: StateDisconnected, - ackMgr: NewAckManager(), - replay: protection.NewReplayDetector(), - stopPing: make(chan struct{}), - stopRecv: make(chan struct{}), - ctx: ctx, - cancel: cancel, - peerKeys: make(map[string]peerKeys), - helloCh: make(chan OpResult, 1), - authCh: make(chan OpResult, 1), - registerCh: make(chan OpResult, 1), - - chatReqCh: make(chan ChatRequestEvent, 4), - chatAcceptCh: make(chan ChatAcceptEvent, 4), - chatRefuseCh: make(chan ChatRefuseEvent, 4), - chatEndCh: make(chan ChatEndEvent, 4), - chatMsgCh: make(chan ChatMessageEvent, 16), - } -} - -func (c *KittyClient) RegisterAppPayloadHandler(h AppPayloadHandler) { - c.mu.Lock() - defer c.mu.Unlock() - c.appHandler = h -} - -func (c *KittyClient) OnError(h ErrorHandler) { - c.mu.Lock() - defer c.mu.Unlock() - c.errHandler = h -} - -func (c *KittyClient) OnStatus(h StatusHandler) { - c.mu.Lock() - defer c.mu.Unlock() - c.statusHandler = h -} - -func (c *KittyClient) OnDisconnected(h DisconnectHandler) { - c.mu.Lock() - defer c.mu.Unlock() - c.disconnectHandler = h -} - -func (c *KittyClient) User() string { - c.mu.Lock() - defer c.mu.Unlock() - return c.user -} - -func (c *KittyClient) getKeysForPeer(peer string) (kEnc, kMac []byte, ok bool) { - pk, exists := c.peerKeys[peer] - if !exists { - return nil, nil, false - } - return pk.kEnc, pk.kMac, true -} - -type OpResult struct { - OK bool - Code string - Desc string -} - -func (r OpResult) Error() string { - if r.OK { - return "" - } - if r.Desc != "" { - return r.Code + ": " + r.Desc - } - return r.Code -} - -type ChatRequestEvent struct { - From string -} - -type ChatAcceptEvent struct { - From string -} - -type ChatRefuseEvent struct { - From string - Reason string -} - -type ChatEndEvent struct { - From string - Reason string -} - -type ChatMessageEvent struct { - From string - Text string -} - -// Chat event getters -func (c *KittyClient) ChatRequestEvents() <-chan ChatRequestEvent { - return c.chatReqCh -} - -func (c *KittyClient) ChatAcceptEvents() <-chan ChatAcceptEvent { - return c.chatAcceptCh -} - -func (c *KittyClient) ChatRefuseEvents() <-chan ChatRefuseEvent { - return c.chatRefuseCh -} - -func (c *KittyClient) ChatEndEvents() <-chan ChatEndEvent { - return c.chatEndCh -} - -func (c *KittyClient) ChatMessageEvents() <-chan ChatMessageEvent { - return c.chatMsgCh -} diff --git a/client/api/client_chat_events.go b/client/api/client_chat_events.go new file mode 100644 index 0000000..0bd7f6e --- /dev/null +++ b/client/api/client_chat_events.go @@ -0,0 +1,74 @@ +package api + +// OpResult represents the outcome of an asynchronous operation such as +// HELLO, AUTH or REGISTER. It implements the error interface for convenient +// propagation through Go APIs. +type OpResult struct { + OK bool + Code string + Desc string +} + +func (r OpResult) Error() string { + if r.OK { + return "" + } + if r.Desc != "" { + return r.Code + ": " + r.Desc + } + return r.Code +} + +// ChatRequestEvent is emitted when a peer requests to start a chat session. +type ChatRequestEvent struct { + From string +} + +// ChatAcceptEvent is emitted when a peer accepts a previously sent chat request. +type ChatAcceptEvent struct { + From string +} + +// ChatRefuseEvent is emitted when a peer refuses a chat request. +type ChatRefuseEvent struct { + From string + Reason string +} + +// ChatEndEvent is emitted when a peer terminates an active chat session. +type ChatEndEvent struct { + From string + Reason string +} + +// ChatMessageEvent is emitted when a peer sends a text message within +// an established chat session. +type ChatMessageEvent struct { + From string + Text string +} + +// ChatRequestEvents returns a read-only channel of incoming chat requests. +func (c *KittyClient) ChatRequestEvents() <-chan ChatRequestEvent { + return c.chatReqCh +} + +// ChatAcceptEvents returns a read-only channel of chat accept events. +func (c *KittyClient) ChatAcceptEvents() <-chan ChatAcceptEvent { + return c.chatAcceptCh +} + +// ChatRefuseEvents returns a read-only channel of chat refusal events. +func (c *KittyClient) ChatRefuseEvents() <-chan ChatRefuseEvent { + return c.chatRefuseCh +} + +// ChatEndEvents returns a read-only channel of chat termination events. +func (c *KittyClient) ChatEndEvents() <-chan ChatEndEvent { + return c.chatEndCh +} + +// ChatMessageEvents returns a read-only channel of incoming chat messages. +func (c *KittyClient) ChatMessageEvents() <-chan ChatMessageEvent { + return c.chatMsgCh +} diff --git a/client/api/client_core.go b/client/api/client_core.go new file mode 100644 index 0000000..c259edf --- /dev/null +++ b/client/api/client_core.go @@ -0,0 +1,121 @@ +package api + +import ( + "context" + "sync" + + "github.com/gabbla05/KittyProtocol/internal/protection" +) + +// ClientState represents the high-level lifecycle state of KittyClient. +// It is intentionally coarse-grained and UI-agnostic. +type ClientState int + +const ( + StateDisconnected ClientState = iota + StateHandshaking + StateAuthenticating + StateRegistering + StateSelectingTarget + StateEstablished +) + +// AppPayloadHandler is invoked for application-level payloads that are not +// recognized as chat control frames. It is typically used by higher layers +// (e.g. GUI, bots) to process custom messages. +type AppPayloadHandler func(sender string, payload []byte) + +// ErrorHandler receives protocol-level errors that are not directly tied +// to HELLO / AUTH / REGISTER operations. +type ErrorHandler func(code, desc string) + +// StatusHandler receives presence/status updates for a given user. +type StatusHandler func(target, status string) + +// DisconnectHandler is invoked when the underlying transport is broken +// or the receiver loop terminates due to a read error. +type DisconnectHandler func(err error) + +type peerKeys struct { + kEnc []byte + kMac []byte +} + +// KittyClient is the main high-level client for KittyProtocol. +// +// It encapsulates: +// - QUIC transport (ConnAdapter / StreamAdapter), +// - TLS + TOFU certificate pinning, +// - E2EE key management, +// - replay protection, +// - ACK tracking, +// - chat events and application payload dispatch, +// - lifecycle state machine. +// +// The type is safe for concurrent use by multiple goroutines. +type KittyClient struct { + mu sync.Mutex + state ClientState + + conn ConnAdapter + stream StreamAdapter + + user string + target string + + ackMgr *AckManager + replay *protection.ReplayDetector + stopPing chan struct{} + stopRecv chan struct{} + ctx context.Context + cancel context.CancelFunc + + // lastFrame is a development-only helper used for replay testing. + lastFrame []byte + + peerKeys map[string]peerKeys + + appHandler AppPayloadHandler + errHandler ErrorHandler + statusHandler StatusHandler + disconnectHandler DisconnectHandler + + helloCh chan OpResult + authCh chan OpResult + registerCh chan OpResult + + chatReqCh chan ChatRequestEvent + chatAcceptCh chan ChatAcceptEvent + chatRefuseCh chan ChatRefuseEvent + chatEndCh chan ChatEndEvent + chatMsgCh chan ChatMessageEvent +} + +// NewKittyClient constructs a new client instance with a fresh internal +// context, replay detector, ACK manager and all event channels. +// +// The returned client is in StateDisconnected and has no active transport. +func NewKittyClient() *KittyClient { + ctx, cancel := context.WithCancel(context.Background()) + + return &KittyClient{ + state: StateDisconnected, + ackMgr: NewAckManager(), + replay: protection.NewReplayDetector(), + stopPing: make(chan struct{}), + stopRecv: make(chan struct{}), + ctx: ctx, + cancel: cancel, + peerKeys: make(map[string]peerKeys), + + helloCh: make(chan OpResult, 1), + authCh: make(chan OpResult, 1), + registerCh: make(chan OpResult, 1), + + chatReqCh: make(chan ChatRequestEvent, 4), + chatAcceptCh: make(chan ChatAcceptEvent, 4), + chatRefuseCh: make(chan ChatRefuseEvent, 4), + chatEndCh: make(chan ChatEndEvent, 4), + chatMsgCh: make(chan ChatMessageEvent, 16), + } +} diff --git a/client/api/client_handlers.go b/client/api/client_handlers.go new file mode 100644 index 0000000..673fc8b --- /dev/null +++ b/client/api/client_handlers.go @@ -0,0 +1,52 @@ +package api + +// RegisterAppPayloadHandler registers a callback for application-level +// payloads that are not recognized as chat control frames. +// +// Thread-safety: safe to call at any time; handler replacement is atomic. +func (c *KittyClient) RegisterAppPayloadHandler(h AppPayloadHandler) { + c.mu.Lock() + defer c.mu.Unlock() + c.appHandler = h +} + +// OnError registers a callback for protocol-level errors that are not +// directly tied to HELLO / AUTH / REGISTER operations. +func (c *KittyClient) OnError(h ErrorHandler) { + c.mu.Lock() + defer c.mu.Unlock() + c.errHandler = h +} + +// OnStatus registers a callback for presence/status updates. +func (c *KittyClient) OnStatus(h StatusHandler) { + c.mu.Lock() + defer c.mu.Unlock() + c.statusHandler = h +} + +// OnDisconnected registers a callback that is invoked when the underlying +// transport is broken or the receiver loop terminates due to a read error. +func (c *KittyClient) OnDisconnected(h DisconnectHandler) { + c.mu.Lock() + defer c.mu.Unlock() + c.disconnectHandler = h +} + +// User returns the currently authenticated username, if any. +// If the client is not authenticated, an empty string is returned. +func (c *KittyClient) User() string { + c.mu.Lock() + defer c.mu.Unlock() + return c.user +} + +// getKeysForPeer returns the E2EE keys for a given peer, if present. +// It is intentionally unexported; key management is internal to KittyClient. +func (c *KittyClient) getKeysForPeer(peer string) (kEnc, kMac []byte, ok bool) { + pk, exists := c.peerKeys[peer] + if !exists { + return nil, nil, false + } + return pk.kEnc, pk.kMac, true +} From cc81d0ff1a351c70bb9d5c92b8775813a95585b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Brzezi=C5=84ski?= Date: Tue, 26 May 2026 14:43:30 +0000 Subject: [PATCH 40/44] errors and constants refactor --- TODO | 3 +- client/api/constants.go | 11 ++ client/api/e2ee.go | 16 ++- client/api/errors.go | 43 +++++- client/api/logger.go | 14 +- client/api/ping.go | 3 +- client/api/receive.go | 282 ------------------------------------- client/api/receive_loop.go | 5 +- client/api/send_data.go | 8 ++ client/api/send_status.go | 4 + client/api/state.go | 8 +- client/api/target.go | 10 +- client/api/transport.go | 6 +- 13 files changed, 112 insertions(+), 301 deletions(-) delete mode 100644 client/api/receive.go diff --git a/TODO b/TODO index 75ed30e..da3ae55 100644 --- a/TODO +++ b/TODO @@ -60,4 +60,5 @@ Jest jeszcze kilka rzeczy do zrobienia w tym projekcie. Daję listę, którą fa - gwiazdki w cli jak się hasło wpisuje albo nie pokazywać jak się wpisuje w ogóle bo obecnie się wyświetla w konsoli normalnie - czy komendy typu /command nie powinny być ogólnie dostępne, także w gui na przyszłość, żeby gui wołało tylko jakąś komendę np. /status albo /chat itp pytanie do zasaatnowienia i odpowiedzi. Wiem że sporo tego, ale możesz to podzielić namniejsze etapy którymi będziemy się zajmować teraz po kolei - authtimer powinien się zaczynać po wybraniu opcji logowania/rejestracji a nie po wystartowaniu aplikacji. Powiniensię restartować przy każdorazowym wybraniu opcji /login bo w ogóle ten timer fl arejestracji chyba nie bardzo ma sens -- komenda /menu zamiast każdorazowo wyświetlać i psuć flow \ No newline at end of file +- komenda /menu zamiast każdorazowo wyświetlać i psuć flow +- brak pokazywania sekretu przy wpisywaniu sekretu \ No newline at end of file diff --git a/client/api/constants.go b/client/api/constants.go index e395a4a..d7cfb88 100644 --- a/client/api/constants.go +++ b/client/api/constants.go @@ -15,4 +15,15 @@ const ( // defaultPingInterval controls how often PING frames are sent to the Hub. defaultPingInterval = 30 * time.Second + + // minSharedSecretLength defines the minimum number of bytes required + // for a valid E2EE shared secret. + minSharedSecretLength = 16 + + // maxPayloadSize defines the maximum allowed plaintext payload size + // before encryption. This protects memory usage and prevents abuse. + maxPayloadSize = 16 * 1024 // 16 KB + + // maxUsernameLength defines the maximum allowed username length. + maxUsernameLength = 64 ) diff --git a/client/api/e2ee.go b/client/api/e2ee.go index e11d730..29afcbd 100644 --- a/client/api/e2ee.go +++ b/client/api/e2ee.go @@ -1,8 +1,6 @@ package api import ( - "errors" - "github.com/gabbla05/KittyProtocol/internal/cryptoee" ) @@ -13,12 +11,19 @@ import ( // - Keys are derived using HKDF-SHA256 in internal/cryptoee. // - Keys are zeroized and cleared in KittyClient.Close(). // - Keys are kept only in memory and never written to disk. +// - A minimum secret length is enforced to avoid weak E2EE setups. func (c *KittyClient) SetSharedSecretForPeer(peer string, secret []byte) error { if peer == "" { - return errors.New("peer cannot be empty") + return ErrEmptyPeer + } + if len(peer) > maxUsernameLength { + return ErrPeerNameTooLong } if len(secret) == 0 { - return errors.New("secret cannot be empty") + return ErrEmptySecret + } + if len(secret) < minSharedSecretLength { + return ErrSharedSecretTooShort } kEnc, kMac, err := cryptoee.DeriveKeysFromSecret(secret) @@ -32,13 +37,16 @@ func (c *KittyClient) SetSharedSecretForPeer(peer string, secret []byte) error { if c.peerKeys == nil { c.peerKeys = make(map[string]peerKeys) } + c.peerKeys[peer] = peerKeys{ kEnc: kEnc, kMac: kMac, } + return nil } +// HasSharedSecret returns true if E2EE keys exist for the given peer. func (c *KittyClient) HasSharedSecret(peer string) bool { c.mu.Lock() defer c.mu.Unlock() diff --git a/client/api/errors.go b/client/api/errors.go index bce80c5..6cf81c2 100644 --- a/client/api/errors.go +++ b/client/api/errors.go @@ -2,6 +2,7 @@ package api import "errors" +// Transport / connection errors var ( // ErrNotConnected is returned when an operation requires an active // connection/stream but the client is disconnected. @@ -9,12 +10,48 @@ var ( // ErrNoStream is returned when the underlying stream is nil. ErrNoStream = errors.New("stream is nil") +) + +// Target / peer errors +var ( + // ErrTargetNotSet is returned when an operation requires a target + // but none is configured. + ErrTargetNotSet = errors.New("target not set") + + // ErrTargetNameTooLong is returned when the configured target name + // exceeds the maximum allowed length. + ErrTargetNameTooLong = errors.New("target name too long") + + // ErrPeerNameTooLong is returned when the identifier of a peer + // exceeds the maximum allowed length. + ErrPeerNameTooLong = errors.New("peer name too long") + + // ErrSharedSecretTooShort is returned when the provided shared secret + // does not meet the minimum length requirement. + ErrSharedSecretTooShort = errors.New("shared secret too short") // ErrNoSharedSecret is returned when trying to send or decrypt // E2EE data without a derived secret for the peer. ErrNoSharedSecret = errors.New("no shared secret for target") - // ErrTargetNotSet is returned when an operation requires a target - // but none is configured. - ErrTargetNotSet = errors.New("target not set") + // ErrEmptyPeer is returned when SetSharedSecretForPeer is called + // with an empty peer identifier. + ErrEmptyPeer = errors.New("peer cannot be empty") + + // ErrEmptySecret is returned when SetSharedSecretForPeer is called + // with an empty secret. + ErrEmptySecret = errors.New("secret cannot be empty") +) + +// Frame / protocol errors +var ( + // ErrPayloadTooLarge is returned when the frame payload size + // exceeds the protocol's defined maximum limit. + ErrPayloadTooLarge = errors.New("payload too large") + + // ErrUnknownFrameType indicates that the received frame type is not recognized. + ErrUnknownFrameType = errors.New("unknown frame type") + + // ErrFrameParseFailed indicates that a frame could not be unmarshaled. + ErrFrameParseFailed = errors.New("failed to parse frame") ) diff --git a/client/api/logger.go b/client/api/logger.go index d1a0f95..d211824 100644 --- a/client/api/logger.go +++ b/client/api/logger.go @@ -5,6 +5,7 @@ import ( "sync" ) +// LogLevel represents the severity of a log message. type LogLevel int const ( @@ -14,15 +15,19 @@ const ( LogError ) +// Logger is a minimal logging interface used by KittyClient. +// +// UI layers (CLI, GUI, Wails frontend) are expected to provide their own +// implementation and install it via SetLogger. type Logger interface { Log(level LogLevel, msg string) } type defaultLogger struct{} -func (defaultLogger) Log(level LogLevel, msg string) { - // Domyślnie nic — API jest UI-agnostic. -} +// Log implements Logger but intentionally discards all messages. +// This keeps the API layer UI-agnostic by default. +func (defaultLogger) Log(level LogLevel, msg string) {} type logManager struct { mu sync.Mutex @@ -33,6 +38,9 @@ var globalLogger = &logManager{ logger: defaultLogger{}, } +// SetLogger installs a process-wide logger used by the client API. +// It is safe to call from multiple goroutines, but typically configured +// once during application startup. func SetLogger(l Logger) { globalLogger.mu.Lock() defer globalLogger.mu.Unlock() diff --git a/client/api/ping.go b/client/api/ping.go index 7490774..5a92d50 100644 --- a/client/api/ping.go +++ b/client/api/ping.go @@ -11,7 +11,8 @@ import ( // PING frames to keep the KittyProtocol session active. // // Although QUIC has its own keep-alive mechanisms, the Hub expects -// application-level PING frames to detect idle clients. +// application-level PING frames to detect idle clients and clean up +// stale sessions. // // The loop terminates when: // - stopPing channel is closed, diff --git a/client/api/receive.go b/client/api/receive.go deleted file mode 100644 index 2421e0a..0000000 --- a/client/api/receive.go +++ /dev/null @@ -1,282 +0,0 @@ -// // receive.go -package api - -// import ( -// "encoding/json" - -// "github.com/gabbla05/KittyProtocol/internal/cryptoee" -// "github.com/gabbla05/KittyProtocol/protocol" -// ) - -// // chatFrameProbe is a lightweight probe structure used to detect -// // whether decrypted DATA payload is a chat control/message frame. -// type chatFrameProbe struct { -// Type string `json:"type"` -// From string `json:"from"` -// To string `json:"to"` -// Payload json.RawMessage `json:"payload,omitempty"` -// } - -// type chatRefusePayload struct { -// Reason string `json:"reason,omitempty"` -// } - -// type chatEndPayload struct { -// Reason string `json:"reason,omitempty"` -// } - -// type textMessagePayload struct { -// Text string `json:"text"` -// } - -// // StartReceiverLoop starts a background goroutine that continuously reads -// // frames from the QUIC stream and dispatches them to appropriate handlers. -// // -// // RESPONSIBILITIES: -// // - Frame type detection and basic parsing. -// // - State-machine transitions for HELLO / AUTH / REGISTER. -// // - Dispatching ACK events to AckManager. -// // - Decrypting and interpreting DATA frames (chat / app payloads). -// // - Emitting high-level events on dedicated channels. -// // -// // THREAD SAFETY: -// // - Captures required references under lock before starting the goroutine. -// // - Uses internal locking only when accessing mutable client state. -// func (c *KittyClient) StartReceiverLoop(disconnected chan struct{}) { -// c.mu.Lock() -// stream := c.stream -// replay := c.replay -// ackMgr := c.ackMgr -// stopRecv := c.stopRecv - -// helloCh := c.helloCh -// authCh := c.authCh -// registerCh := c.registerCh - -// chatReqCh := c.chatReqCh -// chatAcceptCh := c.chatAcceptCh -// chatRefuseCh := c.chatRefuseCh -// chatEndCh := c.chatEndCh -// chatMsgCh := c.chatMsgCh -// c.mu.Unlock() - -// if stream == nil { -// return -// } - -// go func() { -// buf := make([]byte, defaultRecvBufferSize) - -// for { -// select { -// case <-stopRecv: -// return -// default: -// } - -// n, err := stream.Read(buf) -// if err != nil { -// // Dynamic disconnect handler (UI / app can override). -// c.mu.Lock() -// dh := c.disconnectHandler -// c.mu.Unlock() - -// if dh != nil { -// dh(err) -// } else { -// log(LogError, "disconnected: %v", err) -// } - -// select { -// case <-disconnected: -// default: -// close(disconnected) -// } -// return -// } - -// frameBytes := buf[:n] - -// typeName, msgID, err := protocol.GetFrameType(frameBytes) -// if err != nil { -// c.mu.Lock() -// eh := c.errHandler -// c.mu.Unlock() - -// if eh != nil { -// eh("PARSE_ERROR", err.Error()) -// } else { -// log(LogError, "parse error: %v", err) -// } -// continue -// } - -// switch typeName { - -// // ------------------------------------------------------------ -// // MEOW_OK — generic success response (HELLO / AUTH / REGISTER / DATA) -// // ------------------------------------------------------------ -// case protocol.FrameTypeMeowOK: -// c.mu.Lock() -// currentState := c.state -// c.mu.Unlock() - -// switch currentState { - -// case StateHandshaking: -// helloCh <- OpResult{OK: true} -// c.setState(StateAuthenticating) - -// case StateAuthenticating: -// authCh <- OpResult{OK: true} -// c.setState(StateSelectingTarget) - -// case StateRegistering: -// registerCh <- OpResult{OK: true} -// c.setState(StateAuthenticating) - -// default: -// if ackMgr != nil { -// ackMgr.NotifyDelivered(msgID) -// } -// } - -// // ------------------------------------------------------------ -// // ERROR — error response (HELLO / AUTH / REGISTER / generic) -// // ------------------------------------------------------------ -// case protocol.FrameTypeError: -// var ef protocol.ErrorFrame -// if json.Unmarshal(frameBytes, &ef) != nil { -// log(LogError, "failed to parse ERROR frame") -// continue -// } - -// c.mu.Lock() -// currentState := c.state -// eh := c.errHandler -// c.mu.Unlock() - -// switch currentState { - -// case StateHandshaking: -// helloCh <- OpResult{OK: false, Code: ef.Code, Desc: ef.Desc} - -// case StateAuthenticating: -// authCh <- OpResult{OK: false, Code: ef.Code, Desc: ef.Desc} - -// case StateRegistering: -// registerCh <- OpResult{OK: false, Code: ef.Code, Desc: ef.Desc} - -// default: -// if eh != nil { -// eh(ef.Code, ef.Desc) -// } else { -// log(LogError, "server error %s: %s", ef.Code, ef.Desc) -// } -// } - -// // ------------------------------------------------------------ -// // DATA — E2EE chat payload or other application data -// // ------------------------------------------------------------ -// case protocol.FrameTypeData: -// var df protocol.DataFrame -// if json.Unmarshal(frameBytes, &df) != nil { -// log(LogError, "failed to parse DATA frame") -// continue -// } - -// // Replay protection -// if replay != nil && replay.MarkAndCheck(df.MsgID) { -// continue -// } - -// c.mu.Lock() -// kEnc, kMac, ok := c.getKeysForPeer(df.Sender) -// appHandler := c.appHandler -// c.mu.Unlock() - -// if !ok { -// log(LogWarn, "no shared secret for %s", df.Sender) -// continue -// } - -// plaintext, err := cryptoee.DecryptAndVerifyWithKeys( -// df.MsgID, df.Target, df.Payload, df.MAC, kEnc, kMac, -// ) -// if err != nil { -// log(LogError, "E2EE error: %v", err) -// continue -// } - -// // Try to interpret as a chat control/message frame. -// var probe chatFrameProbe -// if err := json.Unmarshal([]byte(plaintext), &probe); err == nil && probe.Type != "" { -// switch probe.Type { - -// case "CHAT_REQUEST": -// if chatReqCh != nil { -// chatReqCh <- ChatRequestEvent{From: probe.From} -// } - -// case "CHAT_ACCEPT": -// if chatAcceptCh != nil { -// chatAcceptCh <- ChatAcceptEvent{From: probe.From} -// } - -// case "CHAT_REFUSE": -// var p chatRefusePayload -// _ = json.Unmarshal(probe.Payload, &p) -// if chatRefuseCh != nil { -// chatRefuseCh <- ChatRefuseEvent{From: probe.From, Reason: p.Reason} -// } - -// case "CHAT_END": -// var p chatEndPayload -// _ = json.Unmarshal(probe.Payload, &p) -// if chatEndCh != nil { -// chatEndCh <- ChatEndEvent{From: probe.From, Reason: p.Reason} -// } - -// case "TEXT_MESSAGE": -// var p textMessagePayload -// _ = json.Unmarshal(probe.Payload, &p) -// if chatMsgCh != nil { -// chatMsgCh <- ChatMessageEvent{From: probe.From, Text: p.Text} -// } - -// default: -// log(LogWarn, "unknown chat frame type: %s", probe.Type) -// } - -// // Chat frame fully handled, proceed to next frame. -// continue -// } - -// // Fallback: raw application data delivered to appHandler. -// if appHandler != nil { -// appHandler(df.Sender, []byte(plaintext)) -// } - -// // ------------------------------------------------------------ -// // STATUS_RES — response to GET_STATUS -// // ------------------------------------------------------------ -// case protocol.FrameTypeStatusRes: -// var sf protocol.StatusResFrame -// if json.Unmarshal(frameBytes, &sf) != nil { -// log(LogError, "failed to parse STATUS_RES") -// continue -// } - -// c.mu.Lock() -// sh := c.statusHandler -// c.mu.Unlock() - -// if sh != nil { -// sh(sf.Target, sf.Status) -// } else { -// log(LogInfo, "status: %s is %s", sf.Target, sf.Status) -// } -// } -// } -// }() -// } diff --git a/client/api/receive_loop.go b/client/api/receive_loop.go index 02b7046..325eec9 100644 --- a/client/api/receive_loop.go +++ b/client/api/receive_loop.go @@ -40,7 +40,7 @@ func (c *KittyClient) StartReceiverLoop(disconnected chan struct{}) { typeName, msgID, err := protocol.GetFrameType(frameBytes) if err != nil { - c.handleParseError(err) + c.handleParseError(ErrFrameParseFailed) continue } @@ -61,6 +61,9 @@ func (c *KittyClient) StartReceiverLoop(disconnected chan struct{}) { case protocol.FrameTypeStatusRes: c.handleStatusResFrame(frameBytes) + + default: + c.handleParseError(ErrUnknownFrameType) } } }() diff --git a/client/api/send_data.go b/client/api/send_data.go index 0912555..c9de078 100644 --- a/client/api/send_data.go +++ b/client/api/send_data.go @@ -24,6 +24,10 @@ func (c *KittyClient) SendAppFrameEncrypted(target string, payload []byte) error return ErrTargetNotSet } + if len(target) > maxUsernameLength { + return ErrTargetNameTooLong + } + c.mu.Lock() kEnc, kMac, ok := c.getKeysForPeer(target) ackMgr := c.ackMgr @@ -41,6 +45,10 @@ func (c *KittyClient) SendAppFrameEncrypted(target string, payload []byte) error canonTarget := canonicalTarget(target) + if len(payload) > maxPayloadSize { + return ErrPayloadTooLarge + } + payloadB64, macB64, err := cryptoee.EncryptAndMACWithKeys( msgID, canonTarget, diff --git a/client/api/send_status.go b/client/api/send_status.go index 78fb73b..57be0e9 100644 --- a/client/api/send_status.go +++ b/client/api/send_status.go @@ -17,6 +17,10 @@ func (c *KittyClient) SendGetStatus(target string) error { return ErrTargetNotSet } + if len(target) > maxUsernameLength { + return ErrTargetNameTooLong + } + msgID := time.Now().UnixMilli() frame := protocol.GetStatusFrame{ diff --git a/client/api/state.go b/client/api/state.go index 21cc2c6..a7819b6 100644 --- a/client/api/state.go +++ b/client/api/state.go @@ -1,6 +1,8 @@ package api -// State returns the current client state (thread-safe). +// State returns the current client state. +// +// Thread-safety: safe to call from any goroutine. func (c *KittyClient) State() ClientState { c.mu.Lock() defer c.mu.Unlock() @@ -8,7 +10,9 @@ func (c *KittyClient) State() ClientState { } // setState updates the internal state. -// This method is intentionally unexported to prevent misuse by UI layers. +// +// This method is intentionally unexported to prevent UI layers from +// mutating the protocol state machine directly. func (c *KittyClient) setState(newState ClientState) { c.mu.Lock() defer c.mu.Unlock() diff --git a/client/api/target.go b/client/api/target.go index 3c9acc1..458737a 100644 --- a/client/api/target.go +++ b/client/api/target.go @@ -1,14 +1,20 @@ package api -// SetTarget sets the current chat target. +// SetTarget sets the current chat target (peer username). // This is a UI-level decision and does not involve protocol logic. func (c *KittyClient) SetTarget(target string) { c.mu.Lock() defer c.mu.Unlock() + + if len(target) > maxUsernameLength { + // UI-level error, nie protokołowy + log(LogWarn, "target name too long") + } + c.target = target } -// Target returns the current chat target. +// Target returns the current chat target (peer username). func (c *KittyClient) Target() string { c.mu.Lock() defer c.mu.Unlock() diff --git a/client/api/transport.go b/client/api/transport.go index e1c3e00..2c95b21 100644 --- a/client/api/transport.go +++ b/client/api/transport.go @@ -7,7 +7,9 @@ import ( ) // StreamAdapter abstracts a bidirectional QUIC stream. -// It allows KittyClient to remain transport-agnostic and easily testable. +// +// This indirection allows KittyClient to remain transport-agnostic and +// easily testable (e.g. with in-memory or mock streams). type StreamAdapter interface { Read(p []byte) (int, error) Write(p []byte) (int, error) @@ -15,7 +17,7 @@ type StreamAdapter interface { // QUIC-specific cancellation hooks are kept here because the current // transport is QUIC. If we ever swap transport, a new adapter can - // implement these as no-ops. + // implement these as no-ops or map them to equivalent semantics. CancelRead(code quic.StreamErrorCode) CancelWrite(code quic.StreamErrorCode) } From 2a8f85d85c4f8d4732ddea8e5d886214ded458ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Brzezi=C5=84ski?= Date: Tue, 26 May 2026 15:05:30 +0000 Subject: [PATCH 41/44] chat logic refactor --- client/app/app.go | 114 ++++++++------ client/app/chat/events.go | 43 ++++++ client/app/{chat_frames.go => chat/frames.go} | 55 ++----- client/app/chat/logic.go | 141 ++++++++++++++++++ client/app/{chat_state.go => chat/state.go} | 24 ++- client/app/chat_logic.go | 137 ----------------- 6 files changed, 279 insertions(+), 235 deletions(-) create mode 100644 client/app/chat/events.go rename client/app/{chat_frames.go => chat/frames.go} (60%) create mode 100644 client/app/chat/logic.go rename client/app/{chat_state.go => chat/state.go} (64%) delete mode 100644 client/app/chat_logic.go diff --git a/client/app/app.go b/client/app/app.go index e45ef68..a32700f 100644 --- a/client/app/app.go +++ b/client/app/app.go @@ -2,8 +2,11 @@ package app import ( "github.com/gabbla05/KittyProtocol/client/api" + "github.com/gabbla05/KittyProtocol/client/app/chat" ) +// UI defines the minimal interface required by App. +// It allows App to remain UI-agnostic (CLI, GUI, Wails, etc.). type UI interface { ReadLine() string ReadSharedSecret() []byte @@ -12,40 +15,58 @@ type UI interface { Prompt() } +// App is the high-level application layer. +// It wires together: +// - KittyClient (transport + E2EE) +// - ChatLogic (chat operations) +// - ChatState (local chat state) +// - ChatEventBridge (incoming events) +// - UI (presentation layer) type App struct { client *api.KittyClient ui UI disconnected <-chan struct{} - chatState *ChatState - secrets *SecretStore + + chatState *chat.ChatState + chatLogic *chat.ChatLogic + chatBridge *chat.ChatEventBridge + + secrets *SecretStore } +// NewApp constructs a new application layer instance. func NewApp(c *api.KittyClient, ui UI, disconnected <-chan struct{}) *App { + state := chat.NewChatState() + a := &App{ client: c, ui: ui, disconnected: disconnected, - chatState: NewChatState(), - secrets: nil, + chatState: state, + chatLogic: chat.NewChatLogic(c, state), + chatBridge: chat.NewChatEventBridge(c, state), } - a.attachEventHandlers() - go a.handleChatEvents() + a.attachCoreEventHandlers() + + // Start chat event loop + go a.chatBridge.Run(func(msg string) { + a.ui.Printf("\n%s\n", msg) + a.ui.Prompt() + }) return a } -func (a *App) attachEventHandlers() { +func (a *App) attachCoreEventHandlers() { c := a.client // ERROR frame c.OnError(func(code, desc string) { - // Jeśli w trakcie aktywnego czatu dostaniemy ERR_15, - // potraktuj to jako „peer zniknął” i zamknij lokalnie czat. if code == "ERR_15" { if active, _ := a.chatState.IsActive(); active { a.chatState.EndChat() - a.ui.Printf("\n[CHAT] Czat zakończony (peer unavailable: %s).\n", desc) + a.ui.Printf("\n[CHAT] Chat ended (peer unavailable: %s).\n", desc) a.ui.Prompt() return } @@ -57,7 +78,8 @@ func (a *App) attachEventHandlers() { // STATUS_RES frame c.OnStatus(func(target, status string) { if target == "" && status == "no_target" { - a.ui.Printf("\n[CHAT] Czat zakończony.\n> ") + a.ui.Printf("\n[CHAT] Chat ended.\n") + a.ui.Prompt() return } a.ui.Printf("\n[STATUS] %s is %s\n", target, status) @@ -66,13 +88,35 @@ func (a *App) attachEventHandlers() { // Disconnect event c.OnDisconnected(func(err error) { - // Przy rozłączeniu zawsze czyścimy stan czatu lokalnie. a.chatState.EndChat() a.ui.Printf("\n[DISCONNECTED] %v\n", err) a.ui.Prompt() }) } +// Disconnected returns a channel that is closed when the client disconnects. +func (a *App) Disconnected() <-chan struct{} { + return a.disconnected +} + +// Client exposes the underlying KittyClient for low-level operations +// (e.g. SendBye, SendGetStatus, SetSharedSecretForPeer). +func (a *App) Client() *api.KittyClient { + return a.client +} + +// ChatState exposes the chat state for UI (e.g. to check active chat on /logout). +func (a *App) ChatState() *chat.ChatState { + return a.chatState +} + +// Secrets returns the secret store used for persisting shared secrets. +func (a *App) Secrets() *SecretStore { + return a.secrets +} + +// InitSecretStoreForUser initializes the secret store for a given user and +// loads all stored shared secrets into KittyClient. func (a *App) InitSecretStoreForUser(username string, masterKey []byte) { path := PathForUser(username) a.secrets = NewSecretStore(path, masterKey) @@ -82,42 +126,24 @@ func (a *App) InitSecretStoreForUser(username string, masterKey []byte) { } } -func (a *App) Client() *api.KittyClient { return a.client } -func (a *App) Secrets() *SecretStore { return a.secrets } -func (a *App) Disconnected() <-chan struct{} { return a.disconnected } +// High-level chat operations — thin wrappers delegating to ChatLogic. -// Udostępniamy ChatState dla UI (np. do obsługi /logout). -func (a *App) ChatState() *ChatState { - return a.chatState +func (a *App) StartChatRequest(target string) error { + return a.chatLogic.StartChatRequest(target) } -func (a *App) handleChatEvents() { - for { - select { - case ev := <-a.client.ChatRequestEvents(): - a.chatState.SetPendingRequest(ev.From) - a.ui.Printf("\n[CHAT] %s chce z Tobą rozmawiać. Użyj /accept %s lub /refuse %s\n", - ev.From, ev.From, ev.From) - a.ui.Prompt() - - case ev := <-a.client.ChatAcceptEvents(): - a.chatState.SetActive(ev.From) - a.ui.Printf("\n[CHAT] %s zaakceptował czat.\n", ev.From) - a.ui.Prompt() +func (a *App) AcceptChat(from string) error { + return a.chatLogic.AcceptChat(from) +} - case ev := <-a.client.ChatRefuseEvents(): - a.chatState.ClearPendingRequest() - a.ui.Printf("\n[CHAT] %s odrzucił czat: %s\n", ev.From, ev.Reason) - a.ui.Prompt() +func (a *App) RefuseChat(from, reason string) error { + return a.chatLogic.RefuseChat(from, reason) +} - case ev := <-a.client.ChatEndEvents(): - a.chatState.EndChat() - a.ui.Printf("\n[CHAT] %s zakończył czat: %s\n", ev.From, ev.Reason) - a.ui.Prompt() +func (a *App) EndChat(reason string) error { + return a.chatLogic.EndChat(reason) +} - case ev := <-a.client.ChatMessageEvents(): - a.ui.Printf("\n[%s] %s\n", ev.From, ev.Text) - a.ui.Prompt() - } - } +func (a *App) SendTextMessage(text string) error { + return a.chatLogic.SendTextMessage(text) } diff --git a/client/app/chat/events.go b/client/app/chat/events.go new file mode 100644 index 0000000..fa2d0ed --- /dev/null +++ b/client/app/chat/events.go @@ -0,0 +1,43 @@ +package chat + +import ( + "github.com/gabbla05/KittyProtocol/client/api" +) + +// ChatEventBridge connects KittyClient event channels with ChatState. +// It is UI-agnostic; UI is notified via callbacks in App. +type ChatEventBridge struct { + client *api.KittyClient + chatState *ChatState +} + +// NewChatEventBridge constructs a new event bridge. +func NewChatEventBridge(client *api.KittyClient, state *ChatState) *ChatEventBridge { + return &ChatEventBridge{client: client, chatState: state} +} + +// Run starts a blocking loop that processes chat-related events. +func (b *ChatEventBridge) Run(onEvent func(msg string)) { + for { + select { + case ev := <-b.client.ChatRequestEvents(): + b.chatState.SetPendingRequest(ev.From) + onEvent("[CHAT] " + ev.From + " wants to chat with you.") + + case ev := <-b.client.ChatAcceptEvents(): + b.chatState.SetActive(ev.From) + onEvent("[CHAT] " + ev.From + " accepted the chat.") + + case ev := <-b.client.ChatRefuseEvents(): + b.chatState.ClearPendingRequest() + onEvent("[CHAT] " + ev.From + " refused the chat: " + ev.Reason) + + case ev := <-b.client.ChatEndEvents(): + b.chatState.EndChat() + onEvent("[CHAT] " + ev.From + " ended the chat: " + ev.Reason) + + case ev := <-b.client.ChatMessageEvents(): + onEvent("[" + ev.From + "] " + ev.Text) + } + } +} diff --git a/client/app/chat_frames.go b/client/app/chat/frames.go similarity index 60% rename from client/app/chat_frames.go rename to client/app/chat/frames.go index aebfb50..0bb523e 100644 --- a/client/app/chat_frames.go +++ b/client/app/chat/frames.go @@ -1,8 +1,9 @@ -package app +package chat import "encoding/json" -// App frames types +// ChatFrameType enumerates all application-level chat control frames. +// These frames are encrypted and transported inside DATA frames at the API layer. type ChatFrameType string const ( @@ -13,7 +14,8 @@ const ( TextMessage ChatFrameType = "TEXT_MESSAGE" ) -// General app frame struccture +// ChatFrame is the generic application-level chat frame. +// It is serialized to JSON and encrypted by KittyClient before sending. type ChatFrame struct { Type ChatFrameType `json:"type"` From string `json:"from"` @@ -21,15 +23,11 @@ type ChatFrame struct { Payload json.RawMessage `json:"payload,omitempty"` } -// Payloady dla poszczególnych ramek +// Payload structures for each chat frame type. -type ChatRequestPayload struct { - // Na razie puste — w przyszłości można dodać np. "topic" -} +type ChatRequestPayload struct{} -type ChatAcceptPayload struct { - // Też puste — można dodać np. "sessionID" -} +type ChatAcceptPayload struct{} type ChatRefusePayload struct { Reason string `json:"reason,omitempty"` @@ -43,54 +41,29 @@ type TextMessagePayload struct { Text string `json:"text"` } -// Helpery do tworzenia ramek +// Constructors for each chat frame type. func NewChatRequest(from, to string) ChatFrame { payload, _ := json.Marshal(ChatRequestPayload{}) - return ChatFrame{ - Type: ChatRequest, - From: from, - To: to, - Payload: payload, - } + return ChatFrame{Type: ChatRequest, From: from, To: to, Payload: payload} } func NewChatAccept(from, to string) ChatFrame { payload, _ := json.Marshal(ChatAcceptPayload{}) - return ChatFrame{ - Type: ChatAccept, - From: from, - To: to, - Payload: payload, - } + return ChatFrame{Type: ChatAccept, From: from, To: to, Payload: payload} } func NewChatRefuse(from, to, reason string) ChatFrame { payload, _ := json.Marshal(ChatRefusePayload{Reason: reason}) - return ChatFrame{ - Type: ChatRefuse, - From: from, - To: to, - Payload: payload, - } + return ChatFrame{Type: ChatRefuse, From: from, To: to, Payload: payload} } func NewChatEnd(from, to, reason string) ChatFrame { payload, _ := json.Marshal(ChatEndPayload{Reason: reason}) - return ChatFrame{ - Type: ChatEnd, - From: from, - To: to, - Payload: payload, - } + return ChatFrame{Type: ChatEnd, From: from, To: to, Payload: payload} } func NewTextMessage(from, to, text string) ChatFrame { payload, _ := json.Marshal(TextMessagePayload{Text: text}) - return ChatFrame{ - Type: TextMessage, - From: from, - To: to, - Payload: payload, - } + return ChatFrame{Type: TextMessage, From: from, To: to, Payload: payload} } diff --git a/client/app/chat/logic.go b/client/app/chat/logic.go new file mode 100644 index 0000000..e23afad --- /dev/null +++ b/client/app/chat/logic.go @@ -0,0 +1,141 @@ +package chat + +import ( + "encoding/json" + "errors" + "fmt" + "strings" + + "github.com/gabbla05/KittyProtocol/client/api" +) + +// ChatLogic contains all application-level chat operations. +// It is UI-agnostic and uses KittyClient only for encrypted transport. +type ChatLogic struct { + client *api.KittyClient + chatState *ChatState +} + +// NewChatLogic constructs a new chat logic layer. +func NewChatLogic(client *api.KittyClient, state *ChatState) *ChatLogic { + return &ChatLogic{client: client, chatState: state} +} + +// StartChatRequest initiates a chat session with a peer. +func (l *ChatLogic) StartChatRequest(target string) error { + if target == "" { + return errors.New("target cannot be empty") + } + if target == l.client.User() { + return errors.New("cannot chat with yourself") + } + if active, peer := l.chatState.IsActive(); active { + return fmt.Errorf("chat already active with %s", peer) + } + if pending, from := l.chatState.HasAnyPending(); pending { + return fmt.Errorf("you have a pending request from %s — resolve it first", from) + } + if !l.client.HasSharedSecret(target) { + return fmt.Errorf("no shared secret for %s", target) + } + + frame := NewChatRequest(l.client.User(), target) + return l.sendFrame(frame) +} + +// AcceptChat accepts a pending chat request. +func (l *ChatLogic) AcceptChat(from string) error { + if from == "" { + return errors.New("from cannot be empty") + } + if from == l.client.User() { + return errors.New("cannot chat with yourself") + } + if !l.chatState.HasPendingFrom(from) { + return fmt.Errorf("no pending chat request from %s", from) + } + + frame := NewChatAccept(l.client.User(), from) + if err := l.sendFrame(frame); err != nil { + return err + } + + l.chatState.SetActive(from) + return nil +} + +// RefuseChat rejects a pending chat request. +func (l *ChatLogic) RefuseChat(from, reason string) error { + if from == "" { + return errors.New("from cannot be empty") + } + if from == l.client.User() { + return errors.New("cannot chat with yourself") + } + if !l.chatState.HasPendingFrom(from) { + return fmt.Errorf("no pending chat request from %s", from) + } + + frame := NewChatRefuse(l.client.User(), from, reason) + if err := l.sendFrame(frame); err != nil { + return err + } + + l.chatState.ClearPendingRequest() + return nil +} + +// EndChat terminates an active chat session. +func (l *ChatLogic) EndChat(reason string) error { + active, peer := l.chatState.IsActive() + if !active { + return errors.New("no active chat") + } + if peer == "" { + return errors.New("no active target") + } + if peer == l.client.User() { + return errors.New("cannot chat with yourself") + } + + frame := NewChatEnd(l.client.User(), peer, reason) + if err := l.sendFrame(frame); err != nil { + return err + } + + l.chatState.EndChat() + return nil +} + +// SendTextMessage sends a text message inside an active chat session. +func (l *ChatLogic) SendTextMessage(text string) error { + if text == "" { + return errors.New("text cannot be empty") + } + + active, peer := l.chatState.IsActive() + if !active { + return errors.New("chat not active") + } + if peer == "" { + return errors.New("no active target") + } + if peer == l.client.User() { + return errors.New("cannot chat with yourself") + } + + frame := NewTextMessage(l.client.User(), peer, text) + return l.sendFrame(frame) +} + +// sendFrame serializes and sends a chat frame via encrypted API transport. +func (l *ChatLogic) sendFrame(frame ChatFrame) error { + frame.To = strings.ToLower(strings.TrimSpace(frame.To)) + + data, err := json.Marshal(frame) + if err != nil { + return fmt.Errorf("marshal chat frame: %w", err) + } + + return l.client.SendAppFrameEncrypted(frame.To, data) +} diff --git a/client/app/chat_state.go b/client/app/chat/state.go similarity index 64% rename from client/app/chat_state.go rename to client/app/chat/state.go index 7aa6afa..a438ae4 100644 --- a/client/app/chat_state.go +++ b/client/app/chat/state.go @@ -1,7 +1,9 @@ -package app +package chat import "sync" +// ChatState tracks the local chat session state. +// It is UI-facing and independent from the protocol state machine. type ChatState struct { mu sync.Mutex @@ -12,66 +14,62 @@ type ChatState struct { PendingFrom string } +// NewChatState constructs a new empty chat state. func NewChatState() *ChatState { return &ChatState{} } -// Incoming CHAT_REQUEST +// SetPendingRequest marks that a peer has requested a chat session. func (s *ChatState) SetPendingRequest(from string) { s.mu.Lock() defer s.mu.Unlock() - s.Pending = true s.PendingFrom = from } -// User accepted chat +// SetActive marks that a chat session with the given peer is active. func (s *ChatState) SetActive(peer string) { s.mu.Lock() defer s.mu.Unlock() - s.Active = true s.ActiveTarget = peer - s.Pending = false s.PendingFrom = "" } -// User refused chat +// ClearPendingRequest removes any pending chat request. func (s *ChatState) ClearPendingRequest() { s.mu.Lock() defer s.mu.Unlock() - s.Pending = false s.PendingFrom = "" } -// Chat ended +// EndChat resets the chat state to idle. func (s *ChatState) EndChat() { s.mu.Lock() defer s.mu.Unlock() - s.Active = false s.ActiveTarget = "" - s.Pending = false s.PendingFrom = "" } -// --- bezpieczne gettery / checki --- - +// IsActive returns whether a chat is active and with whom. func (s *ChatState) IsActive() (bool, string) { s.mu.Lock() defer s.mu.Unlock() return s.Active, s.ActiveTarget } +// HasPendingFrom returns true if there is a pending request from the given user. func (s *ChatState) HasPendingFrom(user string) bool { s.mu.Lock() defer s.mu.Unlock() return s.Pending && s.PendingFrom == user } +// HasAnyPending returns whether any pending request exists. func (s *ChatState) HasAnyPending() (bool, string) { s.mu.Lock() defer s.mu.Unlock() diff --git a/client/app/chat_logic.go b/client/app/chat_logic.go deleted file mode 100644 index acba395..0000000 --- a/client/app/chat_logic.go +++ /dev/null @@ -1,137 +0,0 @@ -package app - -import ( - "encoding/json" - "errors" - "fmt" - "strings" -) - -func (a *App) StartChatRequest(target string) error { - if target == "" { - return errors.New("target cannot be empty") - } - - if target == a.client.User() { - return errors.New("cannot chat with yourself") - } - - if active, peer := a.chatState.IsActive(); active { - return fmt.Errorf("chat already active with %s", peer) - } - - if pending, from := a.chatState.HasAnyPending(); pending { - return fmt.Errorf("you have a pending request from %s — resolve it first", from) - } - - if !a.client.HasSharedSecret(target) { - return fmt.Errorf("no shared secret for %s", target) - } - - frame := NewChatRequest(a.client.User(), target) - return a.sendAppFrame(frame) -} - -func (a *App) AcceptChat(from string) error { - if from == "" { - return errors.New("from cannot be empty") - } - - if from == a.client.User() { - return errors.New("cannot chat with yourself") - } - - if !a.chatState.HasPendingFrom(from) { - return fmt.Errorf("no pending chat request from %s", from) - } - - frame := NewChatAccept(a.client.User(), from) - - if err := a.sendAppFrame(frame); err != nil { - return err - } - - // Responder wchodzi w stan Active lokalnie - a.chatState.SetActive(from) - return nil -} - -func (a *App) RefuseChat(from, reason string) error { - if from == "" { - return errors.New("from cannot be empty") - } - - if from == a.client.User() { - return errors.New("cannot chat with yourself") - } - - if !a.chatState.HasPendingFrom(from) { - return fmt.Errorf("no pending chat request from %s", from) - } - - frame := NewChatRefuse(a.client.User(), from, reason) - - if err := a.sendAppFrame(frame); err != nil { - return err - } - - a.chatState.ClearPendingRequest() - return nil -} - -func (a *App) EndChat(reason string) error { - active, peer := a.chatState.IsActive() - if !active { - return errors.New("no active chat") - } - if peer == "" { - return errors.New("no active target") - } - - if peer == a.client.User() { - return errors.New("cannot chat with yourself") - } - - frame := NewChatEnd(a.client.User(), peer, reason) - - if err := a.sendAppFrame(frame); err != nil { - return err - } - - a.chatState.EndChat() - return nil -} - -func (a *App) SendTextMessage(text string) error { - if text == "" { - return errors.New("text cannot be empty") - } - - active, peer := a.chatState.IsActive() - - if !active { - return errors.New("chat not active") - } - - if peer == "" { - return errors.New("no active target") - } - - if peer == a.client.User() { - return errors.New("cannot chat with yourself") - } - - frame := NewTextMessage(a.client.User(), peer, text) - return a.sendAppFrame(frame) -} - -func (a *App) sendAppFrame(frame ChatFrame) error { - frame.To = strings.ToLower(strings.TrimSpace(frame.To)) - - data, err := json.Marshal(frame) - if err != nil { - return fmt.Errorf("marshal chat frame: %w", err) - } - - return a.client.SendAppFrameEncrypted(frame.To, data) -} From 0add3af3294c364edb4e967b9f3cf5a4889884c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Brzezi=C5=84ski?= Date: Tue, 26 May 2026 15:12:59 +0000 Subject: [PATCH 42/44] secretstore refactor --- client/app/app.go | 9 +- client/app/secret_store.go | 239 ------------------------------- client/app/secretstore/crypto.go | 80 +++++++++++ client/app/secretstore/disk.go | 41 ++++++ client/app/secretstore/errors.go | 11 ++ client/app/secretstore/path.go | 16 +++ client/app/secretstore/store.go | 128 +++++++++++++++++ 7 files changed, 281 insertions(+), 243 deletions(-) delete mode 100644 client/app/secret_store.go create mode 100644 client/app/secretstore/crypto.go create mode 100644 client/app/secretstore/disk.go create mode 100644 client/app/secretstore/errors.go create mode 100644 client/app/secretstore/path.go create mode 100644 client/app/secretstore/store.go diff --git a/client/app/app.go b/client/app/app.go index a32700f..17a1e5f 100644 --- a/client/app/app.go +++ b/client/app/app.go @@ -3,6 +3,7 @@ package app import ( "github.com/gabbla05/KittyProtocol/client/api" "github.com/gabbla05/KittyProtocol/client/app/chat" + "github.com/gabbla05/KittyProtocol/client/app/secretstore" ) // UI defines the minimal interface required by App. @@ -31,7 +32,7 @@ type App struct { chatLogic *chat.ChatLogic chatBridge *chat.ChatEventBridge - secrets *SecretStore + secrets *secretstore.SecretStore } // NewApp constructs a new application layer instance. @@ -111,15 +112,15 @@ func (a *App) ChatState() *chat.ChatState { } // Secrets returns the secret store used for persisting shared secrets. -func (a *App) Secrets() *SecretStore { +func (a *App) Secrets() *secretstore.SecretStore { return a.secrets } // InitSecretStoreForUser initializes the secret store for a given user and // loads all stored shared secrets into KittyClient. func (a *App) InitSecretStoreForUser(username string, masterKey []byte) { - path := PathForUser(username) - a.secrets = NewSecretStore(path, masterKey) + path := secretstore.PathForUser(username) + a.secrets = secretstore.NewSecretStore(path, masterKey) for peer, secret := range a.secrets.All() { _ = a.client.SetSharedSecretForPeer(peer, secret) diff --git a/client/app/secret_store.go b/client/app/secret_store.go deleted file mode 100644 index 620d2d6..0000000 --- a/client/app/secret_store.go +++ /dev/null @@ -1,239 +0,0 @@ -package app - -import ( - "crypto/aes" - "crypto/cipher" - "crypto/rand" - "crypto/sha256" - "encoding/base64" - "encoding/json" - "errors" - "io" - "os" - "path/filepath" - "sync" -) - -// SecretStore manages per-peer shared secrets persisted on disk. -// Each Kitty user has its own directory: ~/.kitty//secrets.json.enc -// -// Plik na dysku jest CAŁY zaszyfrowany AES-GCM kluczem wyprowadzonym -// z masterKey (np. hasła użytkownika). -type SecretStore struct { - mu sync.Mutex - path string - masterKey []byte - secrets map[string][]byte -} - -type diskSecrets struct { - Peers map[string]string `json:"peers"` // peer -> base64(secret) -} - -// deriveKey normalizuje masterKey do 32 bajtów (AES-256) przez SHA-256. -// Jeśli masterKey jest hasłem, to jest to prosty KDF. -// W przyszłości można to podmienić na PBKDF2/Argon2. -func deriveKey(masterKey []byte) []byte { - sum := sha256.Sum256(masterKey) - return sum[:] -} - -// encrypt encryptuje plaintext przy użyciu AES-GCM(masterKey). -// Zwraca: base64( nonce || ciphertext ). -func encrypt(masterKey, plaintext []byte) (string, error) { - if len(masterKey) == 0 { - return "", errors.New("master key is empty") - } - - key := deriveKey(masterKey) - - block, err := aes.NewCipher(key) - if err != nil { - return "", err - } - - gcm, err := cipher.NewGCM(block) - if err != nil { - return "", err - } - - nonce := make([]byte, gcm.NonceSize()) - if _, err := io.ReadFull(rand.Reader, nonce); err != nil { - return "", err - } - - ciphertext := gcm.Seal(nil, nonce, plaintext, nil) - - out := append(nonce, ciphertext...) - return base64.StdEncoding.EncodeToString(out), nil -} - -// decrypt odszyfrowuje base64( nonce || ciphertext ) przy użyciu AES-GCM(masterKey). -func decrypt(masterKey []byte, enc string) ([]byte, error) { - if len(masterKey) == 0 { - return nil, errors.New("master key is empty") - } - - raw, err := base64.StdEncoding.DecodeString(enc) - if err != nil { - return nil, err - } - - key := deriveKey(masterKey) - - block, err := aes.NewCipher(key) - if err != nil { - return nil, err - } - - gcm, err := cipher.NewGCM(block) - if err != nil { - return nil, err - } - - if len(raw) < gcm.NonceSize() { - return nil, errors.New("ciphertext too short") - } - - nonce := raw[:gcm.NonceSize()] - ciphertext := raw[gcm.NonceSize():] - - plaintext, err := gcm.Open(nil, nonce, ciphertext, nil) - if err != nil { - return nil, err - } - return plaintext, nil -} - -// NewSecretStore creates a SecretStore bound to the given file path. -// masterKey musi być stały dla danego użytkownika (np. hasło logowania). -// Jeśli plik istnieje, jest odszyfrowywany; w przeciwnym razie tworzony jest pusty store. -func NewSecretStore(path string, masterKey []byte) *SecretStore { - s := &SecretStore{ - path: path, - masterKey: append([]byte(nil), masterKey...), - secrets: make(map[string][]byte), - } - _ = s.load() - return s -} - -// PathForUser returns ~/.kitty//secrets.json.enc -func PathForUser(kittyUser string) string { - home, err := os.UserHomeDir() - if err != nil || home == "" { - return filepath.Join(".", "kitty", kittyUser, "secrets.json.enc") - } - return filepath.Join(home, ".kitty", kittyUser, "secrets.json.enc") -} - -func (s *SecretStore) Get(peer string) ([]byte, bool) { - s.mu.Lock() - defer s.mu.Unlock() - - secret, ok := s.secrets[peer] - if !ok { - return nil, false - } - out := make([]byte, len(secret)) - copy(out, secret) - return out, true -} - -func (s *SecretStore) Set(peer string, secret []byte) error { - if peer == "" { - return errors.New("peer cannot be empty") - } - if len(secret) == 0 { - return errors.New("secret cannot be empty") - } - - s.mu.Lock() - defer s.mu.Unlock() - - buf := make([]byte, len(secret)) - copy(buf, secret) - s.secrets[peer] = buf - - return s.saveLocked() -} - -func (s *SecretStore) load() error { - s.mu.Lock() - defer s.mu.Unlock() - - data, err := os.ReadFile(s.path) - if err != nil { - if os.IsNotExist(err) { - return nil - } - return err - } - - // odszyfruj cały plik - plaintext, err := decrypt(s.masterKey, string(data)) - if err != nil { - return err - } - - var ds diskSecrets - if err := json.Unmarshal(plaintext, &ds); err != nil { - return err - } - - s.secrets = make(map[string][]byte, len(ds.Peers)) - for peer, b64 := range ds.Peers { - raw, err := base64.StdEncoding.DecodeString(b64) - if err != nil { - continue - } - s.secrets[peer] = raw - } - - return nil -} - -func (s *SecretStore) saveLocked() error { - dir := filepath.Dir(s.path) - - if err := os.MkdirAll(dir, 0o700); err != nil { - return err - } - - ds := diskSecrets{ - Peers: make(map[string]string, len(s.secrets)), - } - for peer, secret := range s.secrets { - ds.Peers[peer] = base64.StdEncoding.EncodeToString(secret) - } - - plaintext, err := json.MarshalIndent(ds, "", " ") - if err != nil { - return err - } - - enc, err := encrypt(s.masterKey, plaintext) - if err != nil { - return err - } - - tmp := s.path + ".tmp" - if err := os.WriteFile(tmp, []byte(enc), 0o600); err != nil { - return err - } - - return os.Rename(tmp, s.path) -} - -func (s *SecretStore) All() map[string][]byte { - s.mu.Lock() - defer s.mu.Unlock() - - out := make(map[string][]byte, len(s.secrets)) - for k, v := range s.secrets { - buf := make([]byte, len(v)) - copy(buf, v) - out[k] = buf - } - return out -} diff --git a/client/app/secretstore/crypto.go b/client/app/secretstore/crypto.go new file mode 100644 index 0000000..90710f4 --- /dev/null +++ b/client/app/secretstore/crypto.go @@ -0,0 +1,80 @@ +package secretstore + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "io" +) + +// deriveKey normalizes masterKey to 32 bytes (AES‑256) using SHA‑256. +// In the future this can be replaced with PBKDF2/Argon2 without changing callers. +func deriveKey(masterKey []byte) []byte { + sum := sha256.Sum256(masterKey) + return sum[:] +} + +// encrypt encrypts plaintext using AES‑GCM(deriveKey(masterKey)). +// Returns base64(nonce || ciphertext). +func encrypt(masterKey, plaintext []byte) (string, error) { + if len(masterKey) == 0 { + return "", ErrEmptyMasterKey + } + + key := deriveKey(masterKey) + + block, err := aes.NewCipher(key) + if err != nil { + return "", err + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + + nonce := make([]byte, gcm.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return "", err + } + + ciphertext := gcm.Seal(nil, nonce, plaintext, nil) + out := append(nonce, ciphertext...) + + return base64.StdEncoding.EncodeToString(out), nil +} + +// decrypt decrypts base64(nonce || ciphertext) using AES‑GCM(deriveKey(masterKey)). +func decrypt(masterKey []byte, enc string) ([]byte, error) { + if len(masterKey) == 0 { + return nil, ErrEmptyMasterKey + } + + raw, err := base64.StdEncoding.DecodeString(enc) + if err != nil { + return nil, err + } + + key := deriveKey(masterKey) + + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + + if len(raw) < gcm.NonceSize() { + return nil, ErrShortCipher + } + + nonce := raw[:gcm.NonceSize()] + ciphertext := raw[gcm.NonceSize():] + + return gcm.Open(nil, nonce, ciphertext, nil) +} diff --git a/client/app/secretstore/disk.go b/client/app/secretstore/disk.go new file mode 100644 index 0000000..84ed873 --- /dev/null +++ b/client/app/secretstore/disk.go @@ -0,0 +1,41 @@ +package secretstore + +import ( + "encoding/base64" + "encoding/json" +) + +// diskSecrets is the on‑disk representation of secrets. +// Each secret is base64‑encoded to keep the JSON printable. +type diskSecrets struct { + Peers map[string]string `json:"peers"` // peer -> base64(secret) +} + +// marshalDisk converts the in‑memory map into JSON plaintext. +func marshalDisk(secrets map[string][]byte) ([]byte, error) { + ds := diskSecrets{ + Peers: make(map[string]string, len(secrets)), + } + for peer, secret := range secrets { + ds.Peers[peer] = base64.StdEncoding.EncodeToString(secret) + } + return json.MarshalIndent(ds, "", " ") +} + +// unmarshalDisk parses JSON plaintext into an in‑memory map. +func unmarshalDisk(data []byte) (map[string][]byte, error) { + var ds diskSecrets + if err := json.Unmarshal(data, &ds); err != nil { + return nil, err + } + + out := make(map[string][]byte, len(ds.Peers)) + for peer, b64 := range ds.Peers { + raw, err := base64.StdEncoding.DecodeString(b64) + if err != nil { + continue // skip malformed entries + } + out[peer] = raw + } + return out, nil +} diff --git a/client/app/secretstore/errors.go b/client/app/secretstore/errors.go new file mode 100644 index 0000000..63a8a7d --- /dev/null +++ b/client/app/secretstore/errors.go @@ -0,0 +1,11 @@ +package secretstore + +import "errors" + +// Package‑level errors used across the secret store implementation. +var ( + ErrEmptyMasterKey = errors.New("master key is empty") + ErrEmptyPeer = errors.New("peer cannot be empty") + ErrEmptySecret = errors.New("secret cannot be empty") + ErrShortCipher = errors.New("ciphertext too short") +) diff --git a/client/app/secretstore/path.go b/client/app/secretstore/path.go new file mode 100644 index 0000000..1e7c7ac --- /dev/null +++ b/client/app/secretstore/path.go @@ -0,0 +1,16 @@ +package secretstore + +import ( + "os" + "path/filepath" +) + +// PathForUser returns ~/.kitty//secrets.json.enc +// or ./kitty//secrets.json.enc if $HOME is unavailable. +func PathForUser(kittyUser string) string { + home, err := os.UserHomeDir() + if err != nil || home == "" { + return filepath.Join(".", "kitty", kittyUser, "secrets.json.enc") + } + return filepath.Join(home, ".kitty", kittyUser, "secrets.json.enc") +} diff --git a/client/app/secretstore/store.go b/client/app/secretstore/store.go new file mode 100644 index 0000000..e1100e5 --- /dev/null +++ b/client/app/secretstore/store.go @@ -0,0 +1,128 @@ +package secretstore + +import ( + "os" + "path/filepath" + "sync" +) + +// SecretStore manages per‑peer shared secrets persisted on disk. +// It is safe for concurrent use. +type SecretStore struct { + mu sync.Mutex + path string + masterKey []byte + secrets map[string][]byte +} + +// NewSecretStore creates a SecretStore bound to the given file path. +// If the file exists, it is decrypted; otherwise an empty store is created. +func NewSecretStore(path string, masterKey []byte) *SecretStore { + s := &SecretStore{ + path: path, + masterKey: append([]byte(nil), masterKey...), // defensive copy + secrets: make(map[string][]byte), + } + _ = s.load() + return s +} + +// Get returns a copy of the secret for a given peer. +func (s *SecretStore) Get(peer string) ([]byte, bool) { + s.mu.Lock() + defer s.mu.Unlock() + + secret, ok := s.secrets[peer] + if !ok { + return nil, false + } + out := make([]byte, len(secret)) + copy(out, secret) + return out, true +} + +// Set stores/updates the secret for a given peer and persists the file. +func (s *SecretStore) Set(peer string, secret []byte) error { + if peer == "" { + return ErrEmptyPeer + } + if len(secret) == 0 { + return ErrEmptySecret + } + + s.mu.Lock() + defer s.mu.Unlock() + + buf := make([]byte, len(secret)) + copy(buf, secret) + s.secrets[peer] = buf + + return s.saveLocked() +} + +// All returns a deep copy of all secrets. +func (s *SecretStore) All() map[string][]byte { + s.mu.Lock() + defer s.mu.Unlock() + + out := make(map[string][]byte, len(s.secrets)) + for k, v := range s.secrets { + buf := make([]byte, len(v)) + copy(buf, v) + out[k] = buf + } + return out +} + +// load decrypts and loads secrets from disk. +func (s *SecretStore) load() error { + s.mu.Lock() + defer s.mu.Unlock() + + data, err := os.ReadFile(s.path) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + + plaintext, err := decrypt(s.masterKey, string(data)) + if err != nil { + return err + } + + m, err := unmarshalDisk(plaintext) + if err != nil { + return err + } + + s.secrets = m + return nil +} + +// saveLocked serializes and encrypts the current secrets map to disk. +// Caller must hold s.mu. +func (s *SecretStore) saveLocked() error { + dir := filepath.Dir(s.path) + if err := os.MkdirAll(dir, 0o700); err != nil { + return err + } + + plaintext, err := marshalDisk(s.secrets) + if err != nil { + return err + } + + enc, err := encrypt(s.masterKey, plaintext) + if err != nil { + return err + } + + tmp := s.path + ".tmp" + if err := os.WriteFile(tmp, []byte(enc), 0o600); err != nil { + return err + } + + return os.Rename(tmp, s.path) +} From 193b17b1986fdb4f0a6e451ee9d3a702a6cacb16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Brzezi=C5=84ski?= Date: Tue, 26 May 2026 18:40:34 +0000 Subject: [PATCH 43/44] ui_cli refactor --- client/app/truncate.go | 18 ------ client/app/truncate_test.go | 25 -------- client/ui_cli/ack_handlers.go | 17 +++++ client/ui_cli/auth_flow.go | 115 +++++++++++++++++++--------------- client/ui_cli/banner.go | 32 +++++----- client/ui_cli/colors.go | 16 +++++ client/ui_cli/input.go | 45 +++++++++++++ client/ui_cli/logger.go | 3 + client/ui_cli/menu.go | 22 +++---- client/ui_cli/output.go | 23 +++++++ client/ui_cli/ui.go | 24 +++++++ client/ui_cli/ui_cli.go | 112 --------------------------------- 12 files changed, 219 insertions(+), 233 deletions(-) delete mode 100644 client/app/truncate.go delete mode 100644 client/app/truncate_test.go create mode 100644 client/ui_cli/ack_handlers.go create mode 100644 client/ui_cli/colors.go create mode 100644 client/ui_cli/input.go create mode 100644 client/ui_cli/output.go create mode 100644 client/ui_cli/ui.go delete mode 100644 client/ui_cli/ui_cli.go diff --git a/client/app/truncate.go b/client/app/truncate.go deleted file mode 100644 index e85d4e0..0000000 --- a/client/app/truncate.go +++ /dev/null @@ -1,18 +0,0 @@ -package app - -// truncate.go -// Application-level helper for enforcing plaintext size limits -// before encryption and transmission. - -// MaxPlaintextSize is a conservative limit for plaintext message length. -// It is chosen to stay safely below the encrypted payload limit after AEAD overhead. -const MaxPlaintextSize = 1500 - -// TruncateMessage trims the input string so that it does not exceed MaxPlaintextSize. -// It returns the truncated string. No logging is performed here to keep the utility pure. -func TruncateMessage(input string) string { - if len(input) > MaxPlaintextSize { - return input[:MaxPlaintextSize] - } - return input -} diff --git a/client/app/truncate_test.go b/client/app/truncate_test.go deleted file mode 100644 index 31b6927..0000000 --- a/client/app/truncate_test.go +++ /dev/null @@ -1,25 +0,0 @@ -package app - -import "testing" - -// Test that long messages are truncated. -func TestTruncateMessageLong(t *testing.T) { - input := make([]byte, MaxPlaintextSize+100) - for i := range input { - input[i] = 'A' - } - - out := TruncateMessage(string(input)) - if len(out) != MaxPlaintextSize { - t.Fatalf("expected truncated length %d, got %d", MaxPlaintextSize, len(out)) - } -} - -// Test that short messages are unchanged. -func TestTruncateMessageShort(t *testing.T) { - input := "hello" - out := TruncateMessage(input) - if out != input { - t.Fatalf("expected unchanged message") - } -} diff --git a/client/ui_cli/ack_handlers.go b/client/ui_cli/ack_handlers.go new file mode 100644 index 0000000..8a6a99d --- /dev/null +++ b/client/ui_cli/ack_handlers.go @@ -0,0 +1,17 @@ +package ui_cli + +import "fmt" + +// OnDelivered is called when the AckManager reports successful delivery. +// This is UI-only feedback and does not affect application logic. +func (ui *CliUI) OnDelivered(msgID int64) { + fmt.Printf(ColorGreen+"\n[Delivered] msg_id=%d\n"+ColorReset, msgID) + ui.Prompt() +} + +// OnTimeout is called when the AckManager reports a delivery timeout. +// This is UI-only feedback and does not affect application logic. +func (ui *CliUI) OnTimeout(msgID int64) { + fmt.Printf(ColorRed+"\n[Timeout] msg_id=%d not delivered\n"+ColorReset, msgID) + ui.Prompt() +} diff --git a/client/ui_cli/auth_flow.go b/client/ui_cli/auth_flow.go index d9eb0dd..8ff8d47 100644 --- a/client/ui_cli/auth_flow.go +++ b/client/ui_cli/auth_flow.go @@ -8,18 +8,18 @@ import ( "github.com/gabbla05/KittyProtocol/client/api" ) +// ErrQuitRequested is returned when the user chooses /quit during auth flow. var ErrQuitRequested = errors.New("quit requested") -// Async AUTH/REGISTER flow +// authTimeout defines how long the UI waits for AUTH/REGISTER results. +const authTimeout = 5 * time.Second + +// RunAuthFlowAsync drives the interactive LOGIN/REGISTER flow. +// It is UI-only logic: no protocol state is mutated here. +// Returns the user's password (for secret store) or ErrQuitRequested. func (ui *CliUI) RunAuthFlowAsync(client *api.KittyClient) (string, error) { for { - ui.Println(ColorBlue + "\n ==================" + ColorReset) - ui.Println(ColorBlue + " | Wybierz opcję: |") - ui.Println(" | |") - ui.Println(" | " + ColorGreen + "->" + ColorReset + " /login " + ColorBlue + "|") - ui.Println(" | " + ColorGreen + "->" + ColorReset + " /register " + ColorBlue + "|") - ui.Println(" | " + ColorGreen + "->" + ColorReset + " /quit " + ColorBlue + "|") - ui.Println(" ==================\n" + ColorReset) + ui.printAuthMenu() ui.Prompt() cmd := strings.TrimSpace(ui.ReadLine()) @@ -30,57 +30,74 @@ func (ui *CliUI) RunAuthFlowAsync(client *api.KittyClient) (string, error) { client.Close() return "", ErrQuitRequested - // ---------------------------------------------------- - // REGISTER (async) - // ---------------------------------------------------- case "/register": - user, pass := ui.ReadCredentials() + if err := ui.handleRegister(client); err != nil { + ui.Println("[Client] REGISTER error:", err) + } - if err := client.SendRegister(user, pass); err != nil { - ui.Println("[Client] REGISTER send error:", err) + case "/login": + pass, err := ui.handleLogin(client) + if err != nil { + ui.Println("[Client] AUTH error:", err) continue } + return pass, nil - select { - case res := <-client.RegisterResult(): - if !res.OK { - ui.Println("[Client] REGISTER failed:", res.Error()) - continue - } - ui.Println("[Client] REGISTER OK — możesz się teraz zalogować.") + default: + ui.Println("Nieznana komenda.") + } + } +} - case <-time.After(5 * time.Second): - ui.Println("[Client] REGISTER timeout") - continue - } +// printAuthMenu prints the main AUTH/REGISTER menu. +func (ui *CliUI) printAuthMenu() { + ui.Println(ColorBlue + "\n ==================" + ColorReset) + ui.Println(ColorBlue + " | Wybierz opcję: |") + ui.Println(" | |") + ui.Println(" | " + ColorGreen + "->" + ColorReset + " /login " + ColorBlue + "|") + ui.Println(" | " + ColorGreen + "->" + ColorReset + " /register " + ColorBlue + "|") + ui.Println(" | " + ColorGreen + "->" + ColorReset + " /quit " + ColorBlue + "|") + ui.Println(" ==================\n" + ColorReset) +} - // ---------------------------------------------------- - // LOGIN (async) - // ---------------------------------------------------- - case "/login": - user, pass := ui.ReadCredentials() +// handleRegister performs the REGISTER flow. +func (ui *CliUI) handleRegister(client *api.KittyClient) error { + user, pass := ui.ReadCredentials() - if err := client.SendAuth(user, pass); err != nil { - ui.Println("[Client] AUTH send error:", err) - continue - } + if err := client.SendRegister(user, pass); err != nil { + return err + } - select { - case res := <-client.AuthResult(): - if !res.OK { - ui.Println("[Client] AUTH failed:", res.Error()) - continue - } - ui.Println("[Client] AUTH OK — zalogowano.") - return pass, nil - - case <-time.After(5 * time.Second): - ui.Println("[Client] AUTH timeout") - continue - } + select { + case res := <-client.RegisterResult(): + if !res.OK { + return res + } + ui.Println("[Client] REGISTER OK — możesz się teraz zalogować.") + return nil - default: - ui.Println("Nieznana komenda.") + case <-time.After(authTimeout): + return errors.New("REGISTER timeout") + } +} + +// handleLogin performs the AUTH flow and returns the password on success. +func (ui *CliUI) handleLogin(client *api.KittyClient) (string, error) { + user, pass := ui.ReadCredentials() + + if err := client.SendAuth(user, pass); err != nil { + return "", err + } + + select { + case res := <-client.AuthResult(): + if !res.OK { + return "", res } + ui.Println("[Client] AUTH OK — zalogowano.") + return pass, nil + + case <-time.After(authTimeout): + return "", errors.New("AUTH timeout") } } diff --git a/client/ui_cli/banner.go b/client/ui_cli/banner.go index 355fb20..b06d231 100644 --- a/client/ui_cli/banner.go +++ b/client/ui_cli/banner.go @@ -3,26 +3,22 @@ package ui_cli import "fmt" func PrintBanner() { - p1 := "\x1b[38;5;218m" - p2 := "\x1b[38;5;213m" - p3 := "\x1b[38;5;212m" - reset := "\x1b[0m" cat := []string{ - p1 + " \\`*-. ", - p1 + " ) _`-. ", - p1 + " ^ ^ . : `. . ", - p1 + "░█▄ ▄█░█▀▀░█▀█░█░░░█░ : _ ' \\ ", - p1 + "░█░▀░█░█▀▀░█░█░█▄▀▄█░ ; *` _. `*-._ ", - p2 + "░▀░░░▀░▀▀▀░▀▀▀░▀░░░▀░ `-.-' `-. ", - p2 + "░█▀▀░█▀▀░█▀▀░█▀█░█▀▀░█▀▀░█▀▄ ; ` `. ", - p3 + "░▀▀█░▀▀█░█▀▀░█░█░█░█░█▀▀░█▀▄ :. . \\ ", - p3 + "░▀▀▀░▀▀▀░▀▀▀░▀░▀░▀▀▀░▀▀▀░▀░▀ . \\ . : .-' . ", - p1 + "Powered by KITTYPROTOCOL ' `+.; ; ' : ", - p1 + " _ _ __ : ' | ; ;-.", - p1 + "__ _____ _ _ __(_)___ _ _ / | / \\ ; ' : :`-: _.`* ;", - p1 + "\\ V / -_) '_(_-< / _ \\ ' \\ | | _ | () | .*' / .*' ; .*`- +' `*'", - p1 + " \\_/\\___|_| /__/_\\___/_||_| |_| (_) \\__/ `*-* `*-* `*-*'" + reset, + ColorPink2 + " \\`*-. ", + ColorPink2 + " ) _`-. ", + ColorPink2 + " ^ ^ . : `. . ", + ColorPink2 + "░█▄ ▄█░█▀▀░█▀█░█░░░█░ : _ ' \\ ", + ColorPink2 + "░█░▀░█░█▀▀░█░█░█▄▀▄█░ ; *` _. `*-._ ", + ColorPink1 + "░▀░░░▀░▀▀▀░▀▀▀░▀░░░▀░ `-.-' `-. ", + ColorPink1 + "░█▀▀░█▀▀░█▀▀░█▀█░█▀▀░█▀▀░█▀▄ ; ` `. ", + ColorPink3 + "░▀▀█░▀▀█░█▀▀░█░█░█░█░█▀▀░█▀▄ :. . \\ ", + ColorPink3 + "░▀▀▀░▀▀▀░▀▀▀░▀░▀░▀▀▀░▀▀▀░▀░▀ . \\ . : .-' . ", + ColorPink2 + "Powered by KITTYPROTOCOL ' `+.; ; ' : ", + ColorPink2 + " _ _ __ : ' | ; ;-.", + ColorPink2 + "__ _____ _ _ __(_)___ _ _ / | / \\ ; ' : :`-: _.`* ;", + ColorPink2 + "\\ V / -_) '_(_-< / _ \\ ' \\ | | _ | () | .*' / .*' ; .*`- +' `*'", + ColorPink2 + " \\_/\\___|_| /__/_\\___/_||_| |_| (_) \\__/ `*-* `*-* `*-*'" + ColorReset, } for _, line := range cat { diff --git a/client/ui_cli/colors.go b/client/ui_cli/colors.go new file mode 100644 index 0000000..4bbc268 --- /dev/null +++ b/client/ui_cli/colors.go @@ -0,0 +1,16 @@ +package ui_cli + +// ANSI color codes used for CLI output. +// These are UI-only and do not leak into application logic. +const ( + ColorReset = "\033[0m" + ColorRed = "\033[31m" + ColorGreen = "\033[32m" + ColorBlue = "\033[34m" + ColorPink1 = "\x1b[38;5;213m" + ColorPink2 = "\x1b[38;5;218m" + ColorPink3 = "\x1b[38;5;212m" + ColorYellow = "\033[33m" + + PromptSymbol = ColorPink1 + "(=^._.^=) > " + ColorReset +) diff --git a/client/ui_cli/input.go b/client/ui_cli/input.go new file mode 100644 index 0000000..c4cac66 --- /dev/null +++ b/client/ui_cli/input.go @@ -0,0 +1,45 @@ +package ui_cli + +import ( + "fmt" + "os" + "strings" + + "golang.org/x/term" +) + +// ReadLine reads a single line from stdin and trims whitespace. +func (ui *CliUI) ReadLine() string { + line, _ := ui.reader.ReadString('\n') + return strings.TrimSpace(line) +} + +// ReadCredentials prompts the user for login and password. +// The password is read without echo using term.ReadPassword. +func (ui *CliUI) ReadCredentials() (string, string) { + fmt.Print(ColorBlue + " -> Login: " + ColorReset) + user, _ := ui.reader.ReadString('\n') + user = strings.TrimSpace(user) + + fmt.Print(ColorBlue + " -> Password: " + ColorReset) + bytePass, _ := term.ReadPassword(int(os.Stdin.Fd())) + fmt.Println() + pass := strings.TrimSpace(string(bytePass)) + + return user, pass +} + +// ReadSharedSecret prompts the user for an E2EE shared secret. +// It enforces non-empty input and loops until valid. +func (ui *CliUI) ReadSharedSecret() []byte { + for { + fmt.Print(ColorYellow + " -> Shared secret (K_AB): " + ColorReset) + secret, _ := ui.reader.ReadString('\n') + secret = strings.TrimSpace(secret) + + if secret != "" { + return []byte(secret) + } + fmt.Println(ColorRed + "[UI] Secret cannot be empty." + ColorReset) + } +} diff --git a/client/ui_cli/logger.go b/client/ui_cli/logger.go index 29d2fa5..4247289 100644 --- a/client/ui_cli/logger.go +++ b/client/ui_cli/logger.go @@ -6,8 +6,11 @@ import ( "github.com/gabbla05/KittyProtocol/client/api" ) +// CliLogger is a simple stdout logger used by the CLI UI. +// It implements api.Logger and is installed via api.SetLogger(). type CliLogger struct{} +// Log prints a formatted log message based on severity. func (CliLogger) Log(level api.LogLevel, msg string) { switch level { case api.LogError: diff --git a/client/ui_cli/menu.go b/client/ui_cli/menu.go index f2d1e6c..981c301 100644 --- a/client/ui_cli/menu.go +++ b/client/ui_cli/menu.go @@ -168,15 +168,15 @@ func (ui *CliUI) RunMainMenu(a *app.App) { } func (ui *CliUI) printMenu() { - ui.Println(ColorBlue + "\n ======================" + ColorReset) - ui.Println(ColorBlue + " | " + ColorGreen + "Dostępne komendy: " + ColorBlue + "|") - ui.Println(" | " + ColorGreen + "->" + ColorReset + " /status " + ColorBlue + "|") - ui.Println(" | " + ColorGreen + "->" + ColorReset + " /secret " + ColorBlue + "|") - ui.Println(" | " + ColorGreen + "->" + ColorReset + " /chat " + ColorBlue + "|") - ui.Println(" | " + ColorGreen + "->" + ColorReset + " /accept " + ColorBlue + "|") - ui.Println(" | " + ColorGreen + "->" + ColorReset + " /refuse " + ColorBlue + "|") - ui.Println(" | " + ColorGreen + "->" + ColorReset + " /msg " + ColorBlue + "|") - ui.Println(" | " + ColorGreen + "->" + ColorReset + " /end " + ColorBlue + "|") - ui.Println(" | " + ColorGreen + "->" + ColorReset + " /logout " + ColorBlue + "|") - ui.Println(ColorBlue + " ======================\n" + ColorReset) + ui.Println(ColorPink3 + "\n ======================" + ColorReset) + ui.Println(ColorPink3 + " | " + ColorGreen + "Dostępne komendy: " + ColorPink3 + "|") + ui.Println(" | " + ColorGreen + "->" + ColorReset + " /status " + ColorPink3 + "|") + ui.Println(" | " + ColorGreen + "->" + ColorReset + " /secret " + ColorPink3 + "|") + ui.Println(" | " + ColorGreen + "->" + ColorReset + " /chat " + ColorPink3 + "|") + ui.Println(" | " + ColorGreen + "->" + ColorReset + " /accept " + ColorPink3 + "|") + ui.Println(" | " + ColorGreen + "->" + ColorReset + " /refuse " + ColorPink3 + "|") + ui.Println(" | " + ColorGreen + "->" + ColorReset + " /msg " + ColorPink3 + "|") + ui.Println(" | " + ColorGreen + "->" + ColorReset + " /end " + ColorPink3 + "|") + ui.Println(" | " + ColorGreen + "->" + ColorReset + " /logout " + ColorPink3 + "|") + ui.Println(ColorPink3 + " ======================\n" + ColorReset) } diff --git a/client/ui_cli/output.go b/client/ui_cli/output.go new file mode 100644 index 0000000..fca2bfb --- /dev/null +++ b/client/ui_cli/output.go @@ -0,0 +1,23 @@ +package ui_cli + +import "fmt" + +// Prompt prints the standard CLI prompt symbol. +func (ui *CliUI) Prompt() { + fmt.Print(PromptSymbol) +} + +// Println prints a line to stdout. +func (ui *CliUI) Println(v ...any) { + fmt.Println(v...) +} + +// Print prints without newline. +func (ui *CliUI) Print(v ...any) { + fmt.Print(v...) +} + +// Printf prints formatted text. +func (ui *CliUI) Printf(format string, v ...any) { + fmt.Printf(format, v...) +} diff --git a/client/ui_cli/ui.go b/client/ui_cli/ui.go new file mode 100644 index 0000000..72d3ee2 --- /dev/null +++ b/client/ui_cli/ui.go @@ -0,0 +1,24 @@ +package ui_cli + +import ( + "bufio" + "os" + + "github.com/gabbla05/KittyProtocol/client/api" +) + +// CliUI implements the UI interface required by the App layer. +// It provides synchronous, blocking terminal input/output. +type CliUI struct { + client *api.KittyClient + reader *bufio.Reader +} + +// NewCliUI constructs a new CLI frontend bound to a KittyClient instance. +// The UI remains transport-agnostic; the client is used only for ACK callbacks. +func NewCliUI(c *api.KittyClient) *CliUI { + return &CliUI{ + client: c, + reader: bufio.NewReader(os.Stdin), + } +} diff --git a/client/ui_cli/ui_cli.go b/client/ui_cli/ui_cli.go deleted file mode 100644 index 3bda321..0000000 --- a/client/ui_cli/ui_cli.go +++ /dev/null @@ -1,112 +0,0 @@ -// ui_cli.go -// CLI implementation of the UI interface used by the App layer. -// This package contains only user interaction logic (stdin/stdout). -// No networking, cryptography or protocol logic is present here. - -package ui_cli - -import ( - "bufio" - "fmt" - "os" - "strings" - - "golang.org/x/term" - - "github.com/gabbla05/KittyProtocol/client/api" -) - -const ( - ColorReset = "\033[0m" - ColorRed = "\033[31m" - ColorGreen = "\033[32m" - ColorBlue = "\033[34m" - ColorPink = "\x1b[38;5;213m" - ColorYellow = "\033[33m" - - Prompt = ColorPink + "(=^._.^=) > " + ColorReset -) - -func (ui *CliUI) Prompt() { - fmt.Print(Prompt) -} - -// CliUI implements the UI interface required by the App layer. -type CliUI struct { - client *api.KittyClient - reader *bufio.Reader -} - -// NewCliUI creates a new CLI frontend bound to a KittyClient instance. -func NewCliUI(c *api.KittyClient) *CliUI { - return &CliUI{ - client: c, - reader: bufio.NewReader(os.Stdin), - } -} - -// --- UI interface methods --- - -// ReadLine reads a single line from stdin, trimming whitespace. -func (ui *CliUI) ReadLine() string { - line, _ := ui.reader.ReadString('\n') - return strings.TrimSpace(line) -} - -// Println prints a line to stdout. -func (ui *CliUI) Println(v ...any) { - fmt.Println(v...) -} - -// Print prints without newline. -func (ui *CliUI) Print(v ...any) { - fmt.Print(v...) -} - -// Printf prints formatted text. -func (ui *CliUI) Printf(format string, v ...any) { - fmt.Printf(format, v...) -} - -// --- Additional helpers used by App --- - -// ReadCredentials prompts the user for login and password. -func (ui *CliUI) ReadCredentials() (string, string) { - fmt.Print(ColorBlue + " -> Login: " + ColorReset) - user, _ := ui.reader.ReadString('\n') - user = strings.TrimSpace(user) - - fmt.Print(ColorBlue + " -> Hasło: " + ColorReset) - // czytamy hasło bez echa - bytePass, _ := term.ReadPassword(int(os.Stdin.Fd())) - fmt.Println() // nowa linia po wpisaniu hasła - pass := strings.TrimSpace(string(bytePass)) - - return user, pass -} - -// ReadSharedSecret prompts the user for the E2EE shared secret. -func (ui *CliUI) ReadSharedSecret() []byte { - for { - fmt.Print(ColorYellow + " -> Wspólny sekret (K_AB): " + ColorReset) - secret, _ := ui.reader.ReadString('\n') - secret = strings.TrimSpace(secret) - - if secret != "" { - return []byte(secret) - } - fmt.Println(ColorRed + "[UI] Sekret nie może być pusty." + ColorReset) - } -} - -// --- ACK event handlers --- - -func (ui *CliUI) OnDelivered(msgID int64) { - fmt.Printf(ColorGreen+"\n[Delivered] msg_id=%d\n"+ColorReset, msgID) - ui.Prompt() -} - -func (ui *CliUI) OnTimeout(msgID int64) { - fmt.Printf(ColorRed+"\n[Timeout] msg_id=%d not delivered\n"+ColorReset, msgID) - ui.Prompt() -} From 53307da5551667d2a039e655389b2fdbb6d72c3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Brzezi=C5=84ski?= Date: Tue, 26 May 2026 19:59:58 +0000 Subject: [PATCH 44/44] full refactor done --- client/app/chat/events.go | 2 +- client/ui_cli/ack_handlers.go | 5 +- client/ui_cli/auth_flow.go | 8 +- client/ui_cli/command_help.go | 22 +++++ client/ui_cli/command_menu.go | 6 ++ client/ui_cli/menu.go | 163 ++-------------------------------- client/ui_cli/menu_print.go | 18 ++++ client/ui_cli/render.go | 12 +++ client/ui_cli/router.go | 76 ++++++++++++++++ client/ui_commands/chat.go | 82 +++++++++++++++++ client/ui_commands/end.go | 21 +++++ client/ui_commands/help.go | 30 +++++++ client/ui_commands/logout.go | 22 +++++ client/ui_commands/message.go | 32 +++++++ client/ui_commands/secret.go | 57 ++++++++++++ client/ui_commands/status.go | 34 +++++++ 16 files changed, 428 insertions(+), 162 deletions(-) create mode 100644 client/ui_cli/command_help.go create mode 100644 client/ui_cli/command_menu.go create mode 100644 client/ui_cli/menu_print.go create mode 100644 client/ui_cli/render.go create mode 100644 client/ui_cli/router.go create mode 100644 client/ui_commands/chat.go create mode 100644 client/ui_commands/end.go create mode 100644 client/ui_commands/help.go create mode 100644 client/ui_commands/logout.go create mode 100644 client/ui_commands/message.go create mode 100644 client/ui_commands/secret.go create mode 100644 client/ui_commands/status.go diff --git a/client/app/chat/events.go b/client/app/chat/events.go index fa2d0ed..de3b04f 100644 --- a/client/app/chat/events.go +++ b/client/app/chat/events.go @@ -22,7 +22,7 @@ func (b *ChatEventBridge) Run(onEvent func(msg string)) { select { case ev := <-b.client.ChatRequestEvents(): b.chatState.SetPendingRequest(ev.From) - onEvent("[CHAT] " + ev.From + " wants to chat with you.") + onEvent("[CHAT] " + ev.From + " wants to chat with you. You can accept it or refuse it now") case ev := <-b.client.ChatAcceptEvents(): b.chatState.SetActive(ev.From) diff --git a/client/ui_cli/ack_handlers.go b/client/ui_cli/ack_handlers.go index 8a6a99d..76cc2b7 100644 --- a/client/ui_cli/ack_handlers.go +++ b/client/ui_cli/ack_handlers.go @@ -4,9 +4,10 @@ import "fmt" // OnDelivered is called when the AckManager reports successful delivery. // This is UI-only feedback and does not affect application logic. +// UNCOMMENT BODY IF YOU WANT TO SEE IF MESSAGE WAS DELIVERED func (ui *CliUI) OnDelivered(msgID int64) { - fmt.Printf(ColorGreen+"\n[Delivered] msg_id=%d\n"+ColorReset, msgID) - ui.Prompt() + //fmt.Printf(ColorGreen+"\n[Delivered] msg_id=%d\n"+ColorReset, msgID) + //ui.Prompt() } // OnTimeout is called when the AckManager reports a delivery timeout. diff --git a/client/ui_cli/auth_flow.go b/client/ui_cli/auth_flow.go index 8ff8d47..3d05ae0 100644 --- a/client/ui_cli/auth_flow.go +++ b/client/ui_cli/auth_flow.go @@ -44,7 +44,7 @@ func (ui *CliUI) RunAuthFlowAsync(client *api.KittyClient) (string, error) { return pass, nil default: - ui.Println("Nieznana komenda.") + ui.Println("Unknown command.") } } } @@ -52,7 +52,7 @@ func (ui *CliUI) RunAuthFlowAsync(client *api.KittyClient) (string, error) { // printAuthMenu prints the main AUTH/REGISTER menu. func (ui *CliUI) printAuthMenu() { ui.Println(ColorBlue + "\n ==================" + ColorReset) - ui.Println(ColorBlue + " | Wybierz opcję: |") + ui.Println(ColorBlue + " | Choose option: |") ui.Println(" | |") ui.Println(" | " + ColorGreen + "->" + ColorReset + " /login " + ColorBlue + "|") ui.Println(" | " + ColorGreen + "->" + ColorReset + " /register " + ColorBlue + "|") @@ -73,7 +73,7 @@ func (ui *CliUI) handleRegister(client *api.KittyClient) error { if !res.OK { return res } - ui.Println("[Client] REGISTER OK — możesz się teraz zalogować.") + ui.Println("[Client] REGISTER OK — you can log in now.") return nil case <-time.After(authTimeout): @@ -94,7 +94,7 @@ func (ui *CliUI) handleLogin(client *api.KittyClient) (string, error) { if !res.OK { return "", res } - ui.Println("[Client] AUTH OK — zalogowano.") + ui.Println("[Client] AUTH OK — logged in.") return pass, nil case <-time.After(authTimeout): diff --git a/client/ui_cli/command_help.go b/client/ui_cli/command_help.go new file mode 100644 index 0000000..f52ca19 --- /dev/null +++ b/client/ui_cli/command_help.go @@ -0,0 +1,22 @@ +package ui_cli + +import "github.com/gabbla05/KittyProtocol/client/ui_commands" + +// cmdHelp prints a styled help page with detailed command descriptions. +func (ui *CliUI) cmdHelp() { + ui.Println(ColorPink1 + "\n ╔══════════════════════════════════════════════════════╗" + ColorReset) + ui.Println(ColorPink1 + " ║ Kitty CLI Help ║" + ColorReset) + ui.Println(ColorPink1 + " ╚══════════════════════════════════════════════════════╝\n" + ColorReset) + + ui.Println(ColorPink2 + " Available Commands:" + ColorReset) + + for _, h := range ui_commands.Help() { + ui.Println() + ui.Printf(ColorGreen+" %-22s"+ColorReset+"\n", h.Command) + ui.Printf(" %s\n", h.Description) + } + + ui.Println(ColorPink3 + "\n ════════════════════════════════════════════════════════" + ColorReset) + ui.Println(ColorPink3 + " Tip: Use /menu to show the compact command list anytime." + ColorReset) + ui.Println(ColorPink3 + " ════════════════════════════════════════════════════════\n" + ColorReset) +} diff --git a/client/ui_cli/command_menu.go b/client/ui_cli/command_menu.go new file mode 100644 index 0000000..9e6b074 --- /dev/null +++ b/client/ui_cli/command_menu.go @@ -0,0 +1,6 @@ +package ui_cli + +// cmdMenu prints the command menu on demand. +func (ui *CliUI) cmdMenu() { + ui.printMenu() +} diff --git a/client/ui_cli/menu.go b/client/ui_cli/menu.go index 981c301..14806be 100644 --- a/client/ui_cli/menu.go +++ b/client/ui_cli/menu.go @@ -1,25 +1,25 @@ package ui_cli import ( - "bytes" - "errors" - "os" "strings" - "github.com/gabbla05/KittyProtocol/client/api" "github.com/gabbla05/KittyProtocol/client/app" ) +// RunMainMenu drives the main interactive CLI loop. +// It blocks until the user logs out or the client disconnects. func (ui *CliUI) RunMainMenu(a *app.App) { + + ui.printMenu() + for { select { case <-a.Disconnected(): - ui.Println(ColorRed + "[Client] Rozłączono z serwerem. Zamykanie aplikacji." + ColorReset) + ui.Println(ColorRed + "[Client] Disconnected from server. Exiting." + ColorReset) return default: } - ui.printMenu() ui.Prompt() line := strings.TrimSpace(ui.ReadLine()) @@ -27,156 +27,9 @@ func (ui *CliUI) RunMainMenu(a *app.App) { continue } - switch { - - // ---------------------------------------------------- - // LOGOUT - // ---------------------------------------------------- - case line == "/logout": - if active, peer := a.ChatState().IsActive(); active && peer != "" { - if err := a.EndChat("user logout client"); err != nil { - ui.Println(ColorRed+"[CHAT ERROR]"+ColorReset, err) - } - } - _ = a.Client().SendBye() + // handleCommand returns true when menu should exit (logout) + if ui.handleCommand(line, a) { return - - // ---------------------------------------------------- - // STATUS - // ---------------------------------------------------- - case strings.HasPrefix(line, "/status "): - user := strings.TrimSpace(strings.TrimPrefix(line, "/status ")) - user = strings.ToLower(user) - if user == "" { - ui.Println(ColorYellow + "Usage: /status " + ColorReset) - continue - } - _ = a.Client().SendGetStatus(user) - - // ---------------------------------------------------- - // SECRET - // ---------------------------------------------------- - case strings.HasPrefix(line, "/secret "): - args := strings.Fields(line) - if len(args) < 2 { - ui.Println(ColorYellow + "Usage: /secret [file:]" + ColorReset) - continue - } - - user := strings.ToLower(args[1]) - - var secret []byte - if len(args) == 3 && strings.HasPrefix(args[2], "file:") { - path := strings.TrimPrefix(args[2], "file:") - data, err := os.ReadFile(path) - if err != nil { - ui.Printf(ColorRed+"[E2EE] Failed to read secret file: %v\n"+ColorReset, err) - continue - } - secret = bytes.TrimSpace(data) - } else { - secret = ui.ReadSharedSecret() - } - - if err := a.Client().SetSharedSecretForPeer(user, secret); err != nil { - ui.Println(ColorRed+"[E2EE] Error deriving keys:"+ColorReset, err) - continue - } - if err := a.Secrets().Set(user, secret); err != nil { - ui.Println(ColorRed+"[E2EE] Error saving secret:"+ColorReset, err) - continue - } - - ui.Printf(ColorGreen+"[E2EE] Shared secret configured for %s.\n"+ColorReset, user) - - // ---------------------------------------------------- - // CHAT REQUEST - // ---------------------------------------------------- - case strings.HasPrefix(line, "/chat "): - user := strings.TrimSpace(strings.TrimPrefix(line, "/chat ")) - user = strings.ToLower(user) - if user == "" { - ui.Println(ColorYellow + "Usage: /chat " + ColorReset) - continue - } - - if err := a.StartChatRequest(user); err != nil { - if errors.Is(err, api.ErrNoSharedSecret) || strings.Contains(err.Error(), "no shared secret") { - ui.Printf(ColorBlue+"[CHAT] Brak wspólnego sekretu z %s. Użyj /secret %s.\n"+ColorReset, user, user) - } else { - ui.Println(ColorRed+"[CHAT ERROR]"+ColorReset, err) - } - } else { - ui.Printf(ColorBlue+"[CHAT] Wysłano CHAT_REQUEST do %s.\n"+ColorReset, user) - } - - // ---------------------------------------------------- - // ACCEPT CHAT - // ---------------------------------------------------- - case strings.HasPrefix(line, "/accept "): - user := strings.TrimSpace(strings.TrimPrefix(line, "/accept ")) - user = strings.ToLower(user) - if user == "" { - ui.Println(ColorYellow + "Usage: /accept " + ColorReset) - continue - } - - if err := a.AcceptChat(user); err != nil { - ui.Println(ColorRed+"[CHAT ERROR]"+ColorReset, err) - } - - // ---------------------------------------------------- - // REFUSE CHAT - // ---------------------------------------------------- - case strings.HasPrefix(line, "/refuse "): - user := strings.TrimSpace(strings.TrimPrefix(line, "/refuse ")) - user = strings.ToLower(user) - if user == "" { - ui.Println(ColorYellow + "Usage: /refuse " + ColorReset) - continue - } - - if err := a.RefuseChat(user, "user refused"); err != nil { - ui.Println(ColorRed+"[CHAT ERROR]"+ColorReset, err) - } - - // ---------------------------------------------------- - // SEND MESSAGE - // ---------------------------------------------------- - case strings.HasPrefix(line, "/msg "): - text := strings.TrimSpace(strings.TrimPrefix(line, "/msg ")) - if text == "" { - continue - } - - if err := a.SendTextMessage(text); err != nil { - ui.Println(ColorRed+"[CHAT ERROR]"+ColorReset, err) - } - - // ---------------------------------------------------- - // END CHAT - // ---------------------------------------------------- - case line == "/end": - if err := a.EndChat("user ended chat"); err != nil { - ui.Println(ColorRed+"[CHAT ERROR]"+ColorReset, err) - } - - default: - ui.Println(ColorYellow + "Nieznana komenda." + ColorReset) } } } - -func (ui *CliUI) printMenu() { - ui.Println(ColorPink3 + "\n ======================" + ColorReset) - ui.Println(ColorPink3 + " | " + ColorGreen + "Dostępne komendy: " + ColorPink3 + "|") - ui.Println(" | " + ColorGreen + "->" + ColorReset + " /status " + ColorPink3 + "|") - ui.Println(" | " + ColorGreen + "->" + ColorReset + " /secret " + ColorPink3 + "|") - ui.Println(" | " + ColorGreen + "->" + ColorReset + " /chat " + ColorPink3 + "|") - ui.Println(" | " + ColorGreen + "->" + ColorReset + " /accept " + ColorPink3 + "|") - ui.Println(" | " + ColorGreen + "->" + ColorReset + " /refuse " + ColorPink3 + "|") - ui.Println(" | " + ColorGreen + "->" + ColorReset + " /msg " + ColorPink3 + "|") - ui.Println(" | " + ColorGreen + "->" + ColorReset + " /end " + ColorPink3 + "|") - ui.Println(" | " + ColorGreen + "->" + ColorReset + " /logout " + ColorPink3 + "|") - ui.Println(ColorPink3 + " ======================\n" + ColorReset) -} diff --git a/client/ui_cli/menu_print.go b/client/ui_cli/menu_print.go new file mode 100644 index 0000000..3d6abd0 --- /dev/null +++ b/client/ui_cli/menu_print.go @@ -0,0 +1,18 @@ +package ui_cli + +// printMenu prints the main command menu. +func (ui *CliUI) printMenu() { + ui.Println(ColorPink2 + "\n ======================" + ColorReset) + ui.Println(ColorPink2 + " | " + ColorGreen + "Available commands: " + ColorPink2 + "|") + ui.Println(" | " + ColorGreen + "->" + ColorReset + " /status " + ColorPink2 + "|") + ui.Println(" | " + ColorGreen + "->" + ColorReset + " /secret " + ColorPink2 + "|") + ui.Println(" | " + ColorGreen + "->" + ColorReset + " /chat " + ColorPink2 + "|") + ui.Println(" | " + ColorGreen + "->" + ColorReset + " /accept " + ColorPink2 + "|") + ui.Println(" | " + ColorGreen + "->" + ColorReset + " /refuse " + ColorPink2 + "|") + ui.Println(" | " + ColorGreen + "->" + ColorReset + " /msg " + ColorPink2 + "|") + ui.Println(" | " + ColorGreen + "->" + ColorReset + " /end " + ColorPink2 + "|") + ui.Println(" | " + ColorGreen + "->" + ColorReset + " /logout " + ColorPink2 + "|") + ui.Println(" | " + ColorGreen + "->" + ColorReset + " /menu " + ColorPink2 + "|") + ui.Println(" | " + ColorGreen + "->" + ColorReset + " /help " + ColorPink2 + "|") + ui.Println(ColorPink2 + " ======================\n" + ColorReset) +} diff --git a/client/ui_cli/render.go b/client/ui_cli/render.go new file mode 100644 index 0000000..7a74858 --- /dev/null +++ b/client/ui_cli/render.go @@ -0,0 +1,12 @@ +package ui_cli + +// render prints the result of a ui_commands call. +func (ui *CliUI) render(msg string, err error) { + if err != nil { + ui.Println(ColorRed + "[ERROR] " + err.Error() + ColorReset) + return + } + if msg != "" { + ui.Println(ColorGreen + msg + ColorReset) + } +} diff --git a/client/ui_cli/router.go b/client/ui_cli/router.go new file mode 100644 index 0000000..3a0849a --- /dev/null +++ b/client/ui_cli/router.go @@ -0,0 +1,76 @@ +package ui_cli + +import ( + "strings" + + "github.com/gabbla05/KittyProtocol/client/app" + "github.com/gabbla05/KittyProtocol/client/ui_commands" +) + +// handleCommand routes a single CLI command. +// Returns true when the menu should exit (logout). +func (ui *CliUI) handleCommand(line string, a *app.App) bool { + + switch { + + // UI-only commands + case line == "/menu": + ui.cmdMenu() + return false + + case line == "/help": + ui.cmdHelp() + return false + + // logout + case line == "/logout": + msg, err := ui_commands.Logout(a) + ui.render(msg, err) + return true + + // status + case strings.HasPrefix(line, "/status "): + msg, err := ui_commands.Status(line, a) + ui.render(msg, err) + return false + + // secret (requires UI input) + case strings.HasPrefix(line, "/secret "): + secret := ui.ReadSharedSecret() + msg, err := ui_commands.Secret(line, secret, a) + ui.render(msg, err) + return false + + // chat + case strings.HasPrefix(line, "/chat "): + msg, err := ui_commands.ChatRequest(line, a) + ui.render(msg, err) + return false + + case strings.HasPrefix(line, "/accept "): + msg, err := ui_commands.ChatAccept(line, a) + ui.render(msg, err) + return false + + case strings.HasPrefix(line, "/refuse "): + msg, err := ui_commands.ChatRefuse(line, a) + ui.render(msg, err) + return false + + // message + case strings.HasPrefix(line, "/msg "): + msg, err := ui_commands.SendMessage(line, a) + ui.render(msg, err) + return false + + // end chat + case line == "/end": + msg, err := ui_commands.EndChat(a) + ui.render(msg, err) + return false + + default: + ui.Println(ColorYellow + "Unknown command. Use /help." + ColorReset) + return false + } +} diff --git a/client/ui_commands/chat.go b/client/ui_commands/chat.go new file mode 100644 index 0000000..65b4857 --- /dev/null +++ b/client/ui_commands/chat.go @@ -0,0 +1,82 @@ +package ui_commands + +import ( + "errors" + "strings" + + "github.com/gabbla05/KittyProtocol/client/api" + "github.com/gabbla05/KittyProtocol/client/app" +) + +// ChatRequest handles the logic for the "/chat " command. +// It extracts the target username from the CLI input, validates it, +// and delegates the actual chat request initiation to the App layer. +// +// This function is UI‑agnostic: it does not print anything, does not +// read from stdin, and does not depend on terminal colors. Instead, +// it returns a user-facing message (string) and/or an error, which +// the UI layer (CLI or GUI) is responsible for rendering. +// +// Returns: +// - string: a human-readable message for the UI +// - error: non-nil if the operation failed +func ChatRequest(line string, a *app.App) (string, error) { + user := strings.TrimSpace(strings.TrimPrefix(line, "/chat ")) + user = strings.ToLower(user) + + if user == "" { + return "Usage: /chat ", nil + } + + err := a.StartChatRequest(user) + if err != nil { + if errors.Is(err, api.ErrNoSharedSecret) { + return "No shared secret with " + user + ". Use /secret " + user, nil + } + return "", err + } + + return "CHAT_REQUEST sent to " + user, nil +} + +// ChatAccept handles the logic for the "/accept " command. +// It validates the username and delegates the acceptance of a chat +// request to the App layer. +// +// UI‑agnostic: returns a message and/or error for the UI to render. +func ChatAccept(line string, a *app.App) (string, error) { + user := strings.TrimSpace(strings.TrimPrefix(line, "/accept ")) + user = strings.ToLower(user) + + if user == "" { + return "Usage: /accept ", nil + } + + err := a.AcceptChat(user) + if err != nil { + return "", err + } + + return "Chat accepted with " + user, nil +} + +// ChatRefuse handles the logic for the "/refuse " command. +// It validates the username and delegates the refusal of a chat +// request to the App layer. +// +// UI‑agnostic: returns a message and/or error for the UI to render. +func ChatRefuse(line string, a *app.App) (string, error) { + user := strings.TrimSpace(strings.TrimPrefix(line, "/refuse ")) + user = strings.ToLower(user) + + if user == "" { + return "Usage: /refuse ", nil + } + + err := a.RefuseChat(user, "user refused") + if err != nil { + return "", err + } + + return "Chat refused with " + user, nil +} diff --git a/client/ui_commands/end.go b/client/ui_commands/end.go new file mode 100644 index 0000000..707af5a --- /dev/null +++ b/client/ui_commands/end.go @@ -0,0 +1,21 @@ +package ui_commands + +import "github.com/gabbla05/KittyProtocol/client/app" + +// EndChat handles the logic for the "/end" command. +// It terminates the currently active chat session by delegating +// the operation to the App layer. +// +// This function is UI‑agnostic and returns a message and/or error +// for the UI layer to render. +// +// Returns: +// - string: confirmation message +// - error: non-nil if ending the chat failed +func EndChat(a *app.App) (string, error) { + err := a.EndChat("user ended chat") + if err != nil { + return "", err + } + return "Chat ended.", nil +} diff --git a/client/ui_commands/help.go b/client/ui_commands/help.go new file mode 100644 index 0000000..f3fc975 --- /dev/null +++ b/client/ui_commands/help.go @@ -0,0 +1,30 @@ +package ui_commands + +// HelpEntry represents a single help item describing a command +// and its purpose. The UI layer is responsible for formatting +// and rendering these entries (e.g., colors, ASCII boxes, etc.). +type HelpEntry struct { + Command string + Description string +} + +// Help returns a static list of all supported commands along with +// human-readable descriptions. This data is UI‑agnostic and can be +// rendered differently by CLI and GUI frontends. +// +// The UI layer decides how to present this list (e.g., colored table, +// popup window, tooltip, etc.). +func Help() []HelpEntry { + return []HelpEntry{ + {"/status ", "Check whether a user is online or offline."}, + {"/secret [file:]", "Configure the shared E2EE secret for a peer."}, + {"/chat ", "Send a chat request to a user."}, + {"/accept ", "Accept an incoming chat request."}, + {"/refuse ", "Refuse an incoming chat request."}, + {"/msg ", "Send a text message to the active chat partner."}, + {"/end", "End the currently active chat session."}, + {"/logout", "Log out from the server and close the client."}, + {"/menu", "Display the command menu."}, + {"/help", "Display detailed help for all commands."}, + } +} diff --git a/client/ui_commands/logout.go b/client/ui_commands/logout.go new file mode 100644 index 0000000..f9a4a7e --- /dev/null +++ b/client/ui_commands/logout.go @@ -0,0 +1,22 @@ +package ui_commands + +import "github.com/gabbla05/KittyProtocol/client/app" + +// Logout handles the logic for the "/logout" command. +// It gracefully terminates any active chat session and then +// sends a BYE frame to the server via the App layer. +// +// This function does not exit the program itself — the UI layer +// (CLI or GUI) decides what "logout" means in its context. +// +// Returns: +// - string: confirmation message +// - error: non-nil if sending BYE failed +func Logout(a *app.App) (string, error) { + if active, peer := a.ChatState().IsActive(); active && peer != "" { + _ = a.EndChat("user logout") + } + + _ = a.Client().SendBye() + return "Logged out.", nil +} diff --git a/client/ui_commands/message.go b/client/ui_commands/message.go new file mode 100644 index 0000000..391aa9f --- /dev/null +++ b/client/ui_commands/message.go @@ -0,0 +1,32 @@ +package ui_commands + +import ( + "strings" + + "github.com/gabbla05/KittyProtocol/client/app" +) + +// SendMessage handles the logic for the "/msg " command. +// It extracts the message text from the CLI input and delegates +// the actual encrypted message sending to the App layer. +// +// This function is UI‑agnostic: it does not print anything and does +// not interact with stdin. It simply returns a user-facing message +// and/or an error for the UI layer (CLI or GUI) to render. +// +// Returns: +// - string: confirmation message for the UI +// - error: non-nil if sending the message failed +func SendMessage(line string, a *app.App) (string, error) { + text := strings.TrimSpace(strings.TrimPrefix(line, "/msg ")) + if text == "" { + return "", nil + } + + err := a.SendTextMessage(text) + if err != nil { + return "", err + } + + return "Message sent.", nil +} diff --git a/client/ui_commands/secret.go b/client/ui_commands/secret.go new file mode 100644 index 0000000..73db2ba --- /dev/null +++ b/client/ui_commands/secret.go @@ -0,0 +1,57 @@ +package ui_commands + +import ( + "bytes" + "os" + "strings" + + "github.com/gabbla05/KittyProtocol/client/app" +) + +// Secret handles the logic for the "/secret [file:]" command. +// It supports two modes: +// 1. Loading the shared secret from a file (file:) +// 2. Using a secret provided by the UI layer (e.g. typed by the user) +// +// This function performs no I/O other than reading a file when explicitly +// requested. It does not print anything and does not interact with stdin. +// The UI layer is responsible for collecting the secret from the user. +// +// After obtaining the secret, it delegates key derivation and storage to +// the App layer and SecretStore. +// +// Returns: +// - string: a user-facing message +// - error: non-nil if key derivation or storage failed +func Secret(line string, secretInput []byte, a *app.App) (string, error) { + args := strings.Fields(line) + if len(args) < 2 { + return "Usage: /secret [file:]", nil + } + + user := strings.ToLower(args[1]) + var secret []byte + + // Load secret from file if requested + if len(args) == 3 && strings.HasPrefix(args[2], "file:") { + path := strings.TrimPrefix(args[2], "file:") + data, err := os.ReadFile(path) + if err != nil { + return "", err + } + secret = bytes.TrimSpace(data) + } else { + // Secret provided by UI (CLI or GUI) + secret = secretInput + } + + // Derive keys and store secret + if err := a.Client().SetSharedSecretForPeer(user, secret); err != nil { + return "", err + } + if err := a.Secrets().Set(user, secret); err != nil { + return "", err + } + + return "Shared secret configured for " + user, nil +} diff --git a/client/ui_commands/status.go b/client/ui_commands/status.go new file mode 100644 index 0000000..499b8f6 --- /dev/null +++ b/client/ui_commands/status.go @@ -0,0 +1,34 @@ +package ui_commands + +import ( + "strings" + + "github.com/gabbla05/KittyProtocol/client/app" +) + +// Status handles the logic for the "/status " command. +// It extracts the username from the CLI input, validates it, +// and delegates the status request to the App layer. +// +// This function does not print anything and does not depend on +// terminal-specific features. It returns a message and/or error +// for the UI layer to render. +// +// Returns: +// - string: a user-facing message +// - error: non-nil if sending the request failed +func Status(line string, a *app.App) (string, error) { + user := strings.TrimSpace(strings.TrimPrefix(line, "/status ")) + user = strings.ToLower(user) + + if user == "" { + return "Usage: /status ", nil + } + + err := a.Client().SendGetStatus(user) + if err != nil { + return "", err + } + + return "Status request sent.", nil +}