diff --git a/.env.example b/.env.example index d7034dd..0a30aff 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,5 @@ -# KittyProtocol Hub address conf - adjust to situtation (given addres is private vm on Azure) -# 20.82.139.232:9999 +# ----------------------------- +# KittyProtocol configuration +# ----------------------------- KITTY_HUB_ADDR=127.0.0.1:9999 - -# 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 +KITTY_INTERCEPT_ADDR=0.0.0.0:9999 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..05cdb61 --- /dev/null +++ b/.github/workflows/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/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..3344f8c --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,41 @@ +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: 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/.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/README.md b/README.md index 6e18ade..021c17b 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 @@ -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 95700cf..380e30c 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 @@ -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 4eb32fc..da3ae55 100644 --- a/TODO +++ b/TODO @@ -1,9 +1,64 @@ -- 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 -- Ł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 \ No newline at end of file + + +- 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 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 + +- SENSOWNOŚĆ CERTYFIKATÓW! + +- 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). 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) + +- 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 + +- 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 + +- 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 +- 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 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 +- 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 +- brak pokazywania sekretu przy wpisywaniu sekretu \ No newline at end of file diff --git a/kitty_logo.png b/assets/img/kitty_logo.png similarity index 100% rename from kitty_logo.png rename to assets/img/kitty_logo.png diff --git a/client/api/ack.go b/client/api/ack.go index d3a8e0d..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) @@ -33,16 +34,19 @@ 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, } } -// 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() diff --git a/client/api/auth.go b/client/api/auth.go index 305f75f..cec7051 100644 --- a/client/api/auth.go +++ b/client/api/auth.go @@ -2,35 +2,72 @@ package api import ( "encoding/json" - "errors" "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. +// ----------------------------------------------------------------------------- +// AUTH / REGISTER (asynchronous) +// ----------------------------------------------------------------------------- + +// SendAuth sends an AUTH frame with the provided credentials. // -// PROTOCOL ORDER: -// 1. SendHello() -// 2. WaitForHelloOK() -// 3. SendAuth() -// 4. WaitForAuthOK() +// 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 { - c.mu.Lock() - stream := c.stream - c.mu.Unlock() + 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 +} + +// 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 +} - if stream == nil { - return errors.New("stream is nil") +// 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 } frame := protocol.AuthFrame{ BaseFrame: protocol.BaseFrame{ - Type: "AUTH", + Type: frameType, MsgID: time.Now().UnixMilli(), }, User: user, @@ -42,47 +79,23 @@ func (c *KittyClient) SendAuth(user, pass string) error { return err } - _, err = stream.Write(b) - 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) { c.mu.Lock() - stream := c.stream - c.mu.Unlock() - - buf := make([]byte, 4096) - 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" + if beforeUnlock != nil { + beforeUnlock() } + c.state = nextState + c.mu.Unlock() - switch typeName { - case "ERROR": - var errFrame protocol.ErrorFrame - if json.Unmarshal(buf[:n], &errFrame) == nil { - return false, errFrame.Code - } - return false, "PARSE_ERROR" + _, err = stream.Write(b) + return err +} - case "MEOW_OK": - var okFrame protocol.MeowOkFrame - if json.Unmarshal(buf[:n], &okFrame) != nil { - return false, "PARSE_ERROR" - } - return true, "" - } +// ----------------------------------------------------------------------------- +// HELLO (asynchronous) +// ----------------------------------------------------------------------------- - return false, "UNKNOWN_FRAME" +// 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/client.go b/client/api/client.go deleted file mode 100644 index a40327c..0000000 --- a/client/api/client.go +++ /dev/null @@ -1,67 +0,0 @@ -package api - -import ( - "context" - "sync" - - "github.com/gabbla05/KittyProtocol/internal/protection" - "github.com/quic-go/quic-go" -) - -// 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 - -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 -) - -// 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 - conn *quic.Conn - stream *quic.Stream - - // Session metadata - user string - target string - - // Subsystems - ackMgr *AckManager - replay *protection.ReplayDetector - stopPing chan struct{} - stopRecv chan struct{} - ctx context.Context - cancel context.CancelFunc - - // 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) -} - -// 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, - } -} 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 +} diff --git a/client/api/close.go b/client/api/close.go index 00991fc..c93eb84 100644 --- a/client/api/close.go +++ b/client/api/close.go @@ -3,23 +3,27 @@ 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: +// Close gracefully shuts down the client and securely clears all sensitive data. // -// - stops ping and receiver loops, -// - closes the QUIC stream and connection, -// - cancels the internal context, -// - zeroizes encryption keys, -// - resets replay detector and ACK manager, -// - clears session state (target, lastFrame). +// 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: calling it multiple times is safe. +// 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: @@ -31,32 +35,44 @@ func (c *KittyClient) Close() { close(c.stopRecv) } - // Cancel context + // Cancel context (if any). if c.cancel != nil { c.cancel() } - // Close stream and connection + // 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. 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 keys - if c.kEnc != nil { - cryptoee.Zeroize(c.kEnc) - c.kEnc = nil - } - if c.kMac != nil { - cryptoee.Zeroize(c.kMac) - c.kMac = 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 + // Reset session state. + c.user = "" c.target = "" c.lastFrame = nil c.replay = protection.NewReplayDetector() diff --git a/client/api/connect.go b/client/api/connect.go index c79ff94..23457a4 100644 --- a/client/api/connect.go +++ b/client/api/connect.go @@ -12,8 +12,9 @@ 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. +// - 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. // // STATE TRANSITIONS: @@ -24,12 +25,10 @@ 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. - // 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{}) @@ -37,75 +36,44 @@ func (c *KittyClient) Connect(hubAddr string) error { tlsConf := buildTLSConfig() - // QUIC Dial - conn, err := quic.DialAddr(context.Background(), hubAddr, tlsConf, nil) + // 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 + // Open a bidirectional 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 } - // 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 + c.setState(StateHandshaking) + + // Send HELLO immediately (async handshake). if err := c.SendHello(); err != nil { return err } - c.setState(StateHandshaking) return nil } // Disconnect closes the QUIC connection and stream. -// This is a convenience wrapper around KittyClient.Close(). +// +// This is a convenience wrapper around Close() to keep the public API explicit. 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) -} - -// 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/api/constants.go b/client/api/constants.go new file mode 100644 index 0000000..d7cfb88 --- /dev/null +++ b/client/api/constants.go @@ -0,0 +1,29 @@ +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 + + // 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 cd3b12e..29afcbd 100644 --- a/client/api/e2ee.go +++ b/client/api/e2ee.go @@ -1,15 +1,31 @@ package api -import "github.com/gabbla05/KittyProtocol/internal/cryptoee" +import ( + "github.com/gabbla05/KittyProtocol/internal/cryptoee" +) -// SetSharedSecret derives encryption and MAC keys from the shared secret -// and stores them in the client. +// 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 { +// - A minimum secret length is enforced to avoid weak E2EE setups. +func (c *KittyClient) SetSharedSecretForPeer(peer string, secret []byte) error { + if peer == "" { + return ErrEmptyPeer + } + if len(peer) > maxUsernameLength { + return ErrPeerNameTooLong + } + if len(secret) == 0 { + return ErrEmptySecret + } + if len(secret) < minSharedSecretLength { + return ErrSharedSecretTooShort + } + kEnc, kMac, err := cryptoee.DeriveKeysFromSecret(secret) if err != nil { return err @@ -18,7 +34,26 @@ 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 } + +// 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() + + if c.peerKeys == nil { + return false + } + _, ok := c.peerKeys[peer] + return ok +} diff --git a/client/api/errors.go b/client/api/errors.go new file mode 100644 index 0000000..6cf81c2 --- /dev/null +++ b/client/api/errors.go @@ -0,0 +1,57 @@ +package api + +import "errors" + +// Transport / connection 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") +) + +// 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") + + // 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/hello.go b/client/api/hello.go index 381d793..3169692 100644 --- a/client/api/hello.go +++ b/client/api/hello.go @@ -2,81 +2,40 @@ 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. +// 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 errors.New("stream is nil") + stream, err := c.ensureConnected() + if err != nil { + return fmt.Errorf("cannot send HELLO: %w", err) } 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 = stream.Write(b) - return err -} - -// 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() - - buf := make([]byte, 4096) - 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 "ERROR": - var errFrame protocol.ErrorFrame - if json.Unmarshal(buf[:n], &errFrame) == nil { - return false, errFrame.Code - } - return false, "PARSE_ERROR" - - case "MEOW_OK": - var okFrame protocol.MeowOkFrame - if json.Unmarshal(buf[:n], &okFrame) != nil { - return false, "PARSE_ERROR" - } - return true, "" + if _, err := stream.Write(b); err != nil { + return fmt.Errorf("failed to send HELLO: %w", err) } - return false, "UNKNOWN_FRAME" + return nil } diff --git a/client/api/logger.go b/client/api/logger.go new file mode 100644 index 0000000..d211824 --- /dev/null +++ b/client/api/logger.go @@ -0,0 +1,56 @@ +package api + +import ( + "fmt" + "sync" +) + +// LogLevel represents the severity of a log message. +type LogLevel int + +const ( + LogDebug LogLevel = iota + LogInfo + LogWarn + 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{} + +// 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 + logger Logger +} + +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() + 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 ad25edd..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, @@ -23,12 +24,12 @@ func (c *KittyClient) StartPingLoop() { stop := c.stopPing c.mu.Unlock() - if stream == nil { + if stream == nil || stop == nil { return } go func() { - ticker := time.NewTicker(30 * time.Second) + ticker := time.NewTicker(defaultPingInterval) defer ticker.Stop() for { @@ -47,7 +48,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 deleted file mode 100644 index 0000d6f..0000000 --- a/client/api/receive.go +++ /dev/null @@ -1,140 +0,0 @@ -package api - -import ( - "encoding/json" - "fmt" - - "github.com/gabbla05/KittyProtocol/internal/cryptoee" - "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. -// -// 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). -func (c *KittyClient) StartReceiverLoop(disconnected chan struct{}) { - c.mu.Lock() - stream := c.stream - replay := c.replay - ackMgr := c.ackMgr - stopRecv := c.stopRecv - c.mu.Unlock() - - if stream == nil { - return - } - - go func() { - buf := make([]byte, 4096) - - for { - select { - case <-stopRecv: - return - default: - } - - 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.") - - // signal application layer exactly once - select { - case <-disconnected: - // already closed - default: - close(disconnected) - } - return - } - - typeName, msgID, err := protocol.GetFrameType(buf[:n]) - if err != nil { - fmt.Println("[Client: Receive] Parse error:", err) - continue - } - - switch typeName { - case "MEOW_OK": - // Delivery acknowledgment (optional). - // If no AckManager is configured, this is silently ignored. - if ackMgr != nil { - ackMgr.NotifyDelivered(msgID) - } - - 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.") - } - } else { - fmt.Println("\n[Client: Receive] Failed to parse ERROR frame\n> ") - } - - 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) - if replay != nil && replay.MarkAndCheck(df.MsgID) { - continue - } - - plaintext, err := cryptoee.DecryptAndVerifyWithKeys( - df.MsgID, - df.Target, // associated data: logical target of the message - df.Payload, - df.MAC, - kEnc, - kMac, - ) - if err != nil { - fmt.Printf("\n[Client: Receive] E2EE error: %v\n> ", err) - continue - } - - fmt.Printf("\n[Client: Receive] Message from %s: %s\n> ", df.Sender, plaintext) - - case "STATUS_RES": - var sf protocol.StatusResFrame - if json.Unmarshal(buf[:n], &sf) != nil { - fmt.Println("\n[Client: Receive] Failed to parse STATUS_RES frame\n> ") - continue - } - - if sf.Target == "" && sf.Status == "no_target" { - fmt.Printf("\n[Client: Receive] Chat ended. No active target.\n> ") - continue - } - - fmt.Printf("\n[Client: Receive] %s is %s\n> ", 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..325eec9 --- /dev/null +++ b/client/api/receive_loop.go @@ -0,0 +1,100 @@ +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(ErrFrameParseFailed) + 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) + + default: + c.handleParseError(ErrUnknownFrameType) + } + } + }() +} + +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/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 4a666a6..4a41c04 100644 --- a/client/api/send.go +++ b/client/api/send.go @@ -2,132 +2,37 @@ package api import ( "encoding/json" - "errors" - "time" - - "github.com/gabbla05/KittyProtocol/internal/cryptoee" - "github.com/gabbla05/KittyProtocol/protocol" + "strings" ) -// 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. -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") - } - if target == "" { - return errors.New("target not set") - } - - 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 - } - - 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 - } - - // 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() - - return nil +// 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)) } -// 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 { +// 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() - stream := c.stream - c.mu.Unlock() + defer c.mu.Unlock() - if stream == nil { - return errors.New("stream is nil") + if c.stream == nil { + return nil, ErrNoStream } - - msgID := time.Now().UnixMilli() - - frame := protocol.GetStatusFrame{ - BaseFrame: protocol.BaseFrame{ - Type: "GET_STATUS", - MsgID: msgID, - }, - Target: target, - } - - b, err := json.Marshal(frame) - if err != nil { - return err + if c.state == StateDisconnected { + return nil, ErrNotConnected } - - _, err = stream.Write(b) - return err + return c.stream, nil } -// SendBye sends a BYE frame to the Hub and does NOT close the stream. -// Stream closing is handled by KittyClient.Close(). -func (c *KittyClient) SendBye() error { - c.mu.Lock() - stream := c.stream - c.mu.Unlock() - - if stream == nil { - return errors.New("stream is nil") - } - - frame := protocol.BaseFrame{ - Type: "BYE", - MsgID: time.Now().UnixMilli(), - } - +// 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 } 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..c9de078 --- /dev/null +++ b/client/api/send_data.go @@ -0,0 +1,85 @@ +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 + } + + if len(target) > maxUsernameLength { + return ErrTargetNameTooLong + } + + 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) + + if len(payload) > maxPayloadSize { + return ErrPayloadTooLarge + } + + 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..57be0e9 --- /dev/null +++ b/client/api/send_status.go @@ -0,0 +1,35 @@ +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 + } + + if len(target) > maxUsernameLength { + return ErrTargetNameTooLong + } + + msgID := time.Now().UnixMilli() + + frame := protocol.GetStatusFrame{ + BaseFrame: protocol.BaseFrame{ + Type: protocol.FrameTypeGetStatus, + MsgID: msgID, + }, + Target: canonicalTarget(target), + } + + return c.sendFrame(stream, frame) +} 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/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") diff --git a/client/api/transport.go b/client/api/transport.go new file mode 100644 index 0000000..2c95b21 --- /dev/null +++ b/client/api/transport.go @@ -0,0 +1,72 @@ +package api + +import ( + "context" + + "github.com/quic-go/quic-go" +) + +// StreamAdapter abstracts a bidirectional QUIC stream. +// +// 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) + 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 or map them to equivalent semantics. + 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 56607c9..17a1e5f 100644 --- a/client/app/app.go +++ b/client/app/app.go @@ -1,29 +1,150 @@ package app -import "github.com/gabbla05/KittyProtocol/client/api" +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 the App layer. -// It allows plugging in different frontends (CLI, GUI, tests). +// 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 Println(v ...any) Printf(format string, v ...any) + Prompt() } -// App coordinates user interaction (UI) with the KittyClient API. -// It contains no networking or cryptography — only application logic. +// 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 *chat.ChatState + chatLogic *chat.ChatLogic + chatBridge *chat.ChatEventBridge + + secrets *secretstore.SecretStore } -// NewApp creates a new application controller. +// NewApp constructs a new application layer instance. func NewApp(c *api.KittyClient, ui UI, disconnected <-chan struct{}) *App { - return &App{ + state := chat.NewChatState() + + a := &App{ client: c, ui: ui, disconnected: disconnected, + chatState: state, + chatLogic: chat.NewChatLogic(c, state), + chatBridge: chat.NewChatEventBridge(c, state), + } + + 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) attachCoreEventHandlers() { + c := a.client + + // ERROR frame + c.OnError(func(code, desc string) { + if code == "ERR_15" { + if active, _ := a.chatState.IsActive(); active { + a.chatState.EndChat() + a.ui.Printf("\n[CHAT] Chat ended (peer unavailable: %s).\n", desc) + a.ui.Prompt() + return + } + } + a.ui.Printf("\n[ERROR] %s: %s\n", code, desc) + a.ui.Prompt() + }) + + // STATUS_RES frame + c.OnStatus(func(target, status string) { + if target == "" && status == "no_target" { + a.ui.Printf("\n[CHAT] Chat ended.\n") + a.ui.Prompt() + return + } + a.ui.Printf("\n[STATUS] %s is %s\n", target, status) + a.ui.Prompt() + }) + + // Disconnect event + c.OnDisconnected(func(err error) { + 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.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 := secretstore.PathForUser(username) + a.secrets = secretstore.NewSecretStore(path, masterKey) + + for peer, secret := range a.secrets.All() { + _ = a.client.SetSharedSecretForPeer(peer, secret) } } + +// High-level chat operations — thin wrappers delegating to ChatLogic. + +func (a *App) StartChatRequest(target string) error { + return a.chatLogic.StartChatRequest(target) +} + +func (a *App) AcceptChat(from string) error { + return a.chatLogic.AcceptChat(from) +} + +func (a *App) RefuseChat(from, reason string) error { + return a.chatLogic.RefuseChat(from, reason) +} + +func (a *App) EndChat(reason string) error { + return a.chatLogic.EndChat(reason) +} + +func (a *App) SendTextMessage(text string) error { + return a.chatLogic.SendTextMessage(text) +} 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/events.go b/client/app/chat/events.go new file mode 100644 index 0000000..de3b04f --- /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. You can accept it or refuse it now") + + 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 new file mode 100644 index 0000000..0bb523e --- /dev/null +++ b/client/app/chat/frames.go @@ -0,0 +1,69 @@ +package chat + +import "encoding/json" + +// 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 ( + ChatRequest ChatFrameType = "CHAT_REQUEST" + ChatAccept ChatFrameType = "CHAT_ACCEPT" + ChatRefuse ChatFrameType = "CHAT_REFUSE" + ChatEnd ChatFrameType = "CHAT_END" + TextMessage ChatFrameType = "TEXT_MESSAGE" +) + +// 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"` + To string `json:"to"` + Payload json.RawMessage `json:"payload,omitempty"` +} + +// Payload structures for each chat frame type. + +type ChatRequestPayload struct{} + +type ChatAcceptPayload struct{} + +type ChatRefusePayload struct { + Reason string `json:"reason,omitempty"` +} + +type ChatEndPayload struct { + Reason string `json:"reason,omitempty"` +} + +type TextMessagePayload struct { + Text string `json:"text"` +} + +// 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} +} + +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/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 new file mode 100644 index 0000000..a438ae4 --- /dev/null +++ b/client/app/chat/state.go @@ -0,0 +1,77 @@ +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 + + Active bool + ActiveTarget string + + Pending bool + PendingFrom string +} + +// NewChatState constructs a new empty chat state. +func NewChatState() *ChatState { + return &ChatState{} +} + +// 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 +} + +// 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 = "" +} + +// ClearPendingRequest removes any pending chat request. +func (s *ChatState) ClearPendingRequest() { + s.mu.Lock() + defer s.mu.Unlock() + s.Pending = false + s.PendingFrom = "" +} + +// 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 = "" +} + +// 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() + return s.Pending, s.PendingFrom +} diff --git a/client/app/menu.go b/client/app/menu.go deleted file mode 100644 index 96ceb39..0000000 --- a/client/app/menu.go +++ /dev/null @@ -1,57 +0,0 @@ -package app - -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 - select { - case <-a.disconnected: - a.ui.Println("[Client] Rozłączono z serwerem. Zamykanie aplikacji.") - return - default: - } - - a.printMenu() - - line := strings.TrimSpace(a.ui.ReadLine()) - - switch { - case line == "": - continue - - case line == "/quit": - _ = a.client.SendBye() - return - - case strings.HasPrefix(line, "/status "): - user := strings.TrimSpace(strings.TrimPrefix(line, "/status ")) - if user == "" { - a.ui.Println("Usage: /status ") - continue - } - _ = a.client.SendGetStatus(user) - - case strings.HasPrefix(line, "/chat "): - user := strings.TrimSpace(strings.TrimPrefix(line, "/chat ")) - if user == "" { - a.ui.Println("Usage: /chat ") - continue - } - a.RunChatSession(user) - - default: - a.ui.Println("Nieznana komenda.") - } - } -} - -// printMenu prints the list of available commands. -func (a *App) printMenu() { - a.ui.Println("Dostępne komendy:") - a.ui.Println(" /status ") - a.ui.Println(" /chat ") - a.ui.Println(" /quit") -} 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) +} 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/main.go b/client/main.go deleted file mode 100644 index 7f412e7..0000000 --- a/client/main.go +++ /dev/null @@ -1,92 +0,0 @@ -// 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" - - "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) - - // 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) - - // OS signal handling (Ctrl+C, SIGTERM, SIGQUIT). - setupSignalHandler(client) - - // Resolve Hub address. - hubAddr := os.Getenv("KITTY_HUB_ADDR") - if hubAddr == "" { - hubAddr = "127.0.0.1:9999" - } - - fmt.Println("[Client] Connecting to Hub:", hubAddr) - - // QUIC connection. - if err := client.Connect(hubAddr); err != nil { - fmt.Println("[Client] Connection error:", err) - return - } - - // HELLO handshake. - if err := client.WaitForHelloOK(); err != nil { - fmt.Println("[Client] HELLO failed:", err) - client.Close() - return - } - - // AUTH. - user, pass := ui.ReadCredentials() - if err := client.SendAuth(user, pass); err != nil { - fmt.Println("[Client] AUTH send error:", err) - client.Close() - return - } - - if err := client.WaitForAuthOK(); err != nil { - fmt.Println("[Client] AUTH failed:", err) - client.Close() - return - } - - // Background loops. - client.StartReceiverLoop(disconnected) - client.StartPingLoop() - - // Main workflow. - application.RunMainMenu() - - // 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) - - go func() { - <-sigCh - _ = client.SendBye() - client.Close() - fmt.Println("\n[Client] Session closed due to signal.") - os.Exit(0) - }() -} diff --git a/client/start.go b/client/start.go new file mode 100644 index 0000000..b906f10 --- /dev/null +++ b/client/start.go @@ -0,0 +1,102 @@ +package client + +import ( + "os" + "os/signal" + "syscall" + "time" + + "github.com/gabbla05/KittyProtocol/client/api" + "github.com/gabbla05/KittyProtocol/client/app" + "github.com/gabbla05/KittyProtocol/client/ui_cli" +) + +func Start() { + client := api.NewKittyClient() + ui := ui_cli.NewCliUI(client) + + // Logger CLI + api.SetLogger(ui_cli.CliLogger{}) + + disconnected := make(chan struct{}) + + hubAddr := os.Getenv("KITTY_HUB_ADDR") + if hubAddr == "" { + hubAddr = "127.0.0.1:9999" + } + + ui_cli.PrintBanner() + ui.Println("\n[Client] Connecting to Hub:", hubAddr) + + // CONNECT + if err := client.Connect(hubAddr); err != nil { + ui.Println("[Client] Connection error:", err) + return + } + + // ---------------------------------------------------- + // 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 (CLI-specific) + // ---------------------------------------------------- + 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 + } + + // ---------------------------------------------------- + // AUTH SUCCESS → start application + // ---------------------------------------------------- + application := app.NewApp(client, ui, disconnected) + + // ⬇️ NOWOŚĆ: przekazujemy masterKey = hasło użytkownika + application.InitSecretStoreForUser(client.User(), []byte(pass)) + + client.RegisterAckHandler(ui) + + setupSignalHandler(client) + + // Ping loop dopiero po AUTH + 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) + }() +} diff --git a/client/ui_cli/ack_handlers.go b/client/ui_cli/ack_handlers.go new file mode 100644 index 0000000..76cc2b7 --- /dev/null +++ b/client/ui_cli/ack_handlers.go @@ -0,0 +1,18 @@ +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. +// 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() +} + +// 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 new file mode 100644 index 0000000..3d05ae0 --- /dev/null +++ b/client/ui_cli/auth_flow.go @@ -0,0 +1,103 @@ +package ui_cli + +import ( + "errors" + "strings" + "time" + + "github.com/gabbla05/KittyProtocol/client/api" +) + +// ErrQuitRequested is returned when the user chooses /quit during auth flow. +var ErrQuitRequested = errors.New("quit requested") + +// 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.printAuthMenu() + ui.Prompt() + + cmd := strings.TrimSpace(ui.ReadLine()) + + switch cmd { + + case "/quit": + client.Close() + return "", ErrQuitRequested + + case "/register": + if err := ui.handleRegister(client); err != nil { + ui.Println("[Client] REGISTER error:", err) + } + + case "/login": + pass, err := ui.handleLogin(client) + if err != nil { + ui.Println("[Client] AUTH error:", err) + continue + } + return pass, nil + + default: + ui.Println("Unknown command.") + } + } +} + +// printAuthMenu prints the main AUTH/REGISTER menu. +func (ui *CliUI) printAuthMenu() { + ui.Println(ColorBlue + "\n ==================" + ColorReset) + ui.Println(ColorBlue + " | Choose option: |") + ui.Println(" | |") + ui.Println(" | " + ColorGreen + "->" + ColorReset + " /login " + ColorBlue + "|") + ui.Println(" | " + ColorGreen + "->" + ColorReset + " /register " + ColorBlue + "|") + ui.Println(" | " + ColorGreen + "->" + ColorReset + " /quit " + ColorBlue + "|") + ui.Println(" ==================\n" + ColorReset) +} + +// handleRegister performs the REGISTER flow. +func (ui *CliUI) handleRegister(client *api.KittyClient) error { + user, pass := ui.ReadCredentials() + + if err := client.SendRegister(user, pass); err != nil { + return err + } + + select { + case res := <-client.RegisterResult(): + if !res.OK { + return res + } + ui.Println("[Client] REGISTER OK — you can log in now.") + return nil + + 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 — logged in.") + 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 new file mode 100644 index 0000000..b06d231 --- /dev/null +++ b/client/ui_cli/banner.go @@ -0,0 +1,27 @@ +package ui_cli + +import "fmt" + +func PrintBanner() { + + cat := []string{ + ColorPink2 + " \\`*-. ", + ColorPink2 + " ) _`-. ", + ColorPink2 + " ^ ^ . : `. . ", + ColorPink2 + "░█▄ ▄█░█▀▀░█▀█░█░░░█░ : _ ' \\ ", + ColorPink2 + "░█░▀░█░█▀▀░█░█░█▄▀▄█░ ; *` _. `*-._ ", + ColorPink1 + "░▀░░░▀░▀▀▀░▀▀▀░▀░░░▀░ `-.-' `-. ", + ColorPink1 + "░█▀▀░█▀▀░█▀▀░█▀█░█▀▀░█▀▀░█▀▄ ; ` `. ", + ColorPink3 + "░▀▀█░▀▀█░█▀▀░█░█░█░█░█▀▀░█▀▄ :. . \\ ", + ColorPink3 + "░▀▀▀░▀▀▀░▀▀▀░▀░▀░▀▀▀░▀▀▀░▀░▀ . \\ . : .-' . ", + ColorPink2 + "Powered by KITTYPROTOCOL ' `+.; ; ' : ", + ColorPink2 + " _ _ __ : ' | ; ;-.", + ColorPink2 + "__ _____ _ _ __(_)___ _ _ / | / \\ ; ' : :`-: _.`* ;", + ColorPink2 + "\\ V / -_) '_(_-< / _ \\ ' \\ | | _ | () | .*' / .*' ; .*`- +' `*'", + ColorPink2 + " \\_/\\___|_| /__/_\\___/_||_| |_| (_) \\__/ `*-* `*-* `*-*'" + ColorReset, + } + + for _, line := range cat { + fmt.Println(line) + } +} 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/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/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 new file mode 100644 index 0000000..4247289 --- /dev/null +++ b/client/ui_cli/logger.go @@ -0,0 +1,25 @@ +package ui_cli + +import ( + "fmt" + + "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: + 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/client/ui_cli/menu.go b/client/ui_cli/menu.go new file mode 100644 index 0000000..14806be --- /dev/null +++ b/client/ui_cli/menu.go @@ -0,0 +1,35 @@ +package ui_cli + +import ( + "strings" + + "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] Disconnected from server. Exiting." + ColorReset) + return + default: + } + + ui.Prompt() + + line := strings.TrimSpace(ui.ReadLine()) + if line == "" { + continue + } + + // handleCommand returns true when menu should exit (logout) + if ui.handleCommand(line, a) { + return + } + } +} 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/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/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_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 1a24e0b..0000000 --- a/client/ui_cli/ui_cli.go +++ /dev/null @@ -1,89 +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" - - "github.com/gabbla05/KittyProtocol/client/api" -) - -// 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 -} - -// 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 { - fmt.Print("> ") - line, _ := ui.reader.ReadString('\n') - return strings.TrimSpace(line) -} - -// Println prints a line to stdout. -func (ui *CliUI) Println(v ...any) { - fmt.Println(v...) -} - -// Printf prints a formatted line to stdout. -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("Login: ") - user, _ := ui.reader.ReadString('\n') - - fmt.Print("Hasło: ") - 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: ") - secret, _ := ui.reader.ReadString('\n') - secret = strings.TrimSpace(secret) - - if secret != "" { - return []byte(secret) - } - fmt.Println("[UI] Sekret nie może być pusty.") - } -} - -// --- 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) -} - -// 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) -} 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 +} 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() +} 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/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() +); 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 diff --git a/go.mod b/go.mod index d2b03c1..ec0ff91 100644 --- a/go.mod +++ b/go.mod @@ -1,14 +1,17 @@ module github.com/gabbla05/KittyProtocol -go 1.24 +go 1.25.0 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 golang.org/x/crypto v0.41.0 + golang.org/x/term v0.43.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 + golang.org/x/sys v0.44.0 // indirect ) diff --git a/go.sum b/go.sum index 11e1b6a..e2067ae 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,12 @@ +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= 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= @@ -14,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/hub/auth_flow.go b/hub/auth_flow.go deleted file mode 100644 index ecd24cc..0000000 --- a/hub/auth_flow.go +++ /dev/null @@ -1,41 +0,0 @@ -// hub/auth_flow.go -// Implements the initial HELLO → AUTH flow and authorization timeout handling. - -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") - }) -} diff --git a/hub/bootstrap.go b/hub/bootstrap.go new file mode 100644 index 0000000..ff46f32 --- /dev/null +++ b/hub/bootstrap.go @@ -0,0 +1,62 @@ +// 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 ( + "context" + "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 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 — initiating graceful shutdown", sig) + + // Stop all active sessions + globalSessions.Stop() + + // 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/constants.go b/hub/constants.go new file mode 100644 index 0000000..da1dc19 --- /dev/null +++ b/hub/constants.go @@ -0,0 +1,60 @@ +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. + +//=============================================================================== +// 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 + +// 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..2e3e429 --- /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 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 + 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/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 new file mode 100644 index 0000000..aeb7d46 --- /dev/null +++ b/hub/dispatcher.go @@ -0,0 +1,100 @@ +// dispatcher.go +// 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, + state: stateInit, + } + defer ctx.cleanup() + + buf := make([]byte, readBufferSize) + formatErrCount := 0 + + for { + n, err := stream.Read(buf) + if err != nil { + + // --- 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 and validate header. + typeName, msgID, perr := protocol.GetFrameType(raw) + if perr != nil || msgID <= 0 { + formatErrCount++ + sendError(stream, protocol.ErrFormatError, "Invalid frame header") + + if formatErrCount >= maxFormatErrors { + logWarn("Too many malformed frames from client — closing connection") + return + } + continue + } + + formatErrCount = 0 + + // 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/errors.go b/hub/errors.go index 672db19..042c1e4 100644 --- a/hub/errors.go +++ b/hub/errors.go @@ -1,23 +1,24 @@ -// 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/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. -func sendError(stream *quic.Stream, code, desc string) { +// This function MUST be used by all handlers to ensure consistent error reporting. +func sendError(stream protection.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/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/handler_auth.go b/hub/handler_auth.go index 3a21785..d915b07 100644 --- a/hub/handler_auth.go +++ b/hub/handler_auth.go @@ -1,59 +1,73 @@ -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 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"). +// 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, 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 timer. + // Stop AUTH timeout if c.authTimer != nil { c.authTimer.Stop() c.authTimer = nil } - // Verify credentials. + // 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, protocol.ErrSessionError, "User already logged in") return } - // Create session. + // 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. + // Send MEOW_OK ok := protocol.MeowOkFrame{ BaseFrame: protocol.BaseFrame{ - Type: "MEOW_OK", + Type: protocol.FrameTypeMeowOK, MsgID: frame.MsgID, }, 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) - } - } else { - fmt.Println("[Hub: Auth] Failed to marshal MEOW_OK:", err) + b, err := json.Marshal(ok) + if err != nil { + logError("[AUTH] Failed to marshal MEOW_OK: %v", err) + return + } + + if _, err := c.stream.Write(b); err != nil { + logError("[AUTH] Failed to send MEOW_OK: %v", err) } } diff --git a/hub/handler_auth_test.go b/hub/handler_auth_test.go new file mode 100644 index 0000000..aac0c21 --- /dev/null +++ b/hub/handler_auth_test.go @@ -0,0 +1,104 @@ +package hub + +import ( + "encoding/json" + "testing" + + "github.com/gabbla05/KittyProtocol/internal/auth" + "github.com/gabbla05/KittyProtocol/internal/protection" + "github.com/gabbla05/KittyProtocol/protocol" +) + +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") + } +} + +func TestHandleAuthUnknownUser(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: "ghost", + Pass: "whatever", + } + raw, _ := json.Marshal(frame) + + c.handleAuth(raw) + + if c.state != stateHelloReceived { + t.Fatalf("AUTH with unknown user should not authenticate") + } +} diff --git a/hub/handler_bye.go b/hub/handler_bye.go index fa1e567..09ea6a3 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 +// handler_bye.go +// Handles the BYE frame — clean session termination requested by the client. + +package hub + +import ( + "github.com/gabbla05/KittyProtocol/protocol" +) + +func (c *clientContext) handleBye(raw []byte) { + if c.state != stateAuthenticated { + sendError(c.stream, protocol.ErrProtocolViolation, "BYE not allowed before AUTH") + return + } + + if _, err := protocol.ParseByeFrame(raw); err != nil { + sendError(c.stream, protocol.ErrFormatError, err.Error()) + return } + + logInfo("[BYE] Cleaning up session for user: %s", 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_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_context.go b/hub/handler_context.go deleted file mode 100644 index b625bfb..0000000 --- a/hub/handler_context.go +++ /dev/null @@ -1,48 +0,0 @@ -package main - -import ( - "fmt" - "time" - - "github.com/gabbla05/KittyProtocol/internal/protection" - "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 clientContext struct { - conn *quic.Conn - stream *quic.Stream - session *protection.Session - username string - authTimer *protection.AuthTimer -} - -// 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) - 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 - } -} - -// 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..d42354e 100644 --- a/hub/handler_data.go +++ b/hub/handler_data.go @@ -1,64 +1,64 @@ -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" ) -// 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) { - frame, err := protocol.ParseDataFrame(raw) - if err != nil { - sendError(c.stream, "ERR_02", err.Error()) + if c.state != stateAuthenticated { + sendError(c.stream, protocol.ErrProtocolViolation, "DATA not allowed before AUTH") return } - if frame.Target == "" { - sendError(c.stream, "ERR_02", "Missing target") + frame, err := protocol.ParseDataFrame(raw) + if err != nil { + 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 limiting. + // 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 != nil && c.session.Replay.MarkAndCheck(frame.MsgID) { - sendError(c.stream, "ERR_06", "Replay detected") + // Replay protection + if c.session.Replay.MarkAndCheck(frame.MsgID) { + sendError(c.stream, protocol.ErrReplayDetected, "Replay detected") return } - // Update activity. + // Update activity c.touch() - // Route to target. + // Route message if !routeData(*frame, c.session, c.stream) { return } - // ACK for sender. + // Send ACK ack := protocol.MeowOkFrame{ BaseFrame: protocol.BaseFrame{ - Type: "MEOW_OK", + Type: protocol.FrameTypeMeowOK, MsgID: frame.MsgID, }, Status: "Delivered", @@ -66,11 +66,11 @@ 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) + logError("[DATA] Failed to marshal ACK: %v", err) return } if _, err := c.stream.Write(b); err != nil { - fmt.Println("[Hub: Data] Failed to send MEOW_OK ACK:", err) + logError("[DATA] Failed to send ACK: %v", err) } } 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_dispatcher.go b/hub/handler_dispatcher.go deleted file mode 100644 index 36c0db6..0000000 --- a/hub/handler_dispatcher.go +++ /dev/null @@ -1,87 +0,0 @@ -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) - return - } - defer stream.Close() - - ctx := &clientContext{ - conn: conn, - stream: stream, - } - defer ctx.cleanup() - - buf := make([]byte, 4096) - - for { - n, err := stream.Read(buf) - if err != nil { - if err != io.EOF { - fmt.Println("[Hub: HandlerDispatcher] Stream read error:", 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()) - continue - } - - // Debug logging for frame type - // fmt.Println("[Hub: HandlerDispatcher] Received frame type:", typeName) - - switch typeName { - case "HELLO": - ctx.handleHello() - - case "AUTH": - ctx.handleAuth(raw) - - case "PING": - ctx.handlePing() - - case "DATA": - ctx.handleData(raw) - - case "GET_STATUS": - 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() - return - - default: - sendError(stream, "ERR_02", "Unknown frame type: "+typeName) - } - } -} diff --git a/hub/handler_hello.go b/hub/handler_hello.go index 141a0bd..b9ff752 100644 --- a/hub/handler_hello.go +++ b/hub/handler_hello.go @@ -1,7 +1,53 @@ -package main +// handler_hello.go +// Handles the HELLO frame — the first step of the KittyProtocol handshake. -// handleHello processes the HELLO frame. -// It sends MEOW_OK("Ready for auth") and starts the AUTH timeout timer. -func (c *clientContext) handleHello() { - c.authTimer = handleHELLO(c.stream, c.conn) +package hub + +import ( + "encoding/json" + + "github.com/gabbla05/KittyProtocol/internal/protection" + "github.com/gabbla05/KittyProtocol/protocol" +) + +func (c *clientContext) handleHello(raw []byte) { + if c.state != stateInit { + sendError(c.stream, protocol.ErrProtocolViolation, "HELLO not allowed in current state") + return + } + + hello, err := protocol.ParseHelloFrame(raw) + if err != nil { + sendError(c.stream, protocol.ErrFormatError, err.Error()) + return + } + + logInfo("[HELLO] Client version: %s", hello.Version) + + c.state = stateHelloReceived + + // Respond with MEOW_OK + ok := protocol.MeowOkFrame{ + BaseFrame: protocol.BaseFrame{ + Type: protocol.FrameTypeMeowOK, + MsgID: hello.MsgID, + }, + Status: "Ready for auth", + } + + 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 (20s from protection.DefaultAuthTimeout) + c.authTimer = protection.StartAuthTimer(func() { + sendError(c.stream, protocol.ErrAuthorizationTimeout, "Authorization timeout reached") + _ = c.conn.CloseWithError(0x03, "ERR_03: Auth Timeout") + }) } 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.go b/hub/handler_ping.go index 9f81600..cc3181b 100644 --- a/hub/handler_ping.go +++ b/hub/handler_ping.go @@ -1,7 +1,22 @@ -package main +// handler_ping.go +// Handles PING frames — keeps the session alive. + +package hub + +import ( + "github.com/gabbla05/KittyProtocol/protocol" +) + +func (c *clientContext) handlePing(raw []byte) { + if c.state != stateAuthenticated { + sendError(c.stream, protocol.ErrProtocolViolation, "PING not allowed before AUTH") + return + } + + if _, err := protocol.ParsePingFrame(raw); err != nil { + sendError(c.stream, protocol.ErrFormatError, err.Error()) + return + } -// handlePing updates session activity timestamp. -// This is used for idle timeout detection. -func (c *clientContext) handlePing() { c.touch() } 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.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_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.go b/hub/handler_status.go index a8b1d22..d0683cb 100644 --- a/hub/handler_status.go +++ b/hub/handler_status.go @@ -1,71 +1,50 @@ -package main +// handler_status.go +// Handles GET_STATUS — checks whether a target user is online. + +package hub import ( - "encoding/json" - "fmt" + "encoding/json" + "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, protocol.ErrProtocolViolation, "GET_STATUS not allowed before AUTH") + return + } + + frame, err := protocol.ParseGetStatusFrame(raw) + if err != nil { + sendError(c.stream, protocol.ErrFormatError, err.Error()) + return + } + + target := strings.ToLower(strings.TrimSpace(frame.Target)) + + status := "offline" + if globalSessions.IsOnline(target) { + 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, protocol.ErrFormatError, "Failed to marshal STATUS_RES") + return + } + + if _, err := c.stream.Write(b); err != nil { + logError("[STATUS] Failed to send STATUS_RES: %v", err) + } } 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/happy_path_test.go b/hub/happy_path_test.go index 86ef084..4727fbe 100644 --- a/hub/happy_path_test.go +++ b/hub/happy_path_test.go @@ -1,157 +1,163 @@ -package main +package hub import ( "context" "crypto/tls" "encoding/json" - "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" ) +// TestHappyPathE2E verifies the full end‑to‑end flow of the KittyProtocol Hub: +// 1. HELLO → MEOW_OK +// 2. AUTH → MEOW_OK +// 3. DATA routing from Alice → Bob +// 4. ACK confirmation back to Alice +// +// 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) { - // Inicjalizacja globalnej mapy sesji (Task 5 / Task 9) - globalSessions = protection.NewSessionManager() - - // Przygotowanie certyfikatów TLS (Task 32) - 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 Huba - 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 w tle (Task 4) - 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, // Akceptowalne dla lokalnego testu integracyjnego + InsecureSkipVerify: true, NextProtos: []string{"kitty-quic-v1"}, } - // ========================================== - // KROK 1: Podłączenie i logowanie Alice - // ========================================== - aliceConn, err := quic.DialAddr(context.Background(), listener.Addr().String(), clientTLS, nil) + // ============================================================ + // 1. ALICE CONNECTS AND AUTHENTICATES + // ============================================================ + + aliceConn, err := quic.DialAddr(context.Background(), addr, 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 - // ========================================== - bobConn, err := quic.DialAddr(context.Background(), listener.Addr().String(), clientTLS, nil) + 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(), addr, 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/logger.go b/hub/logger.go new file mode 100644 index 0000000..bcc3892 --- /dev/null +++ b/hub/logger.go @@ -0,0 +1,52 @@ +// 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" +) + +const ( + colorReset = "\033[0m" + colorRed = "\033[31m" + colorYellow = "\033[33m" + colorBlue = "\033[34m" +) + +var colorsEnabled = true + +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 15f7f0e..0000000 --- a/hub/main.go +++ /dev/null @@ -1,90 +0,0 @@ -// 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" - "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/quic-go/quic-go" -) - -var ( - globalSessions = protection.NewSessionManager() - globalAuth auth.AuthProvider = auth.NewMockAuth() -) - -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) - return - } - - 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 { - fmt.Println("[Hub] Failed to start listener:", err) - return - } - - fmt.Println("[Hub] 🐈 KittyProtocol Hub listening on", addr) - - setupSignalHandler(listener) - - // Accept loop - for { - conn, err := listener.Accept(context.Background()) - if err != nil { - fmt.Println("[Hub] Accept error:", err) - return - } - - go handleClient(conn) - } -} - -// 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) - _ = listener.Close() - }() -} diff --git a/hub/negative_test.go b/hub/negative_test.go index 9d5c7c2..d71cb48 100644 --- a/hub/negative_test.go +++ b/hub/negative_test.go @@ -1,153 +1,164 @@ -package main +package hub import ( "context" "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" ) +// TestNegativeScenarios verifies that the Hub correctly returns protocol‑level +// error frames (ERR_XX) for invalid authentication and invalid DATA routing. +// +// 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) { - // 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_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 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) + if _, err := stream.Write(hb); err != nil { + t.Fatalf("HELLO write error: %v", err) + } buf := make([]byte, 1024) - stream.Read(buf) + if _, err := stream.Read(buf); err != nil && err != io.EOF { + t.Fatalf("HELLO response read error: %v", err) + } + // 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) + if _, err := stream.Write(ab); err != nil { + t.Fatalf("AUTH write error: %v", err) + } - // Poprawka dla EOF 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 != "ERR_04" { - t.Errorf("Oczekiwano ERR_04, otrzymano: %s", errResp.Code) + if errResp.Code != protocol.ErrAuthenticationFailed { + t.Errorf("Expected ERR_04, got: %s", errResp.Code) } }) - t.Run("ERR_15_UserOffline", func(t *testing.T) { - conn, err := quic.DialAddr(context.Background(), listener.Addr().String(), clientTLS, nil) + // ============================================================ + // ERR_15 — Unknown Target (user does not exist) + // ============================================================ + t.Run("ERR_15_UnknownTarget", func(t *testing.T) { + 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) + + // 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 (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", } 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{ - BaseFrame: protocol.BaseFrame{Type: "DATA", MsgID: time.Now().UnixMilli()}, + BaseFrame: protocol.BaseFrame{Type: protocol.FrameTypeData, MsgID: 3}, Target: "ghostuser", Payload: "SGVsbG8=", 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 != "ERR_15" { - t.Errorf("Oczekiwano ERR_15, otrzymano: %s", errResp.Code) + if errResp.Code != protocol.ErrUnknownTarget { + t.Errorf("Expected ERR_15, got: %s", errResp.Code) } }) } diff --git a/hub/performance_test.go b/hub/performance_test.go index f2ee5a7..e2c1c13 100644 --- a/hub/performance_test.go +++ b/hub/performance_test.go @@ -1,4 +1,4 @@ -package main +package hub import ( "context" @@ -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 5cb0617..dbcd153 100644 --- a/hub/router.go +++ b/hub/router.go @@ -1,65 +1,63 @@ -// hub/router.go -// Routing logic for DATA frames between active user sessions. +// router.go +// Implements DATA frame forwarding between authenticated sessions. +// This file contains no protocol parsing — only delivery logic. -package main +package hub import ( - "encoding/json" - "fmt" + "encoding/json" + "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" ) -// 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 +// 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 protection.Stream) bool { + targetSess, ok := globalSessions.Get(frame.Target) + // router.go — poprawiony fragment + if !ok { + // ERR_15 — Unknown Target + sendError(senderStream, protocol.ErrUnknownTarget, "Unknown target user or user is offline") + return false + } + + if targetSess.Stream == nil { + // ERR_05 — Session Error (receiver session corrupted) + sendError(senderStream, protocol.ErrSessionError, "Receiver stream not available") + return false + } + + // Update activity timestamps + now := time.Now() + sender.LastActive = now + targetSess.LastActive = now + + // Build forwarded DATA frame + forward := protocol.DataFrame{ + BaseFrame: protocol.BaseFrame{ + Type: protocol.FrameTypeData, + MsgID: frame.MsgID, + }, + Sender: sender.ID, + Target: frame.Target, + Payload: frame.Payload, + MAC: frame.MAC, + } + + fb, err := json.Marshal(forward) + if err != nil { + 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 { + logError("[Router] Failed to deliver DATA: %v", err) + sendError(senderStream, protocol.ErrSessionError, "Failed to deliver to receiver") + return false + } + + return true } 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 164c9da..56672f1 100644 --- a/hub/security_test.go +++ b/hub/security_test.go @@ -1,144 +1,156 @@ -package main +package hub import ( "context" "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.go b/hub/start.go new file mode 100644 index 0000000..75acb21 --- /dev/null +++ b/hub/start.go @@ -0,0 +1,106 @@ +// 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" + "errors" + "os" + "strings" + + "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. + ctx := setupSignalHandler(listener) + + // Accept incoming QUIC connections. + for { + 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 + } + + // Each client is handled in its own goroutine. + go handleClient(conn) + } +} 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/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 new file mode 100644 index 0000000..a28ad76 --- /dev/null +++ b/internal/auth/db_auth.go @@ -0,0 +1,49 @@ +package auth + +import ( + "database/sql" + "fmt" + + "golang.org/x/crypto/bcrypt" +) + +// DBAuth is a PostgreSQL-backed authentication provider. +type DBAuth struct { + db *sql.DB +} + +func NewDBAuth(db *sql.DB) *DBAuth { + return &DBAuth{db: db} +} + +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 +} + +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) + } + + 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 51% rename from internal/auth/auth.go rename to internal/auth/mock_auth.go index 2a00f0c..0374d41 100644 --- a/internal/auth/auth.go +++ b/internal/auth/mock_auth.go @@ -6,19 +6,14 @@ import ( "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(user, pass string) bool -} - // 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{ @@ -40,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 } @@ -49,3 +43,32 @@ 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 +} 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 +} 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}) +} 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 2bb5fdd..016fcb0 100644 --- a/internal/cryptoee/cryptoee_test.go +++ b/internal/cryptoee/cryptoee_test.go @@ -1,170 +1,295 @@ 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" +) + +// 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 { + t.Fatalf("DeriveKeysFromSecret failed: %v", err) + } + return kEnc, kMac +} + +// +// ──────────────────────────────────────────────────────────────────────────────── +// 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) + + 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) + } +} + +// 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) + + 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) + } + + raw, _ := base64.StdEncoding.DecodeString(payload) + raw[len(raw)-1] ^= 0xFF // flip last byte + tampered := base64.StdEncoding.EncodeToString(raw) + + 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) + + 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) + } + + raw, _ := base64.StdEncoding.DecodeString(mac) + raw[0] ^= 0xAA // flip first byte + tampered := base64.StdEncoding.EncodeToString(raw) + + if _, err := DecryptAndVerifyWithKeys(msgID, target, payload, tampered, kEnc, kMac); err == nil { + t.Fatalf("expected HMAC verification failure") + } +} + +// +// ──────────────────────────────────────────────────────────────────────────────── +// WRONG PARAMETERS (msgID / target) +// ──────────────────────────────────────────────────────────────────────────────── +// + +// TestWrongMsgID verifies that using a different msgID breaks HMAC verification. +func TestWrongMsgID(t *testing.T) { + kEnc, kMac := mustKeys(t) + + msgID := int64(100) + target := "bob" + plaintext := "Hello" + + payload, mac, _ := EncryptAndMACWithKeys(msgID, target, plaintext, kEnc, kMac) + + 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) + + msgID := int64(200) + target := "bob" + plaintext := "Hello" + + payload, mac, _ := EncryptAndMACWithKeys(msgID, target, plaintext, kEnc, kMac) + + if _, err := DecryptAndVerifyWithKeys(msgID, "alice", payload, mac, kEnc, kMac); err == nil { + t.Fatalf("expected HMAC failure for wrong target") + } +} + +// +// ──────────────────────────────────────────────────────────────────────────────── +// BASE64 / PAYLOAD VALIDATION +// ──────────────────────────────────────────────────────────────────────────────── +// + +// TestPayloadTooShort ensures that payloads shorter than nonce size are rejected. +func TestPayloadTooShort(t *testing.T) { + kEnc, kMac := mustKeys(t) + + msgID := int64(300) + target := "bob" + + shortPayload := base64.StdEncoding.EncodeToString([]byte{1, 2, 3}) + + 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" + + 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, _, _ := EncryptAndMACWithKeys(msgID, target, "Hello", kEnc, kMac) + + if _, err := DecryptAndVerifyWithKeys(msgID, target, payload, "!!!notbase64!!!", kEnc, kMac); err == nil { + t.Fatalf("expected base64 decode error for MAC") + } +} + +// +// ──────────────────────────────────────────────────────────────────────────────── +// HKDF TESTS +// ──────────────────────────────────────────────────────────────────────────────── +// + +// TestDeriveKeysDeterministic verifies that HKDF produces deterministic output +// for the same input secret. +func TestDeriveKeysDeterministic(t *testing.T) { + secret := []byte("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA") + + 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" + + payload1, mac1, _ := EncryptAndMACWithKeys(msgID, " Bob ", plaintext, kEnc, kMac) + payload2, mac2, _ := EncryptAndMACWithKeys(msgID, "bob", plaintext, kEnc, kMac) + + // 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) + } + + 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 0c872c5..de13ba2 100644 --- a/internal/cryptoee/decrypt.go +++ b/internal/cryptoee/decrypt.go @@ -9,11 +9,10 @@ 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. - +// 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 { @@ -43,6 +42,8 @@ func DecryptAndVerifyWithKeys(msgID int64, target, payloadB64, macB64 string, kE nonce := raw[:nonceSize] ciphertext := raw[nonceSize:] + 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) h.Write(macInput) @@ -52,7 +53,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..183071b 100644 --- a/internal/cryptoee/encrypt.go +++ b/internal/cryptoee/encrypt.go @@ -4,34 +4,44 @@ import ( "crypto/aes" "crypto/cipher" "crypto/hmac" + "crypto/rand" "crypto/sha256" "encoding/base64" "encoding/binary" "fmt" + "strings" ) -// buildMACInput = cipher || msg_id || 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)) - out := make([]byte, 0, len(cipher)+len(msg)+len(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 } +// canonicalizeTarget normalizes the target string to a stable form +// to avoid MAC mismatches due to case or whitespace differences. +func canonicalizeTarget(t string) string { + // 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 || target. +// over cipher || msg_id || canonical_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. - +// 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 { @@ -44,10 +54,16 @@ func EncryptAndMACWithKeys(msgID int64, target, plaintext string, kEnc, kMac []b } nonceSize := aead.NonceSize() + 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) + } + + // 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), nil) + ciphertext := aead.Seal(nil, nonce, []byte(plaintext), aad) macInput := buildMACInput(ciphertext, msgID, target) h := hmac.New(sha256.New, kMac) 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 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() + } +} 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 cec1afc..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,15 +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)) + // Refill tokens based on elapsed time. + refill := int(elapsed.Seconds() * float64(rl.maxTokens)) if refill > 0 { rl.tokens += refill if rl.tokens > rl.maxTokens { @@ -47,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 = 20 * time.Second - -// 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 6ed8372..611ef85 100644 --- a/internal/protection/replay.go +++ b/internal/protection/replay.go @@ -5,20 +5,8 @@ import ( "time" ) -const ( - // maxHubReplayEntries defines the maximum number of tracked message IDs - // before a cleanup sweep is triggered. - maxHubReplayEntries = 100_000 - - // replayTTL defines how long a message ID is considered "recent" and - // thus subject to replay detection. - replayTTL = 2 * time.Minute - - // replaySweepInterval defines the minimum time between cleanup sweeps. - 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 @@ -28,37 +16,49 @@ 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 { + 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 { + // 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) } } r.lastSweep = now } - // Save the new (or refreshed) entry. + // Enforce memory limit + 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 { + 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..da95598 100644 --- a/internal/protection/replay_test.go +++ b/internal/protection/replay_test.go @@ -5,46 +5,94 @@ 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) - // pierwszy raz + r.MarkAndCheck(id) + r.MarkAndCheck(id) + + // Simulate TTL expiration + r.mu.Lock() + r.seen[id] = time.Now().Add(-ReplayTTL - time.Second) + r.mu.Unlock() + if replay := r.MarkAndCheck(id); replay { - t.Fatalf("first time should NOT be replay") + t.Fatalf("msgID should NOT be replay after TTL expiration") } +} - // drugi raz (od razu) → replay - if replay := r.MarkAndCheck(id); !replay { - t.Fatalf("second time SHOULD be replay") +// TestReplayDetector_SweepRemovesOldEntries ensures that periodic sweeping +// removes expired entries. +func TestReplayDetector_SweepRemovesOldEntries(t *testing.T) { + r := NewReplayDetector() + + oldID := int64(1) + newID := int64(2) + + r.MarkAndCheck(oldID) + + // 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.mu.Unlock() + + r.MarkAndCheck(newID) + + r.mu.Lock() + _, exists := r.seen[oldID] + r.mu.Unlock() + + if exists { + t.Fatalf("expired entry should have been removed during sweep") } +} - // symulujemy upływ czasu - time.Sleep(replayTTL + 10*time.Millisecond) +// TestReplayDetector_MaxEntriesLimit ensures that the detector never grows +// beyond MaxReplayEntries. +func TestReplayDetector_MaxEntriesLimit(t *testing.T) { + r := NewReplayDetector() - // po TTL → NIE replay - if replay := r.MarkAndCheck(id); replay { - t.Fatalf("after TTL msgID should NOT be replay") + for i := 0; i < MaxReplayEntries; i++ { + r.MarkAndCheck(int64(i)) + } + + // Simulate all entries being old + r.mu.Lock() + cutoff := time.Now().Add(-ReplayTTL - time.Second) + for id := range r.seen { + r.seen[id] = cutoff + } + r.mu.Unlock() + + r.MarkAndCheck(999999) + + 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..a140d41 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. @@ -23,18 +15,17 @@ 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(), 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 a293870..52f431b 100644 --- a/internal/protection/session_manager.go +++ b/internal/protection/session_manager.go @@ -6,40 +6,35 @@ 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 - // 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. +// NewSessionManager creates a new SessionManager and starts the idle cleaner. 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. +// Stop terminates the background cleaner goroutine. +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 +42,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 +49,35 @@ 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] 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. +// 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() - _, ok := sm.sessions[user] return ok } diff --git a/internal/protection/session_manager_test.go b/internal/protection/session_manager_test.go index f13090d..fa2c3ca 100644 --- a/internal/protection/session_manager_test.go +++ b/internal/protection/session_manager_test.go @@ -5,10 +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) { - sm := NewSessionManagerWithInterval(50*time.Millisecond, 100*time.Millisecond) + sm := &SessionManager{ + sessions: make(map[string]*Session), + stopChan: make(chan struct{}), + } + + // Start cleaner with very short intervals for testing. + go sm.startCleaner(30*time.Millisecond, 50*time.Millisecond) + // Create a session that is already idle for >50ms. sess := &Session{ ID: "alice", LastActive: time.Now().Add(-200 * time.Millisecond), @@ -17,9 +25,12 @@ 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) + + 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") } } 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/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. --- 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 | + 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/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" +) 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_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.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_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.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_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_error.go b/protocol/frame_error.go new file mode 100644 index 0000000..04788a1 --- /dev/null +++ b/protocol/frame_error.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_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.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_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.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_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.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_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.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_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_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/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.go b/protocol/frames.go index 012260b..fe34a53 100644 --- a/protocol/frames.go +++ b/protocol/frames.go @@ -5,105 +5,19 @@ import ( "fmt" ) -// Frame type constants – single source of truth for all frame type strings. -const ( - FrameTypeHello = "HELLO" - FrameTypeAuth = "AUTH" - FrameTypeData = "DATA" - FrameTypeMeowOK = "MEOW_OK" - FrameTypeError = "ERROR" - FrameTypeGetStatus = "GET_STATUS" - FrameTypeStatusRes = "STATUS_RES" - FrameTypePing = "PING" - FrameTypeBye = "BYE" -) - -// Common error code constants used in protocol-level validation. -const ( - ErrCodeInvalidFrame = "ERR_02" // generic "invalid frame" / "bad format" error -) - -// 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"` // e.g. "HELLO", "AUTH", "DATA" - MsgID int64 `json:"msg_id"` // Timestamp used as a unique ID -} - -// 1. HELLO – initial greeting frame. -type HelloFrame struct { - BaseFrame - Version string `json:"version"` // e.g. "1.0" -} - -// 2. AUTH – authentication frame. -type AuthFrame struct { - BaseFrame - User string `json:"user"` // Username - Pass string `json:"pass"` // Password -} - -// 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 -} - -// 4. MEOW_OK – application-level acknowledgment (ACK). -type MeowOkFrame struct { - BaseFrame - Status string `json:"status,omitempty"` // Optional status description -} - -// 5. ERROR – error frame. -type ErrorFrame struct { - BaseFrame - Code string `json:"code"` // Error code (e.g. ERR_02) - Desc string `json:"desc"` // Error description + Type string `json:"type"` + MsgID int64 `json:"msg_id"` } -// 6. GET_STATUS – query for user status. -type GetStatusFrame struct { - BaseFrame - Target string `json:"target"` // User whose status is being queried -} - -// 7. STATUS_RES – response with user status. -type StatusResFrame struct { - BaseFrame - Target string `json:"target"` // Queried user identifier - Status string `json:"status"` // "online" or "offline" -} - -// 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. +// IsValidType returns true if the provided frame type is recognized by the protocol. func IsValidType(t string) bool { switch t { case FrameTypeHello, FrameTypeAuth, + FrameTypeRegister, FrameTypeData, FrameTypeMeowOK, FrameTypeError, @@ -116,77 +30,18 @@ func IsValidType(t string) bool { return false } -// 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) - } - 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 { - return nil, fmt.Errorf("%s: Invalid JSON format", ErrCodeInvalidFrame) - } - if f.User == "" || f.Pass == "" { - return nil, fmt.Errorf("%s: Missing user or pass in AUTH frame", ErrCodeInvalidFrame) - } - 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 { - return nil, fmt.Errorf("%s: Invalid JSON format", ErrCodeInvalidFrame) - } - if f.Payload == "" || f.MAC == "" { - return nil, fmt.Errorf("%s: Missing payload or MAC in DATA frame", ErrCodeInvalidFrame) - } - 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 { - return nil, fmt.Errorf("%s: Invalid JSON format", ErrCodeInvalidFrame) - } - if f.Code == "" { - return nil, fmt.Errorf("%s: Missing error code in ERROR frame", ErrCodeInvalidFrame) - } - 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 { - return nil, fmt.Errorf("%s: Invalid JSON format", ErrCodeInvalidFrame) +// 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 { + return "", 0, fmt.Errorf("%s: JSON parsing error", 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) - // } - 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 { - return nil, fmt.Errorf("%s: Invalid JSON format", ErrCodeInvalidFrame) + if base.Type == "" || base.MsgID <= 0 { + return "", 0, fmt.Errorf("%s: missing or invalid fields (type/msg_id)", ErrCodeInvalidFrame) } - if f.Target == "" || f.Status == "" { - return nil, fmt.Errorf("%s: Missing target or status in STATUS_RES frame", ErrCodeInvalidFrame) + if !IsValidType(base.Type) { + return "", 0, fmt.Errorf("%s: unknown or invalid frame type", ErrCodeInvalidFrame) } - return &f, nil + return base.Type, base.MsgID, nil } diff --git a/protocol/frames_test.go b/protocol/frames_test.go index 2e14372..4c736b4 100644 --- a/protocol/frames_test.go +++ b/protocol/frames_test.go @@ -1,114 +1,157 @@ package protocol -import ( - "strings" - "testing" -) - -// Test sprawdzający poprawne pobranie typu i ID ramki. -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) - } -} - -// 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) - } - - 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) - } -} - -// 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") - } -} - -// Test dla nieznanego typu wiadomości -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") - } -} - -// Test sprawdzający, czy parser AUTH poprawnie odrzuca puste pola. -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") - } -} - -// Test sprawdzający, czy specyficzne ramki DATA poprawnie się walidują. -func TestDataFrameValidation(t *testing.T) { - importJSON := []byte(`{"type":"DATA","msg_id":123,"target":"bob","payload":"SGVsbG8=","mac":"hash"}`) - f, err := ParseDataFrame(importJSON) - 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") - } - - 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") - } -} - -// Test dla parsera StatusResFrame -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") - } -} - -// Test dla parsera ErrorFrame (sprawdza brak wymaganego kodu błędu) -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") - } -} - -// Test dla parsera GetStatusFrame (sprawdza brak targetu) -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") - } -} - -// Test dla parsera HelloFrame (sprawdza uszkodzoną strukturę) -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") - } -} +// 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") +// } +// } 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)) - } -}