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