Initial
This commit is contained in:
12
.dockerignore
Normal file
12
.dockerignore
Normal file
@@ -0,0 +1,12 @@
|
||||
.git
|
||||
.cursor
|
||||
bin
|
||||
dist
|
||||
tmp
|
||||
coverage
|
||||
|
||||
*.log
|
||||
*.out
|
||||
|
||||
agent-transcripts
|
||||
TASK.txt
|
||||
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
bin
|
||||
18
Dockerfile
Normal file
18
Dockerfile
Normal 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
65
README.md
Normal 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
76
cmd/server/main.go
Normal 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
6
database/schema.sql
Normal 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
36
docker-compose.yml
Normal 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
5
go.mod
Normal file
@@ -0,0 +1,5 @@
|
||||
module netisjwt
|
||||
|
||||
go 1.25.5
|
||||
|
||||
require github.com/lib/pq v1.11.2
|
||||
2
go.sum
Normal file
2
go.sum
Normal 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=
|
||||
102
internal/client/fetch_status.go
Normal file
102
internal/client/fetch_status.go
Normal 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))
|
||||
}
|
||||
48
internal/client/fetch_status_test.go
Normal file
48
internal/client/fetch_status_test.go
Normal 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
109
internal/crypto/keys.go
Normal 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
|
||||
}
|
||||
44
internal/crypto/keys_test.go
Normal file
44
internal/crypto/keys_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
211
internal/httpapi/handlers.go
Normal file
211
internal/httpapi/handlers.go
Normal 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)
|
||||
}
|
||||
125
internal/httpapi/handlers_test.go
Normal file
125
internal/httpapi/handlers_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
11
internal/httpapi/router.go
Normal file
11
internal/httpapi/router.go
Normal 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
195
internal/jws/token.go
Normal 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
|
||||
}
|
||||
48
internal/jws/token_test.go
Normal file
48
internal/jws/token_test.go
Normal 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
125
internal/statuslist/list.go
Normal 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
|
||||
}
|
||||
51
internal/statuslist/list_test.go
Normal file
51
internal/statuslist/list_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
10
internal/storage/migrations.go
Normal file
10
internal/storage/migrations.go
Normal 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()
|
||||
);
|
||||
`
|
||||
167
internal/storage/postgres.go
Normal file
167
internal/storage/postgres.go
Normal 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
14
internal/storage/store.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user