diff --git a/server/internal/services/console.go b/server/internal/services/console.go new file mode 100644 index 0000000..31912de --- /dev/null +++ b/server/internal/services/console.go @@ -0,0 +1,69 @@ +package services + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "fmt" + "strconv" + "strings" + "time" +) + +func sessionHMACKey() ([]byte, error) { + // Reuse the AES key material as the HMAC secret. Distinct domain via prefix. + k, err := encryptionKey() + if err != nil { + return nil, err + } + mac := hmac.New(sha256.New, k) + mac.Write([]byte("vantage-console-session-v1")) + return mac.Sum(nil), nil +} + +func b64(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) } + +// SignSessionToken returns a signed, expiring token binding a session id. +func SignSessionToken(sessionID string, ttl time.Duration) (string, error) { + key, err := sessionHMACKey() + if err != nil { + return "", err + } + exp := time.Now().Add(ttl).Unix() + payload := fmt.Sprintf("%s.%d", b64([]byte(sessionID)), exp) + mac := hmac.New(sha256.New, key) + mac.Write([]byte(payload)) + return payload + "." + b64(mac.Sum(nil)), nil +} + +// VerifySessionToken checks signature + expiry and returns the session id. +func VerifySessionToken(token string) (string, error) { + parts := strings.Split(token, ".") + if len(parts) != 3 { + return "", fmt.Errorf("malformed token") + } + payload := parts[0] + "." + parts[1] + key, err := sessionHMACKey() + if err != nil { + return "", err + } + mac := hmac.New(sha256.New, key) + mac.Write([]byte(payload)) + want := mac.Sum(nil) + got, err := base64.RawURLEncoding.DecodeString(parts[2]) + if err != nil || !hmac.Equal(want, got) { + return "", fmt.Errorf("invalid signature") + } + exp, err := strconv.ParseInt(parts[1], 10, 64) + if err != nil { + return "", fmt.Errorf("invalid expiry") + } + if time.Now().Unix() > exp { + return "", fmt.Errorf("token expired") + } + sid, err := base64.RawURLEncoding.DecodeString(parts[0]) + if err != nil { + return "", fmt.Errorf("invalid session id") + } + return string(sid), nil +} diff --git a/server/internal/services/console_test.go b/server/internal/services/console_test.go new file mode 100644 index 0000000..0d25da8 --- /dev/null +++ b/server/internal/services/console_test.go @@ -0,0 +1,43 @@ +package services + +import ( + "testing" + "time" +) + +func TestSessionTokenRoundTrip(t *testing.T) { + t.Setenv("KEY_ENCRYPTION_KEY", "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff") + + tok, err := SignSessionToken("sess-123", time.Minute) + if err != nil { + t.Fatalf("sign: %v", err) + } + got, err := VerifySessionToken(tok) + if err != nil { + t.Fatalf("verify: %v", err) + } + if got != "sess-123" { + t.Fatalf("got %q want sess-123", got) + } +} + +func TestSessionTokenExpired(t *testing.T) { + t.Setenv("KEY_ENCRYPTION_KEY", "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff") + + tok, err := SignSessionToken("sess-123", -time.Second) + if err != nil { + t.Fatalf("sign: %v", err) + } + if _, err := VerifySessionToken(tok); err == nil { + t.Fatalf("expected expiry error, got nil") + } +} + +func TestSessionTokenTampered(t *testing.T) { + t.Setenv("KEY_ENCRYPTION_KEY", "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff") + + tok, _ := SignSessionToken("sess-123", time.Minute) + if _, err := VerifySessionToken(tok + "x"); err == nil { + t.Fatalf("expected signature error, got nil") + } +}