commit f9b7eaa20d719561d6ad8b551bac0ee90d27dade Author: h4nz4 Date: Thu Mar 12 20:09:16 2026 +0100 Initial diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..1ae24ae --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +.git +.cursor +bin +dist +tmp +coverage + +*.log +*.out + +agent-transcripts +TASK.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c5e82d7 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +bin \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a79bf53 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,18 @@ +FROM golang:1.25 AS builder + +WORKDIR /app + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /bin/netisjwt ./cmd/server + +FROM gcr.io/distroless/static-debian12 + +WORKDIR /app +COPY --from=builder /bin/netisjwt /app/netisjwt + +EXPOSE 8000 + +ENTRYPOINT ["/app/netisjwt"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..a60f3dd --- /dev/null +++ b/README.md @@ -0,0 +1,65 @@ +# Netis testna naloga + +Go aplikacija z REST API za upravljanje bitnih status list, JWS (ES256) podpisovanjem in PostgreSQL shrambo. + +## Zagon + +```bash +docker compose up --build +``` + +API: `http://localhost:8000` + +## Hitri API primeri + +Ustvari novo status listo: + +```bash +curl -X POST http://localhost:8000/api/status +``` + +Dodaj nov status v listo: + +```bash +curl -X POST http://localhost:8000/api/status/ +``` + +Nastavi status na `true`: + +```bash +curl -X PUT http://localhost:8000/api/status// +``` + +Nastavi status na `false`: + +```bash +curl -X DELETE http://localhost:8000/api/status// +``` + +Vrni podpisan JWS: + +```bash +curl http://localhost:8000/api/status// +``` + +## Ustavitev + +```bash +docker compose down +``` + +## Narejeno + +- ECDSA P-256 ključ v PEM, podpis in verifikacija (Base64URL podpis). +- Bitna status struktura na `[]byte` + `encodedList` (`base64(gzip(byteArray))`). +- REST API iz naloge 3. +- JWS compact JWT z JWK headerjem pri `GET /api/status/:statusId/:index`. +- Shramba list v PostgreSQL. +- BONUS a: odjemalec za GET + verifikacijo JWS + vrnitev bool statusa. +- BONUS c: enkripcija seznamov v bazi (AES-GCM). +- Testi za ključne pakete (`crypto`, `statuslist`, `jws`, `httpapi`, `client`). +- Docker (`Dockerfile`) in `docker-compose.yml`. + +## Ni vključeno + +- BONUS b (avtorizacija za PUT/POST/DELETE). diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..7ea229f --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,76 @@ +package main + +import ( + "context" + "encoding/base64" + "log" + "net/http" + "os" + "time" + + appcrypto "netisjwt/internal/crypto" + "netisjwt/internal/httpapi" + "netisjwt/internal/storage" +) + +// main zazene HTTP API streznik in inicializira odvisnosti. +func main() { + cfg := naloziNastavitve() + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + store, err := storage.PoveziInMigriraj(ctx, cfg.databaseURL, cfg.aesKeyBase64) + if err != nil { + log.Fatalf("failed to connect store: %v", err) + } + defer store.Zapri() + + privatePEM, err := naloziAliUstvariZasebniKljucPEM(cfg.privateKeyPath) + if err != nil { + log.Fatalf("failed to load signing key: %v", err) + } + + handler := httpapi.NewObravnalnik(store, privatePEM) + router := httpapi.NewUsmerjevalnik(handler) + + log.Printf("server listening on %s", cfg.listenAddr) + if err := http.ListenAndServe(cfg.listenAddr, router); err != nil { + log.Fatalf("server stopped: %v", err) + } +} + +type config struct { + listenAddr string + databaseURL string + aesKeyBase64 string + privateKeyPath string +} + +// naloziNastavitve prebere konfiguracijo iz okolja z default vrednostmi. +func naloziNastavitve() config { + return config{ + listenAddr: preberiOkoljeAliPrivzeto("APP_ADDR", ":8001"), + databaseURL: preberiOkoljeAliPrivzeto("DATABASE_URL", "postgres://postgres:postgres@localhost:5432/postgres?sslmode=disable"), + aesKeyBase64: preberiOkoljeAliPrivzeto("STATUS_AES_KEY_B64", base64.StdEncoding.EncodeToString([]byte("01234567890123456789012345678901"))), + privateKeyPath: os.Getenv("SIGNING_KEY_PATH"), + } +} + +// naloziAliUstvariZasebniKljucPEM prebere kljuc iz datoteke ali ga ustvari. +func naloziAliUstvariZasebniKljucPEM(path string) ([]byte, error) { + if path == "" { + // Če pot ni podana, ustvarimo ključ ob zagonu za enostaven lokalni test. + return appcrypto.UstvariZasebniKljucECDSAP256PEM() + } + return os.ReadFile(path) +} + +// preberiOkoljeAliPrivzeto vrne env vrednost ali podan fallback. +func preberiOkoljeAliPrivzeto(name, fallback string) string { + value := os.Getenv(name) + if value == "" { + return fallback + } + return value +} diff --git a/database/schema.sql b/database/schema.sql new file mode 100644 index 0000000..ae5ab16 --- /dev/null +++ b/database/schema.sql @@ -0,0 +1,6 @@ +CREATE TABLE IF NOT EXISTS status_lists ( + status_id TEXT PRIMARY KEY, + bit_length INTEGER NOT NULL, + data_encrypted BYTEA NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..1f13113 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,36 @@ +services: + db: + image: postgres:17-alpine + container_name: netis-db + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: postgres + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 5s + timeout: 5s + retries: 10 + + app: + build: + context: . + dockerfile: Dockerfile + container_name: netis-app + environment: + APP_ADDR: ":8000" + DATABASE_URL: "postgres://postgres:postgres@db:5432/postgres?sslmode=disable" + STATUS_AES_KEY_B64: "MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTIzNDU2Nzg5MDE=" + ports: + - "8000:8000" + depends_on: + db: + condition: service_healthy + restart: unless-stopped + +volumes: + postgres_data: diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..6bb2c96 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module netisjwt + +go 1.25.5 + +require github.com/lib/pq v1.11.2 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..de0b64e --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +github.com/lib/pq v1.11.2 h1:x6gxUeu39V0BHZiugWe8LXZYZ+Utk7hSJGThs8sdzfs= +github.com/lib/pq v1.11.2/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= diff --git a/internal/client/fetch_status.go b/internal/client/fetch_status.go new file mode 100644 index 0000000..72d9216 --- /dev/null +++ b/internal/client/fetch_status.go @@ -0,0 +1,102 @@ +package client + +import ( + "bytes" + "compress/gzip" + "context" + "encoding/base64" + "errors" + "fmt" + "io" + "net/http" + "strings" + "time" + + "netisjwt/internal/jws" +) + +// PridobiInPreveriStatus poklice GET endpoint, preveri JWS in vrne status. +func PridobiInPreveriStatus(ctx context.Context, targetURL string) (bool, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, targetURL, nil) + if err != nil { + return false, err + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return false, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return false, fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + rawToken, err := io.ReadAll(resp.Body) + if err != nil { + return false, err + } + token := strings.TrimSpace(string(rawToken)) + + claims, err := jws.PreveriZeton(token) + if err != nil { + return false, err + } + if err := jws.PreveriTrditve(claims, izdajateljIzURL(targetURL), time.Now()); err != nil { + return false, err + } + + values, err := dekodirajKodiranSeznam(claims.Status.EncodedList) + if err != nil { + return false, err + } + return preberiBit(values, claims.Status.Index) +} + +// izdajateljIzURL vrne url brez zadnjega segmenta index. +func izdajateljIzURL(url string) string { + parts := strings.Split(url, "/") + if len(parts) < 5 { + return "" + } + return strings.Join(parts[:len(parts)-1], "/") +} + +// dekodirajKodiranSeznam pretvori base64+gzip seznam v surove bajte. +func dekodirajKodiranSeznam(encoded string) ([]byte, error) { + compressed, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + return nil, err + } + + reader, err := newGzipBralnik(compressed) + if err != nil { + return nil, err + } + defer reader.Close() + + return io.ReadAll(reader) +} + +// preberiBit prebere en bit iz podanega bajtnega zaporedja. +func preberiBit(data []byte, index int) (bool, error) { + if index < 0 { + return false, errors.New("index must be >= 0") + } + byteIdx := index / 8 + if byteIdx >= len(data) { + return false, errors.New("index is out of encoded list range") + } + mask := byte(1 << (index % 8)) + return data[byteIdx]&mask != 0, nil +} + +type gzipReader interface { + io.Reader + Close() error +} + +// newGzipBralnik ustvari gzip reader iz podanih bajtov. +func newGzipBralnik(input []byte) (gzipReader, error) { + return gzip.NewReader(bytes.NewReader(input)) +} diff --git a/internal/client/fetch_status_test.go b/internal/client/fetch_status_test.go new file mode 100644 index 0000000..ccda80c --- /dev/null +++ b/internal/client/fetch_status_test.go @@ -0,0 +1,48 @@ +package client + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + appcrypto "netisjwt/internal/crypto" + "netisjwt/internal/jws" + "netisjwt/internal/statuslist" +) + +func TestFetchAndVerifyStatus(t *testing.T) { + privatePEM, err := appcrypto.UstvariZasebniKljucECDSAP256PEM() + if err != nil { + t.Fatalf("key gen: %v", err) + } + + list := statuslist.New() + list.Dodaj(false) + list.Dodaj(true) + + tokenPath := "/api/status/demo/1" + var baseURL string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + claims, err := jws.SestaviTrditve(baseURL+"/api/status/demo", list, 1, time.Now()) + if err != nil { + t.Fatalf("build claims: %v", err) + } + token, err := jws.UstvariZetonIzPEM(privatePEM, claims) + if err != nil { + t.Fatalf("create token: %v", err) + } + _, _ = w.Write([]byte(token)) + })) + defer server.Close() + baseURL = server.URL + + value, err := PridobiInPreveriStatus(context.Background(), server.URL+tokenPath) + if err != nil { + t.Fatalf("fetch and verify: %v", err) + } + if !value { + t.Fatalf("expected true") + } +} diff --git a/internal/crypto/keys.go b/internal/crypto/keys.go new file mode 100644 index 0000000..1f5bc56 --- /dev/null +++ b/internal/crypto/keys.go @@ -0,0 +1,109 @@ +package appcrypto + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/pem" + "errors" + "fmt" +) + +// Ustvari nov ECDSA P-256 zasebni kljuc v PEM obliki. +func UstvariZasebniKljucECDSAP256PEM() ([]byte, error) { + privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return nil, err + } + + der, err := x509.MarshalECPrivateKey(privateKey) + if err != nil { + return nil, err + } + + block := &pem.Block{ + Type: "EC PRIVATE KEY", + Bytes: der, + } + return pem.EncodeToMemory(block), nil +} + +// Podpisi sporocilo z zasebnim kljucem iz PEM in vrni Base64URL podpis. +func PodpisiSporociloSPEM(pemPrivateKey []byte, message []byte) (string, error) { + privateKey, err := PreberiZasebniKljucPEM(pemPrivateKey) + if err != nil { + return "", err + } + + hash := sha256.Sum256(message) + signature, err := ecdsa.SignASN1(rand.Reader, privateKey, hash[:]) + if err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(signature), nil +} + +// Preveri podpis sporocila z javnim delom zasebnega PEM kljuca. +func PreveriPodpisSPEM(pemPrivateKey []byte, message []byte, signatureBase64URL string) (bool, error) { + privateKey, err := PreberiZasebniKljucPEM(pemPrivateKey) + if err != nil { + return false, err + } + + signature, err := base64.RawURLEncoding.DecodeString(signatureBase64URL) + if err != nil { + return false, err + } + + hash := sha256.Sum256(message) + ok := ecdsa.VerifyASN1(&privateKey.PublicKey, hash[:], signature) + return ok, nil +} + +// Preberi ECDSA zasebni kljuc iz PEM zapisa. +func PreberiZasebniKljucPEM(pemPrivateKey []byte) (*ecdsa.PrivateKey, error) { + block, _ := pem.Decode(pemPrivateKey) + if block == nil { + return nil, errors.New("invalid PEM block") + } + if block.Type != "EC PRIVATE KEY" { + return nil, errors.New("PEM is not EC PRIVATE KEY") + } + return x509.ParseECPrivateKey(block.Bytes) +} + +// Preberi ECDSA javni kljuc iz PEM zapisa. +func PreberiJavniKljucPEM(pemPublicKey []byte) (*ecdsa.PublicKey, error) { + block, _ := pem.Decode(pemPublicKey) + if block == nil { + return nil, errors.New("invalid PEM block") + } + if block.Type != "PUBLIC KEY" { + return nil, errors.New("PEM is not PUBLIC KEY") + } + pubAny, err := x509.ParsePKIXPublicKey(block.Bytes) + if err != nil { + return nil, err + } + pub, ok := pubAny.(*ecdsa.PublicKey) + if !ok { + return nil, fmt.Errorf("public key is not ECDSA") + } + return pub, nil +} + +// Pretvori ECDSA javni kljuc v PEM zapis. +func PretvoriJavniKljucVPEM(publicKey *ecdsa.PublicKey) ([]byte, error) { + der, err := x509.MarshalPKIXPublicKey(publicKey) + if err != nil { + return nil, err + } + block := &pem.Block{ + Type: "PUBLIC KEY", + Bytes: der, + } + return pem.EncodeToMemory(block), nil +} diff --git a/internal/crypto/keys_test.go b/internal/crypto/keys_test.go new file mode 100644 index 0000000..68ceadd --- /dev/null +++ b/internal/crypto/keys_test.go @@ -0,0 +1,44 @@ +package appcrypto + +import "testing" + +func TestSignAndVerifyWithPEM(t *testing.T) { + privatePEM, err := UstvariZasebniKljucECDSAP256PEM() + if err != nil { + t.Fatalf("generate key: %v", err) + } + + message := []byte("hello world") + signature, err := PodpisiSporociloSPEM(privatePEM, message) + if err != nil { + t.Fatalf("sign: %v", err) + } + + ok, err := PreveriPodpisSPEM(privatePEM, message, signature) + if err != nil { + t.Fatalf("verify error: %v", err) + } + if !ok { + t.Fatalf("expected valid signature") + } +} + +func TestVerifyFailsForDifferentMessage(t *testing.T) { + privatePEM, err := UstvariZasebniKljucECDSAP256PEM() + if err != nil { + t.Fatalf("generate key: %v", err) + } + + signature, err := PodpisiSporociloSPEM(privatePEM, []byte("a")) + if err != nil { + t.Fatalf("sign: %v", err) + } + + ok, err := PreveriPodpisSPEM(privatePEM, []byte("b"), signature) + if err != nil { + t.Fatalf("verify error: %v", err) + } + if ok { + t.Fatalf("expected signature to fail") + } +} diff --git a/internal/httpapi/handlers.go b/internal/httpapi/handlers.go new file mode 100644 index 0000000..e19ee93 --- /dev/null +++ b/internal/httpapi/handlers.go @@ -0,0 +1,211 @@ +package httpapi + +import ( + "crypto/rand" + "encoding/base64" + "encoding/json" + "errors" + "net/http" + "strconv" + "strings" + "time" + + "netisjwt/internal/jws" + "netisjwt/internal/statuslist" + "netisjwt/internal/storage" +) + +type Handler struct { + store storage.Store + privatePEM []byte + now func() time.Time +} + +// NewObravnalnik ustvari HTTP obravnalnik za status API. +func NewObravnalnik(store storage.Store, privatePEM []byte) *Handler { + return &Handler{ + store: store, + privatePEM: privatePEM, + now: time.Now, + } +} + +// obravnavajStatusRoot obdela /api/status za GET in POST. +func (h *Handler) obravnavajStatusRoot(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + ids, err := h.store.SeznamIDjev(r.Context()) + if err != nil { + zapisiNapako(w, http.StatusInternalServerError, err) + return + } + zapisiJSON(w, http.StatusOK, map[string]any{"statusIds": ids}) + case http.MethodPost: + statusID, err := newStatusID() + if err != nil { + zapisiNapako(w, http.StatusInternalServerError, err) + return + } + if err := h.store.Ustvari(r.Context(), statusID, statuslist.New()); err != nil { + zapisiNapako(w, http.StatusInternalServerError, err) + return + } + zapisiJSON(w, http.StatusCreated, map[string]any{"statusId": statusID}) + default: + zapisiMethodNotAllowed(w) + } +} + +// obravnavajStatusPoPoti razcleni path parametre statusId in index. +func (h *Handler) obravnavajStatusPoPoti(w http.ResponseWriter, r *http.Request) { + path := strings.TrimPrefix(r.URL.Path, "/api/status/") + path = strings.Trim(path, "/") + if path == "" { + http.NotFound(w, r) + return + } + + parts := strings.Split(path, "/") + if len(parts) == 1 { + if r.Method != http.MethodPost { + zapisiMethodNotAllowed(w) + return + } + h.obravnavajDodajStatus(w, r, parts[0]) + return + } + if len(parts) == 2 { + index, err := strconv.Atoi(parts[1]) + if err != nil { + zapisiNapako(w, http.StatusBadRequest, errors.New("index must be integer")) + return + } + h.obravnavajIndeksiranStatus(w, r, parts[0], index) + return + } + http.NotFound(w, r) +} + +// obravnavajDodajStatus doda novo stanje v status listo. +func (h *Handler) obravnavajDodajStatus(w http.ResponseWriter, r *http.Request, statusID string) { + list, err := h.store.Dobi(r.Context(), statusID) + if errors.Is(err, storage.ErrNotFound) { + zapisiNapako(w, http.StatusNotFound, err) + return + } + if err != nil { + zapisiNapako(w, http.StatusInternalServerError, err) + return + } + + index := list.Dodaj(false) + if err := h.store.Posodobi(r.Context(), statusID, list); err != nil { + zapisiNapako(w, http.StatusInternalServerError, err) + return + } + zapisiJSON(w, http.StatusCreated, map[string]any{"index": index}) +} + +// obravnavajIndeksiranStatus vodi GET/PUT/DELETE za en status index. +func (h *Handler) obravnavajIndeksiranStatus(w http.ResponseWriter, r *http.Request, statusID string, index int) { + switch r.Method { + case http.MethodGet: + h.obravnavajDobiZeton(w, r, statusID, index) + case http.MethodPut: + h.obravnavajNastavi(w, r, statusID, index, true) + case http.MethodDelete: + h.obravnavajNastavi(w, r, statusID, index, false) + default: + zapisiMethodNotAllowed(w) + } +} + +// obravnavajNastavi nastavi vrednost enega status bita. +func (h *Handler) obravnavajNastavi(w http.ResponseWriter, r *http.Request, statusID string, index int, value bool) { + list, err := h.store.Dobi(r.Context(), statusID) + if errors.Is(err, storage.ErrNotFound) { + zapisiNapako(w, http.StatusNotFound, err) + return + } + if err != nil { + zapisiNapako(w, http.StatusInternalServerError, err) + return + } + if err := list.Nastavi(index, value); err != nil { + zapisiNapako(w, http.StatusBadRequest, err) + return + } + if err := h.store.Posodobi(r.Context(), statusID, list); err != nil { + zapisiNapako(w, http.StatusInternalServerError, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// obravnavajDobiZeton vrne podpisan JWS token za podan index. +func (h *Handler) obravnavajDobiZeton(w http.ResponseWriter, r *http.Request, statusID string, index int) { + list, err := h.store.Dobi(r.Context(), statusID) + if errors.Is(err, storage.ErrNotFound) { + zapisiNapako(w, http.StatusNotFound, err) + return + } + if err != nil { + zapisiNapako(w, http.StatusInternalServerError, err) + return + } + if _, err := list.Dobi(index); err != nil { + zapisiNapako(w, http.StatusBadRequest, err) + return + } + + issuer := izdajaIzdajatelja(r, statusID) + claims, err := jws.SestaviTrditve(issuer, list, index, h.now()) + if err != nil { + zapisiNapako(w, http.StatusInternalServerError, err) + return + } + token, err := jws.UstvariZetonIzPEM(h.privatePEM, claims) + if err != nil { + zapisiNapako(w, http.StatusInternalServerError, err) + return + } + + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(token)) +} + +// izdajaIzdajatelja pripravi iss vrednost za JWT trditve. +func izdajaIzdajatelja(r *http.Request, statusID string) string { + scheme := "http" + if r.TLS != nil { + scheme = "https" + } + return scheme + "://" + r.Host + "/api/status/" + statusID +} + +// newStatusID ustvari kratek URL-safe identifikator seznama. +func newStatusID() (string, error) { + raw := make([]byte, 6) + if _, err := rand.Read(raw); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(raw), nil +} + +// zapisiMethodNotAllowed vrne standarden odgovor 405. +func zapisiMethodNotAllowed(w http.ResponseWriter) { + zapisiNapako(w, http.StatusMethodNotAllowed, errors.New("method not allowed")) +} + +// zapisiNapako serializira napako v JSON odziv. +func zapisiNapako(w http.ResponseWriter, code int, err error) { + zapisiJSON(w, code, map[string]any{"error": err.Error()}) +} + +// zapisiJSON zapise poljuben objekt kot JSON odgovor. +func zapisiJSON(w http.ResponseWriter, code int, payload any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + _ = json.NewEncoder(w).Encode(payload) +} diff --git a/internal/httpapi/handlers_test.go b/internal/httpapi/handlers_test.go new file mode 100644 index 0000000..0c98b29 --- /dev/null +++ b/internal/httpapi/handlers_test.go @@ -0,0 +1,125 @@ +package httpapi + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + appcrypto "netisjwt/internal/crypto" + "netisjwt/internal/statuslist" + "netisjwt/internal/storage" +) + +type mockStore struct { + data map[string]*statuslist.StatusList +} + +func newMockStore() *mockStore { + return &mockStore{data: make(map[string]*statuslist.StatusList)} +} + +func (m *mockStore) Ustvari(_ context.Context, statusID string, list *statuslist.StatusList) error { + m.data[statusID] = mustCloneList(list) + return nil +} + +func (m *mockStore) SeznamIDjev(_ context.Context) ([]string, error) { + out := make([]string, 0, len(m.data)) + for id := range m.data { + out = append(out, id) + } + return out, nil +} + +func (m *mockStore) Dobi(_ context.Context, statusID string) (*statuslist.StatusList, error) { + list, ok := m.data[statusID] + if !ok { + return nil, storage.ErrNotFound + } + return mustCloneList(list), nil +} + +func (m *mockStore) Posodobi(_ context.Context, statusID string, list *statuslist.StatusList) error { + if _, ok := m.data[statusID]; !ok { + return storage.ErrNotFound + } + m.data[statusID] = mustCloneList(list) + return nil +} + +func mustCloneList(list *statuslist.StatusList) *statuslist.StatusList { + clone, err := statuslist.IzSurovihPodatkov(list.Bajti(), list.DolzinaBitov()) + if err != nil { + panic(err) + } + return clone +} + +func TestStatusFlow(t *testing.T) { + privatePEM, err := appcrypto.UstvariZasebniKljucECDSAP256PEM() + if err != nil { + t.Fatalf("key gen: %v", err) + } + + store := newMockStore() + handler := NewObravnalnik(store, privatePEM) + router := NewUsmerjevalnik(handler) + + statusID := createListAndReturnID(t, router) + createIndexAndSetTrue(t, router, statusID) + + req := httptest.NewRequest(http.MethodGet, "/api/status/"+statusID+"/0", nil) + req.Host = "localhost:8000" + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rec.Code) + } + token := strings.TrimSpace(rec.Body.String()) + if strings.Count(token, ".") != 2 { + t.Fatalf("expected compact JWS token") + } +} + +func createListAndReturnID(t *testing.T, router http.Handler) string { + t.Helper() + req := httptest.NewRequest(http.MethodPost, "/api/status", nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + if rec.Code != http.StatusCreated { + t.Fatalf("expected 201, got %d", rec.Code) + } + + var response struct { + StatusID string `json:"statusId"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil { + t.Fatalf("invalid json: %v", err) + } + if response.StatusID == "" { + t.Fatalf("expected non-empty statusId") + } + return response.StatusID +} + +func createIndexAndSetTrue(t *testing.T, router http.Handler, statusID string) { + t.Helper() + + addReq := httptest.NewRequest(http.MethodPost, "/api/status/"+statusID, nil) + addRec := httptest.NewRecorder() + router.ServeHTTP(addRec, addReq) + if addRec.Code != http.StatusCreated { + t.Fatalf("expected 201, got %d", addRec.Code) + } + + setReq := httptest.NewRequest(http.MethodPut, "/api/status/"+statusID+"/0", nil) + setRec := httptest.NewRecorder() + router.ServeHTTP(setRec, setReq) + if setRec.Code != http.StatusNoContent { + t.Fatalf("expected 204, got %d", setRec.Code) + } +} diff --git a/internal/httpapi/router.go b/internal/httpapi/router.go new file mode 100644 index 0000000..abd9b51 --- /dev/null +++ b/internal/httpapi/router.go @@ -0,0 +1,11 @@ +package httpapi + +import "net/http" + +// NewUsmerjevalnik pripravi vse API poti za status endpointe. +func NewUsmerjevalnik(handler *Handler) *http.ServeMux { + mux := http.NewServeMux() + mux.HandleFunc("/api/status", handler.obravnavajStatusRoot) + mux.HandleFunc("/api/status/", handler.obravnavajStatusPoPoti) + return mux +} diff --git a/internal/jws/token.go b/internal/jws/token.go new file mode 100644 index 0000000..7009675 --- /dev/null +++ b/internal/jws/token.go @@ -0,0 +1,195 @@ +package jws + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "math/big" + "strings" + "time" + + appcrypto "netisjwt/internal/crypto" + "netisjwt/internal/statuslist" +) + +type StatusClaim struct { + EncodedList string `json:"encodedList"` + Index int `json:"index"` +} + +type Claims struct { + Issuer string `json:"iss"` + IssuedAt int64 `json:"iat"` + ExpiresAt int64 `json:"exp"` + Status StatusClaim `json:"status"` +} + +type TokenHeader struct { + Algorithm string `json:"alg"` + Type string `json:"typ"` + JWK JWKValue `json:"jwk"` +} + +type JWKValue struct { + Kty string `json:"kty"` + Crv string `json:"crv"` + X string `json:"x"` + Y string `json:"y"` +} + +// SestaviTrditve pripravi JWT payload za status endpoint. +func SestaviTrditve(issuer string, list *statuslist.StatusList, index int, now time.Time) (Claims, error) { + encoded, err := list.KodiranSeznam() + if err != nil { + return Claims{}, err + } + return Claims{ + Issuer: issuer, + IssuedAt: now.Unix(), + ExpiresAt: now.Add(24 * time.Hour).Unix(), + Status: StatusClaim{ + EncodedList: encoded, + Index: index, + }, + }, nil +} + +// UstvariZetonIzPEM ustvari JWS compact token iz PEM kljuca. +func UstvariZetonIzPEM(privatePEM []byte, claims Claims) (string, error) { + privateKey, err := appcrypto.PreberiZasebniKljucPEM(privatePEM) + if err != nil { + return "", err + } + return UstvariZeton(privateKey, claims) +} + +// UstvariZeton ustvari JWS compact token iz ECDSA kljuca. +func UstvariZeton(privateKey *ecdsa.PrivateKey, claims Claims) (string, error) { + header := TokenHeader{ + Algorithm: "ES256", + Type: "JWT", + JWK: javniKljucVJWK(&privateKey.PublicKey), + } + + headerPart, err := kodirajDel(header) + if err != nil { + return "", err + } + claimsPart, err := kodirajDel(claims) + if err != nil { + return "", err + } + + signingInput := headerPart + "." + claimsPart + hash := sha256.Sum256([]byte(signingInput)) + signatureRaw, err := ecdsa.SignASN1(rand.Reader, privateKey, hash[:]) + if err != nil { + return "", err + } + signature := base64.RawURLEncoding.EncodeToString(signatureRaw) + return signingInput + "." + signature, nil +} + +// PreveriZeton preveri podpis in vrne dekodirane trditve. +func PreveriZeton(token string) (Claims, error) { + parts := strings.Split(token, ".") + if len(parts) != 3 { + return Claims{}, errors.New("token must have 3 parts") + } + + headerBytes, err := base64.RawURLEncoding.DecodeString(parts[0]) + if err != nil { + return Claims{}, err + } + claimsBytes, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return Claims{}, err + } + + var header TokenHeader + if err := json.Unmarshal(headerBytes, &header); err != nil { + return Claims{}, err + } + var claims Claims + if err := json.Unmarshal(claimsBytes, &claims); err != nil { + return Claims{}, err + } + + pub, err := jwkVJavniKljuc(header.JWK) + if err != nil { + return Claims{}, err + } + signature, err := base64.RawURLEncoding.DecodeString(parts[2]) + if err != nil { + return Claims{}, err + } + + hash := sha256.Sum256([]byte(parts[0] + "." + parts[1])) + if !ecdsa.VerifyASN1(pub, hash[:], signature) { + return Claims{}, errors.New("invalid token signature") + } + return claims, nil +} + +// PreveriTrditve preveri veljavnost iat, exp in iss vrednosti. +func PreveriTrditve(claims Claims, expectedIssuer string, now time.Time) error { + if expectedIssuer != "" && claims.Issuer != expectedIssuer { + return fmt.Errorf("invalid issuer") + } + nowUnix := now.Unix() + if claims.IssuedAt > nowUnix { + return errors.New("iat is in future") + } + if claims.ExpiresAt < nowUnix { + return errors.New("token expired") + } + return nil +} + +// kodirajDel serializira del tokena v Base64URL JSON. +func kodirajDel(v any) (string, error) { + raw, err := json.Marshal(v) + if err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(raw), nil +} + +// javniKljucVJWK pretvori ECDSA javni kljuc v JWK zapis. +func javniKljucVJWK(pub *ecdsa.PublicKey) JWKValue { + return JWKValue{ + Kty: "EC", + Crv: "P-256", + X: base64.RawURLEncoding.EncodeToString(pub.X.FillBytes(make([]byte, 32))), + Y: base64.RawURLEncoding.EncodeToString(pub.Y.FillBytes(make([]byte, 32))), + } +} + +// jwkVJavniKljuc pretvori JWK zapis v ECDSA javni kljuc. +func jwkVJavniKljuc(jwk JWKValue) (*ecdsa.PublicKey, error) { + if jwk.Kty != "EC" || jwk.Crv != "P-256" { + return nil, errors.New("unsupported key type") + } + xBytes, err := base64.RawURLEncoding.DecodeString(jwk.X) + if err != nil { + return nil, err + } + yBytes, err := base64.RawURLEncoding.DecodeString(jwk.Y) + if err != nil { + return nil, err + } + pub := &ecdsa.PublicKey{ + Curve: elliptic.P256(), + X: new(big.Int).SetBytes(xBytes), + Y: new(big.Int).SetBytes(yBytes), + } + if !pub.Curve.IsOnCurve(pub.X, pub.Y) { + return nil, errors.New("jwk is not on P-256 curve") + } + return pub, nil +} diff --git a/internal/jws/token_test.go b/internal/jws/token_test.go new file mode 100644 index 0000000..c6669fe --- /dev/null +++ b/internal/jws/token_test.go @@ -0,0 +1,48 @@ +package jws + +import ( + "testing" + "time" + + appcrypto "netisjwt/internal/crypto" + "netisjwt/internal/statuslist" +) + +func TestCreateAndVerifyToken(t *testing.T) { + privatePEM, err := appcrypto.UstvariZasebniKljucECDSAP256PEM() + if err != nil { + t.Fatalf("key gen: %v", err) + } + + list := statuslist.New() + list.Dodaj(true) + claims, err := SestaviTrditve("http://localhost/api/status/abc", list, 0, time.Now()) + if err != nil { + t.Fatalf("build claims: %v", err) + } + + token, err := UstvariZetonIzPEM(privatePEM, claims) + if err != nil { + t.Fatalf("create token: %v", err) + } + + got, err := PreveriZeton(token) + if err != nil { + t.Fatalf("verify token: %v", err) + } + if got.Status.Index != 0 { + t.Fatalf("expected index 0, got %d", got.Status.Index) + } +} + +func TestValidateClaimsExpired(t *testing.T) { + claims := Claims{ + Issuer: "http://localhost", + IssuedAt: time.Now().Add(-2 * time.Hour).Unix(), + ExpiresAt: time.Now().Add(-1 * time.Hour).Unix(), + } + err := PreveriTrditve(claims, "http://localhost", time.Now()) + if err == nil { + t.Fatalf("expected expiration error") + } +} diff --git a/internal/statuslist/list.go b/internal/statuslist/list.go new file mode 100644 index 0000000..b4ecadc --- /dev/null +++ b/internal/statuslist/list.go @@ -0,0 +1,125 @@ +package statuslist + +import ( + "bytes" + "compress/gzip" + "encoding/base64" + "errors" +) + +var ( + ErrIndexOutOfRange = errors.New("index out of range") +) + +type StatusList struct { + data []byte + bitLength int +} + +// New vrne prazno bitno listo statusov. +func New() *StatusList { + return &StatusList{ + data: make([]byte, 0), + bitLength: 0, + } +} + +// IzSurovihPodatkov zgradi listo iz bajtov in dolzine v bitih. +func IzSurovihPodatkov(data []byte, bitLength int) (*StatusList, error) { + if bitLength < 0 { + return nil, errors.New("bitLength must be >= 0") + } + minBytes := steviloBajtovZaBite(bitLength) + if len(data) < minBytes { + return nil, errors.New("invalid data size for bitLength") + } + + cloned := make([]byte, minBytes) + copy(cloned, data[:minBytes]) + return &StatusList{data: cloned, bitLength: bitLength}, nil +} + +// Dodaj doda novo stanje in vrne njegov index. +func (s *StatusList) Dodaj(value bool) int { + index := s.bitLength + s.zagotoviIndex(index) + s.nastaviBit(index, value) + s.bitLength++ + return index +} + +// Nastavi spremeni obstojece stanje na podanem indexu. +func (s *StatusList) Nastavi(index int, value bool) error { + if index < 0 || index >= s.bitLength { + return ErrIndexOutOfRange + } + s.nastaviBit(index, value) + return nil +} + +// Dobi vrne stanje na podanem indexu. +func (s *StatusList) Dobi(index int) (bool, error) { + if index < 0 || index >= s.bitLength { + return false, ErrIndexOutOfRange + } + return s.dobiBit(index), nil +} + +// DolzinaBitov vrne stevilo aktivnih statusov. +func (s *StatusList) DolzinaBitov() int { + return s.bitLength +} + +// Bajti vrne kopijo notranjih bajtov liste. +func (s *StatusList) Bajti() []byte { + out := make([]byte, len(s.data)) + copy(out, s.data) + return out +} + +// KodiranSeznam vrne base64(gzip(byteArray)) predstavitev liste. +func (s *StatusList) KodiranSeznam() (string, error) { + var buf bytes.Buffer + zip := gzip.NewWriter(&buf) + if _, err := zip.Write(s.data); err != nil { + return "", err + } + if err := zip.Close(); err != nil { + return "", err + } + return base64.StdEncoding.EncodeToString(buf.Bytes()), nil +} + +// zagotoviIndex razsiri notranji buffer za podani index. +func (s *StatusList) zagotoviIndex(index int) { + required := steviloBajtovZaBite(index + 1) + for len(s.data) < required { + s.data = append(s.data, 0) + } +} + +// nastaviBit vklopi ali izklopi bit na podanem indexu. +func (s *StatusList) nastaviBit(index int, value bool) { + byteIdx := index / 8 + mask := byte(1 << (index % 8)) + if value { + s.data[byteIdx] |= mask + return + } + s.data[byteIdx] &^= mask +} + +// dobiBit prebere bit na podanem indexu. +func (s *StatusList) dobiBit(index int) bool { + byteIdx := index / 8 + mask := byte(1 << (index % 8)) + return s.data[byteIdx]&mask != 0 +} + +// steviloBajtovZaBite izracuna koliko bajtov potrebujemo za bite. +func steviloBajtovZaBite(bitLength int) int { + if bitLength == 0 { + return 0 + } + return (bitLength + 7) / 8 +} diff --git a/internal/statuslist/list_test.go b/internal/statuslist/list_test.go new file mode 100644 index 0000000..feaace9 --- /dev/null +++ b/internal/statuslist/list_test.go @@ -0,0 +1,51 @@ +package statuslist + +import ( + "encoding/base64" + "testing" +) + +func TestAppendSetGet(t *testing.T) { + list := New() + if idx := list.Dodaj(false); idx != 0 { + t.Fatalf("expected index 0, got %d", idx) + } + if idx := list.Dodaj(true); idx != 1 { + t.Fatalf("expected index 1, got %d", idx) + } + + if err := list.Nastavi(0, true); err != nil { + t.Fatalf("set error: %v", err) + } + + v0, err := list.Dobi(0) + if err != nil { + t.Fatalf("get error: %v", err) + } + if !v0 { + t.Fatalf("expected true on index 0") + } + + v1, err := list.Dobi(1) + if err != nil { + t.Fatalf("get error: %v", err) + } + if !v1 { + t.Fatalf("expected true on index 1") + } +} + +func TestEncodedListProducesBase64(t *testing.T) { + list := New() + list.Dodaj(true) + list.Dodaj(false) + list.Dodaj(true) + + encoded, err := list.KodiranSeznam() + if err != nil { + t.Fatalf("encoded list error: %v", err) + } + if _, err := base64.StdEncoding.DecodeString(encoded); err != nil { + t.Fatalf("expected valid base64: %v", err) + } +} diff --git a/internal/storage/migrations.go b/internal/storage/migrations.go new file mode 100644 index 0000000..8d9c643 --- /dev/null +++ b/internal/storage/migrations.go @@ -0,0 +1,10 @@ +package storage + +const SchemaSQL = ` +CREATE TABLE IF NOT EXISTS status_lists ( + status_id TEXT PRIMARY KEY, + bit_length INTEGER NOT NULL, + data_encrypted BYTEA NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +` diff --git a/internal/storage/postgres.go b/internal/storage/postgres.go new file mode 100644 index 0000000..a4891be --- /dev/null +++ b/internal/storage/postgres.go @@ -0,0 +1,167 @@ +package storage + +import ( + "context" + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "database/sql" + "encoding/base64" + "errors" + "fmt" + "io" + + _ "github.com/lib/pq" + + "netisjwt/internal/statuslist" +) + +var ErrNotFound = errors.New("status list not found") + +type PostgresStore struct { + db *sql.DB + gc cipher.AEAD +} + +// NewPostgresStore ustvari repozitorij s pripravljeno AES-GCM sifro. +func NewPostgresStore(db *sql.DB, aesKeyBase64 string) (*PostgresStore, error) { + key, err := base64.StdEncoding.DecodeString(aesKeyBase64) + if err != nil { + return nil, fmt.Errorf("invalid STATUS_AES_KEY_B64: %w", err) + } + block, err := aes.NewCipher(key) + if err != nil { + return nil, fmt.Errorf("invalid AES key: %w", err) + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + return &PostgresStore{db: db, gc: gcm}, nil +} + +// PoveziInMigriraj odpre povezavo na bazo in izvede schema migracijo. +func PoveziInMigriraj(ctx context.Context, dsn string, aesKeyBase64 string) (*PostgresStore, error) { + db, err := sql.Open("postgres", dsn) + if err != nil { + return nil, err + } + if err := db.PingContext(ctx); err != nil { + return nil, err + } + if _, err := db.ExecContext(ctx, SchemaSQL); err != nil { + return nil, err + } + return NewPostgresStore(db, aesKeyBase64) +} + +// Zapri zapre povezavo na podatkovno bazo. +func (s *PostgresStore) Zapri() error { + return s.db.Close() +} + +// Ustvari shrani novo status listo. +func (s *PostgresStore) Ustvari(ctx context.Context, statusID string, list *statuslist.StatusList) error { + encrypted, err := s.sifriraj(list.Bajti()) + if err != nil { + return err + } + _, err = s.db.ExecContext( + ctx, + `INSERT INTO status_lists(status_id, bit_length, data_encrypted) VALUES ($1, $2, $3)`, + statusID, + list.DolzinaBitov(), + encrypted, + ) + return err +} + +// SeznamIDjev vrne vse shranjene id-je seznamov. +func (s *PostgresStore) SeznamIDjev(ctx context.Context) ([]string, error) { + rows, err := s.db.QueryContext(ctx, `SELECT status_id FROM status_lists ORDER BY created_at ASC`) + if err != nil { + return nil, err + } + defer rows.Close() + + out := make([]string, 0) + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + return nil, err + } + out = append(out, id) + } + return out, rows.Err() +} + +// Dobi prebere status listo po statusID. +func (s *PostgresStore) Dobi(ctx context.Context, statusID string) (*statuslist.StatusList, error) { + var bitLength int + var encrypted []byte + err := s.db.QueryRowContext( + ctx, + `SELECT bit_length, data_encrypted FROM status_lists WHERE status_id = $1`, + statusID, + ).Scan(&bitLength, &encrypted) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound + } + if err != nil { + return nil, err + } + + raw, err := s.desifriraj(encrypted) + if err != nil { + return nil, err + } + return statuslist.IzSurovihPodatkov(raw, bitLength) +} + +// Posodobi prepiše obstoječo status listo. +func (s *PostgresStore) Posodobi(ctx context.Context, statusID string, list *statuslist.StatusList) error { + encrypted, err := s.sifriraj(list.Bajti()) + if err != nil { + return err + } + + res, err := s.db.ExecContext( + ctx, + `UPDATE status_lists SET bit_length = $2, data_encrypted = $3 WHERE status_id = $1`, + statusID, + list.DolzinaBitov(), + encrypted, + ) + if err != nil { + return err + } + affected, err := res.RowsAffected() + if err != nil { + return err + } + if affected == 0 { + return ErrNotFound + } + return nil +} + +// sifriraj zasciti bajte z AES-GCM in pripne nonce. +func (s *PostgresStore) sifriraj(plain []byte) ([]byte, error) { + nonce := make([]byte, s.gc.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return nil, err + } + ciphertext := s.gc.Seal(nil, nonce, plain, nil) + return append(nonce, ciphertext...), nil +} + +// desifriraj obnovi originalne bajte iz AES-GCM payloada. +func (s *PostgresStore) desifriraj(encrypted []byte) ([]byte, error) { + nonceSize := s.gc.NonceSize() + if len(encrypted) < nonceSize { + return nil, errors.New("encrypted payload too short") + } + nonce := encrypted[:nonceSize] + ciphertext := encrypted[nonceSize:] + return s.gc.Open(nil, nonce, ciphertext, nil) +} diff --git a/internal/storage/store.go b/internal/storage/store.go new file mode 100644 index 0000000..0df13f5 --- /dev/null +++ b/internal/storage/store.go @@ -0,0 +1,14 @@ +package storage + +import ( + "context" + + "netisjwt/internal/statuslist" +) + +type Store interface { + Ustvari(ctx context.Context, statusID string, list *statuslist.StatusList) error + SeznamIDjev(ctx context.Context) ([]string, error) + Dobi(ctx context.Context, statusID string) (*statuslist.StatusList, error) + Posodobi(ctx context.Context, statusID string, list *statuslist.StatusList) error +}