This commit is contained in:
h4nz4
2026-03-12 20:09:16 +01:00
commit f9b7eaa20d
23 changed files with 1481 additions and 0 deletions

12
.dockerignore Normal file
View File

@@ -0,0 +1,12 @@
.git
.cursor
bin
dist
tmp
coverage
*.log
*.out
agent-transcripts
TASK.txt

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
bin

18
Dockerfile Normal file
View File

@@ -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"]

65
README.md Normal file
View File

@@ -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/<statusId>
```
Nastavi status na `true`:
```bash
curl -X PUT http://localhost:8000/api/status/<statusId>/<index>
```
Nastavi status na `false`:
```bash
curl -X DELETE http://localhost:8000/api/status/<statusId>/<index>
```
Vrni podpisan JWS:
```bash
curl http://localhost:8000/api/status/<statusId>/<index>
```
## 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).

76
cmd/server/main.go Normal file
View File

@@ -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
}

6
database/schema.sql Normal file
View File

@@ -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()
);

36
docker-compose.yml Normal file
View File

@@ -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:

5
go.mod Normal file
View File

@@ -0,0 +1,5 @@
module netisjwt
go 1.25.5
require github.com/lib/pq v1.11.2

2
go.sum Normal file
View File

@@ -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=

View File

@@ -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))
}

View File

@@ -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")
}
}

109
internal/crypto/keys.go Normal file
View File

@@ -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
}

View File

@@ -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")
}
}

View File

@@ -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)
}

View File

@@ -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)
}
}

View File

@@ -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
}

195
internal/jws/token.go Normal file
View File

@@ -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
}

View File

@@ -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")
}
}

125
internal/statuslist/list.go Normal file
View File

@@ -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
}

View File

@@ -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)
}
}

View File

@@ -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()
);
`

View File

@@ -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)
}

14
internal/storage/store.go Normal file
View File

@@ -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
}