chore: finalize alpha-0.0.1 and freeze core engine

This commit is contained in:
Kaiy Belist 2026-03-11 16:00:34 +05:00
parent d8f48970c2
commit 497f188c89
29 changed files with 1104 additions and 428 deletions

View File

@ -1,11 +1,11 @@
root = "."
testdata_dir = "testdata"
tmp_dir = "tmp"
tmp_dir = "build"
[build]
args_bin = []
bin = "./tmp/main"
cmd = "go build -o ./tmp/main ./internal/cmd/server/"
bin = "make run"
cmd = "make build"
delay = 1000
exclude_dir = ["assets", "tmp", "vendor", "testdata"]
exclude_file = []

2
.gitignore vendored
View File

@ -2,4 +2,4 @@ tmp/
.env
config.yaml
config.yml
build

32
Dockerfile Normal file
View File

@ -0,0 +1,32 @@
FROM golang:1.25-alpine AS builder
WORKDIR /app
RUN apk add --no-cache make
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN make build-static \
&& cp build/clavis /clavis \
&& chmod +x /clavis
FROM alpine:latest AS production
WORKDIR /app
COPY --from=builder /clavis /app/clavis
RUN apk update --no-cache && apk add --no-cache ca-certificates
ENV LISTEN_ADDRESS=0.0.0.0 \
LISTEN_PORT=3000 \
POSTGRES_USER=root \
POSTGRES_PASSWORD=Ch@nG!E.ME!!\
POSTGRES_ADDRESS=postgresql \
POSTGRES_PORT=5432 \
POSTGRES_DATABASE="clavis" \
VERIFY_DEFAULT=true
CMD ["/app/clavis"]

10
Makefile Normal file
View File

@ -0,0 +1,10 @@
build:
go build -o build/clavis ./internal/cmd/server/
build-static:
CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o build/clavis ./internal/cmd/server
run: build
./build/clavis

74
README.md Normal file
View File

@ -0,0 +1,74 @@
# HKP-Clavis
HKP-Clavis was born out of a desire for simplicity. I found existing solutions like hkp-hagrid to be over-engineered and difficult to administer for smaller, focused deployments. My goal was to create a "minimally stable" implementation of an HKP server that does exactly what is needed—no more, no less—while maintaining high performance.
- Note on Storage: During testing, it became clear that handling large keys (>2MB) directly in PostgreSQL isn't ideal. In the future, I plan to add S3/FileStorage support for blobs to keep the database lean and mean
## 🚀 Features
* **HKP Protocol Support**: Implements core `add`, `get`, and `index` operations.
* **High-Performance Architecture**:
* **Object Pooling**: Uses `sync.Pool` via a custom `KeyManager` to reuse `PGPKey` and `PGPUid` objects, significantly reducing GC pressure.
* **Efficient Memory Allocation**: Controller and Service layers are built to minimize allocations during key parsing and transformation.
* **Database Optimization**:
* **PostgreSQL Backend**: Utilizes `pgx/v5` for robust connection pooling.
* **Security & Sanitization**:
* **Verification-Aware**: Only returns UIDs that have been marked as verified in the database.
* **Key Sanitization**: Automatically filters out sensitive or unverified metadata before serving keys.
## 🛠 Tech Stack
* **Language**: Go 1.22+
* **Database**: PostgreSQL
* **Library**: `ProtonMail/go-crypto` (for OpenPGP operations)
* **Driver**: `pgx/v5`
## 📋 Backlog & Future Roadmap
The project is currently in a "feature freeze" for the core engine. The following features are planned for future development:
### 1. 🌐 GOTH Stack Web Interface
* **Frontend**: Implement a lightweight web UI using **Go Templates + HTMX** (The GOTH Stack).
* **Search**: A user-friendly search bar for finding keys without using the CLI.
* **Stats**: A dashboard showing server uptime, key count, and pool utilization metrics.
* **Kick GPG**: Debug. why key.openpgp.org server (hagrid) and clavis not work with `gpg --recv-key` T__T
### 2. 🗄️ Hybrid Storage & Large Payload Handling
* **Blob Storage Integration**: Implement an external Blob Storage (e.g., MinIO, S3, or local disk) for keys exceeding **1MB** in size to keep the PostgreSQL database lean.
* **Metadata/Binary Split**: Store key metadata in Postgres and the actual `.asc` packets in Blob storage.
### 3. 📧 Email Verification System
* **SMTP Service**: Activate the `SMTPPool` for sending verification emails.
* **Template Engine**: Support for both HTML and Plaintext email templates (required for classic PGP users).
* **Verification Route**: Implement the `GET /verify` endpoint to process tokens and update UID status.
### 4. 🛡️ System Hardening
* **Rate Limiting**: Add middleware to prevent brute-force key uploads or search "scraping".
* **SKS Peering**: Support for the synchronization protocol to federate with other global keyservers.
## ⚙️ Configuration
The application is configured via environment variables:
| Variable | Description |
| :--- | :--- |
| `LISTEN_ADDRESS` | Host to bind the server (e.g., `localhost`) |
| `LISTEN_PORT` | Port for the HKP service (e.g., `8181`) |
| `POSTGRES_USER` | Database user |
| `POSTGRES_PASSWORD` | Database password |
| `POSTGRES_ADDRESS` | Database host |
| `POSTGRES_PORT` | Database port |
| `POSTGRES_DATABASE` | Database name |
| `VERIFY_DEFAULT` | If `true`, new keys are marked as verified immediately (Dev mode) |
ps: verify by smtp not implemented `VERIFY_DEFAULT=true` is recommended
## 📦 Installation & Usage
```bash
# Build the binary
go build -o build/hkp-clavis internal/cmd/server/
# Run the server (ensure env vars are set)
./build/hkp-clavis

View File

@ -1,9 +1,41 @@
services:
postgres_test:
postgres:
image: postgres:17-alpine
container_name: clavis_db
environment:
POSTGRES_DB: test_hagrid_db
POSTGRES_USER: test_user
POSTGRES_PASSWORD: test_password
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- postgres_data:/var/lib/postgresql/data
# ports:
# - "${POSTGRES_PORT}:${POSTGRES_PORT}"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 5s
timeout: 5s
retries: 5
clavis:
build:
context: .
dockerfile: Dockerfile
container_name: clavis_app
depends_on:
postgres:
condition: service_healthy
environment:
- LISTEN_ADDRESS=${LISTEN_ADDRESS}
- LISTEN_PORT=${LISTEN_PORT}
- POSTGRES_USER=${POSTGRES_USER}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
- POSTGRES_ADDRESS=${POSTGRES_HOST}
- POSTGRES_PORT=${POSTGRES_PORT}
- POSTGRES_DATABASE=${POSTGRES_DB}
- VERIFY_DEFAULT=${VERIFY_DEFAULT}
ports:
- "5433:5432"
- "${EXTERNAL_PORT}:${LISTEN_PORT}"
restart: always
volumes:
postgres_data:

4
go.mod
View File

@ -1,4 +1,4 @@
module gadrid
module hkp-clavis
go 1.24.4
@ -6,6 +6,8 @@ require (
github.com/ProtonMail/go-crypto v1.3.0
github.com/emersion/go-openpgp-hkp v0.0.0-20231106091114-bb7c1dad7252
github.com/jackc/pgx/v5 v5.7.5
github.com/joho/godotenv v1.5.1
github.com/lmittmann/tint v1.1.3
github.com/stretchr/testify v1.8.1
)

4
go.sum
View File

@ -16,10 +16,14 @@ github.com/jackc/pgx/v5 v5.7.5 h1:JHGfMnQY+IEtGM63d+NGMjoRpysB2JBwDr5fsngwmJs=
github.com/jackc/pgx/v5 v5.7.5/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lmittmann/tint v1.1.3 h1:Hv4EaHWXQr+GTFnOU4VKf8UvAtZgn0VuKT+G0wFlO3I=
github.com/lmittmann/tint v1.1.3/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=

View File

@ -3,43 +3,105 @@ package main
import (
"context"
"fmt"
"log"
"log/slog"
"net/http"
"os"
"strconv"
"gadrid/internal/repo/storage/postgresql"
storageService "gadrid/internal/service/storage/serv"
controllerHkp "hkp-clavis/internal/controller/hkp"
"hkp-clavis/internal/model"
"hkp-clavis/internal/repo/storage/postgresql"
storageService "hkp-clavis/internal/service/storage/serv"
hkp "github.com/emersion/go-openpgp-hkp"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/joho/godotenv"
"github.com/lmittmann/tint"
)
const (
listenAddress = "localhost:8181"
)
var Logger *slog.Logger
func main() {
dbpool, err := pgxpool.New(context.TODO(), "postgresql://test_user:test_password@localhost:5433/test_hagrid_db")
Logger = slog.New(tint.NewHandler(os.Stdout, nil))
if err := godotenv.Load(); err != nil {
log.Println("No .env file found, reading from system environment")
}
listenAddress := os.Getenv("LISTEN_ADDRESS")
listenPort := os.Getenv("LISTEN_PORT")
listenAddress = fmt.Sprintf("%s:%s", listenAddress, listenPort)
pgUser := os.Getenv("POSTGRES_USER")
pgPassword := os.Getenv("POSTGRES_PASSWORD")
pgAddress := os.Getenv("POSTGRES_ADDRESS")
pgPort := os.Getenv("POSTGRES_PORT")
pgDatabase := os.Getenv("POSTGRES_DATABASE")
// By default all uids in key does not verify
// For dev/debug you can set it to true
verify := os.Getenv("VERIFY_DEFAULT")
defaultVerify := getEnvAsBool(verify, true)
pgUri := fmt.Sprintf(
"postgresql://%s:%s@%s:%s/%s",
pgUser,
pgPassword,
pgAddress,
pgPort,
pgDatabase)
// For memory allocation controller
keyMngr := model.NewKeyManager()
dbpool, err := pgxpool.New(context.TODO(), pgUri)
if err != nil {
panic(err)
}
dbRepo := postgresql.New(context.TODO(), dbpool)
storageService := storageService.New(dbRepo)
db := postgresql.New(context.TODO(), dbpool, keyMngr)
storage := storageService.New(db, keyMngr, defaultVerify)
handler := hkp.Handler{
Adder: storageService,
Lookuper: storageService,
controller, err := controllerHkp.New(storage)
if err != nil {
panic(err)
}
http.HandleFunc("/pks/lookup", func(w http.ResponseWriter, r *http.Request) {
handler.ServeHTTP(w, r)
mux := http.NewServeMux()
hkpHandler := hkp.Handler{
Adder: controller,
Lookuper: controller,
}
mux.HandleFunc("/pks/lookup", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/pgp-keys")
w.Header().Set("Connection", "close")
w.Header().Set("Cache-Control", "no-cache")
hkpHandler.ServeHTTP(w, r)
})
http.HandleFunc("/pks/add", func(w http.ResponseWriter, r *http.Request) {
handler.ServeHTTP(w, r)
mux.HandleFunc("/pks/add", func(w http.ResponseWriter, r *http.Request) {
hkpHandler.ServeHTTP(w, r)
})
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, "Index Page")
})
fmt.Println("Server is listening on address:", listenAddress)
http.ListenAndServe(listenAddress, nil)
handler := LogMiddleware(mux)
Logger.Info("Server is listening on address: %s", handler)
http.ListenAndServe(listenAddress, handler)
}
func getEnvAsBool(name string, defaultVal bool) bool {
valStr, exists := os.LookupEnv(name) // Use LookupEnv for better checking
if !exists || valStr == "" {
return defaultVal
}
if val, err := strconv.ParseBool(valStr); err == nil {
return val
}
return defaultVal
}

View File

@ -0,0 +1,69 @@
package main
import (
"log/slog"
"net/http"
"strings"
"sync"
"time"
)
type responseWriter struct {
http.ResponseWriter
statusCode int
}
func (rw *responseWriter) WriteHeader(code int) {
rw.statusCode = code
rw.ResponseWriter.WriteHeader(code)
}
func (rw *responseWriter) Write(b []byte) (int, error) {
if rw.statusCode == 0 {
rw.statusCode = http.StatusOK
}
return rw.ResponseWriter.Write(b)
}
var rwPool = sync.Pool{
New: func() any {
return &responseWriter{}
},
}
// LogMiddleware
func LogMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rw := rwPool.Get().(*responseWriter)
defer func() {
rw.ResponseWriter = nil
rwPool.Put(rw)
}()
rw.ResponseWriter = w
// rw.statusCode = http.StatusOK
next.ServeHTTP(rw, r)
Logger.Info("request handled",
slog.String("ip", getIP(r)),
slog.String("method", r.Method),
slog.Int("status", rw.statusCode),
slog.String("path", r.URL.Path),
slog.Duration("duration", time.Since(start)),
)
})
}
func getIP(r *http.Request) string {
if ip := r.Header.Get("X-Real-IP"); ip != "" {
return ip
}
if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" {
if i := strings.Index(forwarded, ","); i != -1 {
return forwarded[:i]
}
return forwarded
}
return r.RemoteAddr
}

View File

@ -1,7 +1,11 @@
package hkp
import (
"gadrid/internal/service/storage"
"context"
"fmt"
"hkp-clavis/internal/service/storage"
"strings"
"time"
"github.com/ProtonMail/go-crypto/openpgp"
hkp "github.com/emersion/go-openpgp-hkp"
@ -10,24 +14,69 @@ import (
type HkpController struct {
hkp.Adder
hkp.Lookuper
storage storage.StorageInterface
// verify
storageService storage.StorageInterface
// verifyService
}
const (
defaultTimeout = 30 * time.Second
minEntropy = 15
)
func New(st storage.StorageInterface) (HkpController, error) {
return HkpController{
storage: st,
storageService: st,
}, nil
}
func (s HkpController) Add(k openpgp.EntityList) error {
func (s HkpController) Add(el openpgp.EntityList) error {
ctx, cancel := context.WithTimeout(context.Background(), time.Second*120)
defer cancel()
k, err := s.storageService.Add(ctx, el)
if err != nil {
return fmt.Errorf("hkp controller error: Add: %w", err)
}
_ = k
// Send Verify mail
// PS: Now verify false and you cant get key from DB
return nil
}
func (s HkpController) Get(r *hkp.LookupRequest) (openpgp.EntityList, error) {
return openpgp.EntityList{}, nil
func (s HkpController) Get(req *hkp.LookupRequest) (openpgp.EntityList, error) {
req.Search = strings.Trim(req.Search, "0x")
if len(req.Search) <= 15 {
return nil, fmt.Errorf("controller error: Get: Low entropy string for search")
}
ctx, cancel := context.WithTimeout(context.Background(), defaultTimeout)
defer cancel()
k, err := s.storageService.Get(ctx, req)
if err != nil {
return nil, fmt.Errorf("controller error: Get: %w", err)
}
return k, err
}
func (s HkpController) Index(r *hkp.LookupRequest) ([]hkp.IndexKey, error) {
return []hkp.IndexKey{}, nil
func (s HkpController) Index(req *hkp.LookupRequest) ([]hkp.IndexKey, error) {
if len(req.Search) < 5 {
return nil, fmt.Errorf("controller error: Index: short string for search")
}
ctx, cancel := context.WithTimeout(context.Background(), defaultTimeout)
defer cancel()
k, err := s.storageService.Index(ctx, req)
if err != nil {
return nil, fmt.Errorf("controller error: Get: %w", err)
}
return k, nil
}

143
internal/model/converter.go Normal file
View File

@ -0,0 +1,143 @@
package model
import (
"bytes"
"crypto/rand"
"encoding/hex"
"fmt"
"log"
"regexp"
"strings"
"sync"
"time"
"github.com/ProtonMail/go-crypto/openpgp"
"github.com/ProtonMail/go-crypto/openpgp/armor"
)
func extractEmail(fullNameWithEmail string) string {
re := regexp.MustCompile(`<([^>]+)>`)
matches := re.FindStringSubmatch(fullNameWithEmail)
if len(matches) > 1 {
return matches[1]
}
return ""
}
var (
bufferPool = sync.Pool{
New: func() any { return new(bytes.Buffer) },
}
)
// ConverterPgpToService converts a raw openpgp.EntityList into a slice of PGPkey objects.
// It parses key data, generates verification tokens for UIDs, and extracts email addresses.
//
// Parameters:
//
// entities: The openpgp.EntityList obtained from parsing an armored PGP block.
//
// Returns:
//
// []*PGPkey: A slice of converted PGPkey objects. Returns an empty (non-nil) slice if no valid entities are found.
// error: An error if any entity fails to be processed or contains invalid data.
func (m *KeyManager) ConverterPgpToService(entities openpgp.EntityList, defaultVerifyValue bool) ([]*PGPKey, func(), error) {
res := make([]*PGPKey, 0, len(entities))
release := func() {
m.ReleaseSlice(res)
}
for _, entity := range entities {
if entity == nil || entity.PrimaryKey == nil {
continue
}
buf := bufferPool.Get().(*bytes.Buffer)
buf.Reset()
publicKeyWriter, err := armor.Encode(buf, openpgp.PublicKeyType, nil)
if err != nil {
bufferPool.Put(buf)
return nil, release, fmt.Errorf("armor encode error: %w", err)
}
if err := entity.Serialize(publicKeyWriter); err != nil {
publicKeyWriter.Close()
bufferPool.Put(buf)
return nil, release, fmt.Errorf("serialize error: %w", err)
}
publicKeyWriter.Close()
pgpKey := pgpKeyPool.Get().(*PGPKey)
pgpKey.Reset()
pgpKey.Fingerprint = strings.ToUpper(hex.EncodeToString(entity.PrimaryKey.Fingerprint))
pgpKey.Packet = buf.String()
pgpKey.UpdateTime = entity.PrimaryKey.CreationTime.UTC()
pgpKey.Revoked = entity.Revoked(time.Now())
bufferPool.Put(buf)
for _, identity := range entity.Identities {
if identity == nil || identity.UserId == nil || identity.Name == "" {
continue
}
extractedEmail := extractEmail(identity.Name)
if extractedEmail == "" {
log.Printf("Warning: no email in UID %s for key %s", identity.Name, pgpKey.Fingerprint)
continue
}
var tokenRaw [16]byte
if _, err := rand.Read(tokenRaw[:]); err != nil {
return nil, release, fmt.Errorf("token gen error: %w", err)
}
token := hex.EncodeToString(tokenRaw[:])
pgpKey.Uids = append(pgpKey.Uids, &PGPUid{
Verify: defaultVerifyValue,
UIDString: identity.Name,
Email: extractedEmail,
Token: token,
TokenExpires: time.Now().Add(time.Hour * 5),
Fingerprint: pgpKey.Fingerprint,
})
}
res = append(res, pgpKey)
}
return res, release, nil
}
func SanitizeAndFilterKey(dbKey *PGPKey) (openpgp.EntityList, error) {
parsedEntities, err := openpgp.ReadArmoredKeyRing(strings.NewReader(dbKey.Packet))
if err != nil {
return nil, fmt.Errorf("failed to parse key packet for %s: %w", dbKey.Fingerprint, err)
}
if len(parsedEntities) == 0 {
return nil, fmt.Errorf("parsed key packet for %s yielded no entities", dbKey.Fingerprint)
}
var respEntities openpgp.EntityList
for _, e := range parsedEntities {
if e == nil {
return nil, fmt.Errorf("no entity with matching fingerprint %s found in parsed packet from DB", dbKey.Fingerprint)
}
verifiedUIDStringsFromDB := make(map[string]struct{})
for _, dbUID := range dbKey.Uids {
verifiedUIDStringsFromDB[dbUID.UIDString] = struct{}{}
}
filteredIdentities := make(map[string]*openpgp.Identity)
for identityKey, identity := range e.Identities {
if _, isVerified := verifiedUIDStringsFromDB[identity.Name]; isVerified {
filteredIdentities[identityKey] = identity // Keep this identity
}
}
e.Identities = filteredIdentities
respEntities = append(respEntities, e)
}
return respEntities, nil
}

59
internal/model/manager.go Normal file
View File

@ -0,0 +1,59 @@
package model
import "sync"
var pgpKeyPool = sync.Pool{
New: func() any {
return &PGPKey{
Uids: make([]*PGPUid, 0, 10),
}
},
}
var pgpUidPool = sync.Pool{
New: func() any {
return &PGPUid{}
},
}
// Struct for managed keys allocation in memory
type KeyManager struct{}
func NewKeyManager() *KeyManager {
return &KeyManager{}
}
func (m *KeyManager) Get() *PGPKey {
k := pgpKeyPool.Get().(*PGPKey)
k.Reset()
return k
}
func (m *KeyManager) GetUid() *PGPUid {
u := pgpUidPool.Get().(*PGPUid)
u.Reset()
return u
}
func (m *KeyManager) ReleaseUid(u ...*PGPUid) {
for _, u := range u {
if u == nil {
continue
}
pgpUidPool.Put(u)
}
}
func (m *KeyManager) Release(keys ...*PGPKey) {
for _, k := range keys {
if k == nil {
continue
}
m.ReleaseUid(k.Uids...)
// Unlink all uids objects
k.Uids = k.Uids[:0]
pgpKeyPool.Put(k)
}
}
func (m *KeyManager) ReleaseSlice(keys []*PGPKey) {
m.Release(keys...)
}

View File

@ -0,0 +1,37 @@
package model
import "time"
type PGPKey struct {
Revoked bool
UpdateTime time.Time
Fingerprint string
Packet string
Uids []*PGPUid
}
// Clear data from object
func (k *PGPKey) Reset() {
k.Fingerprint = ""
k.Packet = ""
k.Revoked = false
k.Uids = k.Uids[:0]
}
type PGPUid struct {
Verify bool
Token string
TokenExpires time.Time
Email string
UIDString string // "Full Name (comment) <email@example.com>"
Fingerprint string
}
func (u *PGPUid) Reset() {
u.Verify = false
u.Token = ""
u.TokenExpires = time.Time{}
u.Email = ""
u.UIDString = ""
u.Fingerprint = ""
}

View File

@ -0,0 +1,5 @@
package mail
type MailSenderRepo interface {
SendVerifyMessage()
}

View File

@ -0,0 +1 @@
package smtp

View File

@ -3,7 +3,7 @@ package storage
import (
"context"
"fmt"
service "gadrid/internal/service/storage"
"hkp-clavis/internal/model"
)
var (
@ -17,11 +17,11 @@ type StorageRepositotyInterface interface {
//
// Parameters:
// ctx: Context for the operation.
// keys: A slice of service.PGPkey objects to be added or updated.
// keys: A slice of model.PGPKey objects to be added or updated.
//
// Returns:
// error: An error if the operation fails, wrapped with context (e.g., database connection issues, SQL execution errors).
AddKey(ctx context.Context, keys []*service.PGPkey) error
AddKey(ctx context.Context, keys []*model.PGPKey) error
// VerifyUID updates the verification status of a specific User ID (UID) based on a token.
// It checks the token's validity and expiry before setting 'verified' to true and clearing token fields.
@ -50,11 +50,11 @@ type StorageRepositotyInterface interface {
// fingerprint: The fingerprint of the PGP key to retrieve.
//
// Returns:
// []*service.PGPkey: A slice containing the retrieved PGP key (or an empty slice if not found).
// []*model.PGPKey: A slice containing the retrieved PGP key (or an empty slice if not found).
// error: Returns an error if:
// - The key is not found (errors.Is(err, ErrKeyNotFound)).
// - Database query or scanning fails.
GetKey(ctx context.Context, fingerprint string) ([]*service.PGPkey, error)
GetKey(ctx context.Context, fingerprint string) ([]*model.PGPKey, error)
// Index searches for PGP keys based on a query string.
// It matches against UID strings and emails using case-insensitive regular expressions.
@ -65,9 +65,9 @@ type StorageRepositotyInterface interface {
// q: The query string (treated as a case-insensitive regular expression).
//
// Returns:
// []*service.PGPkey: A slice of matching PGPKey objects, each containing only their verified UIDs.
// []*model.PGPKey: A slice of matching PGPKey objects, each containing only their verified UIDs.
// error: Returns an error if database query or scanning fails.
Index(ctx context.Context, q string) ([]*service.PGPkey, error)
Index(ctx context.Context, q string) ([]*model.PGPKey, error)
// CleanupStaleKeys identifies and deletes PGP keys that no longer have any associated UIDs.
// This method is intended to be run periodically, e.g., by a cron job.
//

View File

@ -7,34 +7,35 @@ import (
"sync"
"time"
service "gadrid/internal/service/storage" // Adjust this import path
"hkp-clavis/internal/model"
// Adjust this import path
)
// InMemStorageMock is a mock implementation of PGPKeyStorage for testing purposes.
// It stores data in memory, mimicking database operations.
type InMemStorageMock struct {
keys map[string]*service.PGPkey
uids map[string]map[string]*service.PGPUid // fingerprint -> uid_string -> UID
mu sync.RWMutex // Mutex for concurrent access safety
keys map[string]*model.PGPKey
uids map[string]map[string]*model.PGPUid // fingerprint -> uid_string -> UID
mu sync.RWMutex // Mutex for concurrent access safety
}
// NewInMemStorageMock creates a new in-memory mock storage.
func NewInMemStorageMock() *InMemStorageMock {
return &InMemStorageMock{
keys: make(map[string]*service.PGPkey),
uids: make(map[string]map[string]*service.PGPUid),
keys: make(map[string]*model.PGPKey),
uids: make(map[string]map[string]*model.PGPUid),
}
}
// AddKey implements PGPKeyStorage interface for the mock.
// See PGPKeyStorage interface for documentation.
func (m *InMemStorageMock) AddKey(ctx context.Context, keys []*service.PGPkey) error {
func (m *InMemStorageMock) AddKey(ctx context.Context, keys []*model.PGPKey) error {
m.mu.Lock()
defer m.mu.Unlock()
for _, k := range keys {
// Simulate UPSERT for pgp_keys
m.keys[k.Fingerprint] = &service.PGPkey{ // Deep copy
m.keys[k.Fingerprint] = &model.PGPKey{ // Deep copy
Fingerprint: k.Fingerprint,
Packet: k.Packet,
Revoked: k.Revoked,
@ -43,14 +44,14 @@ func (m *InMemStorageMock) AddKey(ctx context.Context, keys []*service.PGPkey) e
// Ensure the inner map exists for UIDs
if _, ok := m.uids[k.Fingerprint]; !ok {
m.uids[k.Fingerprint] = make(map[string]*service.PGPUid)
m.uids[k.Fingerprint] = make(map[string]*model.PGPUid)
}
// Simulate INSERT (ON CONFLICT DO NOTHING) for pgp_uids
for _, u := range k.Uids {
if _, exists := m.uids[k.Fingerprint][u.UIDString]; !exists {
m.uids[k.Fingerprint][u.UIDString] = &service.PGPUid{ // Deep copy
UIDString: u.UIDString,
m.uids[k.Fingerprint][u.UIDString] = &model.PGPUid{ // Deep copy
UIDString: u.UIDString,
Email: u.Email,
Verify: u.Verify,
Token: u.Token,
@ -73,7 +74,7 @@ func (m *InMemStorageMock) VerifyUID(ctx context.Context, fingerprint, email, to
return fmt.Errorf("invalid verification token or link") // Mimic no matching entry
}
var targetUID *service.PGPUid
var targetUID *model.PGPUid
var targetUIDString string // To be used as key in the map for update
for uidStr, uid := range keyUids {
if uid.Email == email {
@ -99,7 +100,7 @@ func (m *InMemStorageMock) VerifyUID(ctx context.Context, fingerprint, email, to
// Simulate update
targetUID.Verify = true
targetUID.Token = "" // Simulate setting to null
targetUID.Token = "" // Simulate setting to null
targetUID.TokenExpires = time.Time{} // Simulate setting to null
// Ensure the updated UID is correctly stored back in the map
@ -110,7 +111,7 @@ func (m *InMemStorageMock) VerifyUID(ctx context.Context, fingerprint, email, to
// GetKey implements PGPKeyStorage interface for the mock.
// See PGPKeyStorage interface for documentation.
func (m *InMemStorageMock) GetKey(ctx context.Context, fingerprint string) ([]*service.PGPkey, error) {
func (m *InMemStorageMock) GetKey(ctx context.Context, fingerprint string) ([]*model.PGPKey, error) {
m.mu.RLock()
defer m.mu.RUnlock()
@ -120,11 +121,11 @@ func (m *InMemStorageMock) GetKey(ctx context.Context, fingerprint string) ([]*s
}
// Create a deep copy of the key
resultKey := &service.PGPkey{
resultKey := &model.PGPKey{
Fingerprint: pgpKey.Fingerprint,
Packet: pgpKey.Packet,
Revoked: pgpKey.Revoked,
Uids: []*service.PGPUid{}, // Only add verified UIDs
Uids: []*model.PGPUid{}, // Only add verified UIDs
}
// Filter UIDs by 'verified = true'
@ -132,8 +133,8 @@ func (m *InMemStorageMock) GetKey(ctx context.Context, fingerprint string) ([]*s
for _, uid := range keyUids {
if uid.Verify {
// Deep copy the UID
resultKey.Uids = append(resultKey.Uids, &service.PGPUid{
UIDString: uid.UIDString,
resultKey.Uids = append(resultKey.Uids, &model.PGPUid{
UIDString: uid.UIDString,
Email: uid.Email,
Verify: uid.Verify,
Token: uid.Token,
@ -143,12 +144,12 @@ func (m *InMemStorageMock) GetKey(ctx context.Context, fingerprint string) ([]*s
}
}
return []*service.PGPkey{resultKey}, nil
return []*model.PGPKey{resultKey}, nil
}
// Index implements PGPKeyStorage interface for the mock.
// See PGPKeyStorage interface for documentation.
func (m *InMemStorageMock) Index(ctx context.Context, q string) ([]*service.PGPkey, error) {
func (m *InMemStorageMock) Index(ctx context.Context, q string) ([]*model.PGPKey, error) {
m.mu.RLock()
defer m.mu.RUnlock()
@ -160,13 +161,13 @@ func (m *InMemStorageMock) Index(ctx context.Context, q string) ([]*service.PGPk
// Simulate case-insensitive substring match for regex.
// For a more accurate mock, you'd use the 'regexp' package here.
if strings.Contains(strings.ToLower(uid.UIDString), lowerQ) ||
strings.Contains(strings.ToLower(uid.Email), lowerQ) {
strings.Contains(strings.ToLower(uid.Email), lowerQ) {
matchedFingerprints[f] = true
}
}
}
var resultKeys []*service.PGPkey
var resultKeys []*model.PGPKey
for f := range matchedFingerprints {
// Reuse GetKey logic from mock (which filters for verified UIDs)
keys, err := m.GetKey(ctx, f)

View File

@ -5,8 +5,8 @@ import (
"database/sql"
"errors"
"fmt"
repo "gadrid/internal/repo/storage"
service "gadrid/internal/service/storage"
"hkp-clavis/internal/model"
repo "hkp-clavis/internal/repo/storage"
"strings"
"time"
@ -19,13 +19,71 @@ import (
//go:embed structure.sql
var db_struct string
var (
insertUid = `
insert into pgp_uids(fingerprint, uid, email, verified, verification_token, token_expires_at)
values($1, $2, $3, $4, $5, $6)
on conflict (fingerprint, uid) do nothing;
`
selectUid = `
select uid, verification_token, token_expires_at, verified
from pgp_uids
where fingerprint = $1 and email = $2;
`
updateUidVerify = `
update pgp_uids
set verified = true,
verification_token = null,
token_expires_at = null
where fingerprint = $1 and email = $2 and verification_token = $3;
`
fetchUidVerifed = `
select uid, email, verification_token, token_expires_at, verified
from pgp_uids
where fingerprint = $1 and verified = true;
`
insertKey = `
insert into pgp_keys(fingerprint, packet, revoked, update_time)
values($1, $2, $3, now())
on conflict (fingerprint) do update set
packet = excluded.packet,
revoked = excluded.revoked,
update_time = now();
`
selectKeyByFingerprint = `
select fingerprint, packet, revoked, update_time
from pgp_keys
where fingerprint ~* $1;
`
indexKeyByString = `
SELECT
k.fingerprint, k.packet, k.revoked, k.update_time,
u.uid, u.email, u.verified, u.verification_token, u.token_expires_at
FROM pgp_keys k
JOIN pgp_uids u ON k.fingerprint = u.fingerprint
WHERE k.fingerprint IN (
SELECT DISTINCT fingerprint
FROM pgp_uids
WHERE (uid ~* $1 OR email ~* $1 OR fingerprint ~* $1) AND verified = true
)
ORDER BY k.fingerprint;
`
cleanupStaleKeys = `
delete from pgp_keys pk
where not exists (
select 1 from pgp_uids pu where pu.fingerprint = pk.fingerprint
);
`
)
type PostgresqlStorageRepo struct {
poll *pgxpool.Pool
poll *pgxpool.Pool
keyMngr *model.KeyManager
}
// Get context, url
// Return postgresql repository
func New(ctx context.Context, pool *pgxpool.Pool) repo.StorageRepositotyInterface {
func New(ctx context.Context, pool *pgxpool.Pool, keyManager *model.KeyManager) repo.StorageRepositotyInterface {
_, err := pool.Exec(ctx, db_struct)
if err != nil {
panic(err)
@ -33,10 +91,12 @@ func New(ctx context.Context, pool *pgxpool.Pool) repo.StorageRepositotyInterfac
return PostgresqlStorageRepo{
poll: pool,
// Allocation manager
keyMngr: keyManager,
}
}
func (s PostgresqlStorageRepo) AddKey(ctx context.Context, keys []*service.PGPkey) error {
func (s PostgresqlStorageRepo) AddKey(ctx context.Context, keys []*model.PGPKey) error {
tx, err := s.poll.Begin(ctx)
if err != nil {
return fmt.Errorf("error postgresql: AddKey: begin transaction: %w", err)
@ -46,27 +106,15 @@ func (s PostgresqlStorageRepo) AddKey(ctx context.Context, keys []*service.PGPke
for _, k := range keys {
fingerprint := k.Fingerprint
// Upsert pgp_keys
_, err = tx.Exec(ctx, `
insert into pgp_keys(fingerprint, packet, revoked, update_time)
values($1, $2, $3, now())
on conflict (fingerprint) do update set
packet = excluded.packet,
revoked = excluded.revoked,
update_time = now();
`, fingerprint, k.Packet, k.Revoked)
_, err = tx.Exec(ctx, insertKey, fingerprint, k.Packet, k.Revoked)
if err != nil {
return fmt.Errorf("error postgresql: AddKey: upsert key %s: %w", fingerprint, err)
}
// Upsert pgp_uids
batch := &pgx.Batch{}
insertUIDQuery := `
insert into pgp_uids(fingerprint, uid, email, verified, verification_token, token_expires_at)
values($1, $2, $3, $4, $5, $6)
on conflict (fingerprint, uid) do nothing;
`
for _, u := range k.Uids {
batch.Queue(insertUIDQuery, fingerprint, u.UIDString, u.Email, u.Verify, u.Token, u.TokenExpires)
batch.Queue(insertUid, fingerprint, u.UIDString, u.Email, u.Verify, u.Token, u.TokenExpires)
}
br := tx.SendBatch(ctx, batch)
@ -97,11 +145,7 @@ func (s PostgresqlStorageRepo) VerifyUID(ctx context.Context, fingerprint, email
var tokenExpiresAt sql.NullTime
var isVerified bool
row := tx.QueryRow(ctx, `
select uid, verification_token, token_expires_at, verified
from pgp_uids
where fingerprint = $1 and email = $2;
`, fingerprint, email)
row := tx.QueryRow(ctx, selectUid, fingerprint, email)
if err = row.Scan(&storedUID, &storedToken, &tokenExpiresAt, &isVerified); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
@ -124,13 +168,7 @@ func (s PostgresqlStorageRepo) VerifyUID(ctx context.Context, fingerprint, email
}
// UPDATE the UID status.
result, err := tx.Exec(ctx, `
update pgp_uids
set verified = true,
verification_token = null,
token_expires_at = null
where fingerprint = $1 and email = $2 and verification_token = $3;
`, fingerprint, email, token)
result, err := tx.Exec(ctx, updateUidVerify, fingerprint, email, token)
if err != nil {
return fmt.Errorf("error postgresql: VerifyUID: failed to update UID verification status: %w", err)
}
@ -146,124 +184,115 @@ func (s PostgresqlStorageRepo) VerifyUID(ctx context.Context, fingerprint, email
return tx.Commit(ctx)
}
func (s PostgresqlStorageRepo) GetKey(ctx context.Context, fngprt string) ([]*service.PGPkey, error) {
var packet string
var fingerprint string
var revoked bool
var update_time time.Time
func (s PostgresqlStorageRepo) GetKey(ctx context.Context, fngprt string) (res []*model.PGPKey, err error) {
pgpKey := s.keyMngr.Get()
row := s.poll.QueryRow(ctx, `
select fingerprint, packet, revoked, update_time
from pgp_keys
where fingerprint ~* $1;
`, fngprt)
defer func() {
if err != nil {
s.keyMngr.Release(pgpKey)
}
}()
if err := row.Scan(&fingerprint, &packet, &revoked, &update_time); err != nil {
row := s.poll.QueryRow(ctx, selectKeyByFingerprint, fngprt)
if err = row.Scan(&pgpKey.Fingerprint, &pgpKey.Packet, &pgpKey.Revoked, &pgpKey.UpdateTime); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, repo.ErrKeyNotFound
}
return nil, fmt.Errorf("error postgresql: GetKey: failed to scan key data for fingerprint %s: %w", fngprt, err)
return nil, fmt.Errorf("error postgresql: GetKey: scan error: %w", err)
}
if !strings.Contains(fingerprint, fngprt) {
if !strings.Contains(pgpKey.Fingerprint, fngprt) {
return nil, repo.ErrKeyNotFound
}
// Fetch all VERIFIED UIDs for this key.
rows, err := s.poll.Query(ctx, `
select uid, email, verification_token, token_expires_at, verified
from pgp_uids
where fingerprint = $1 and verified = true;
`, fngprt)
rows, err := s.poll.Query(ctx, fetchUidVerifed, pgpKey.Fingerprint)
if err != nil {
return nil, fmt.Errorf("error postgresql: GetKey: failed to query UIDs for key %s: %w", fngprt, err)
return nil, fmt.Errorf("error postgresql: GetKey: query uids error: %w", err)
}
defer rows.Close()
var uids []*service.PGPUid
for rows.Next() {
uid := &service.PGPUid{}
uid := s.keyMngr.GetUid()
var token sql.NullString
var tokenExpiresAt sql.NullTime
if err := rows.Scan(&uid.UIDString, &uid.Email, &token, &tokenExpiresAt, &uid.Verify); err != nil {
return nil, fmt.Errorf("error postgresql: GetKey: failed to scan UID row for key %s: %w", fngprt, err)
if err = rows.Scan(&uid.UIDString, &uid.Email, &token, &tokenExpiresAt, &uid.Verify); err != nil {
s.keyMngr.ReleaseUid(uid)
return nil, err
}
uid.Token = token.String
uid.TokenExpires = tokenExpiresAt.Time
uids = append(uids, uid)
pgpKey.Uids = append(pgpKey.Uids, uid)
}
if err = rows.Err(); err != nil {
return nil, fmt.Errorf("error postgresql: GetKey: error after iterating UIDs for key %s: %w", fngprt, err)
return nil, err
}
pgpKey := &service.PGPkey{
Fingerprint: fngprt,
UpdateTime: update_time,
Packet: packet,
Revoked: revoked,
Uids: uids, // Only verified UIDs included
}
return []*service.PGPkey{pgpKey}, nil
return []*model.PGPKey{pgpKey}, nil
}
func (s PostgresqlStorageRepo) Index(ctx context.Context, q string) ([]*service.PGPkey, error) {
// Search for matching fingerprints based on the query 'q'.
fingerprintRows, err := s.poll.Query(ctx, `
select distinct fingerprint
from pgp_uids
where (uid ~* $1 or email ~* $1 or fingerprint ~* $1) and verified = true;
`, q)
func (s PostgresqlStorageRepo) Index(ctx context.Context, q string) (resultKeys []*model.PGPKey, err error) {
rows, err := s.poll.Query(ctx, indexKeyByString, q)
if err != nil {
return nil, fmt.Errorf("error postgresql: Index: failed to query matching fingerprints for '%s': %w", q, err)
return nil, fmt.Errorf("postgresql: Index query failed: %w", err)
}
defer fingerprintRows.Close()
defer rows.Close()
var matchingFingerprints []string
for fingerprintRows.Next() {
var f string
if err := fingerprintRows.Scan(&f); err != nil {
return nil, fmt.Errorf("error postgresql: Index: failed to scan fingerprint: %w", err)
}
matchingFingerprints = append(matchingFingerprints, f)
}
if err = fingerprintRows.Err(); err != nil {
return nil, fmt.Errorf("error postgresql: Index: error after iterating fingerprints: %w", err)
}
if len(matchingFingerprints) == 0 {
return []*service.PGPkey{}, nil
}
// For each matching fingerprint, use GetKey to retrieve the full PGPKey object.
var resultKeys []*service.PGPkey
for _, fingerprint := range matchingFingerprints {
keys, err := s.GetKey(ctx, fingerprint)
defer func() {
if err != nil {
if errors.Is(err, repo.ErrKeyNotFound) {
continue
}
return nil, fmt.Errorf("error postgresql: Index: failed to retrieve key %s via GetKey: %w", fingerprint, err)
s.keyMngr.Release(resultKeys...)
}
if len(keys) > 0 {
resultKeys = append(resultKeys, keys[0])
}()
var currentKey *model.PGPKey
for rows.Next() {
var fng, packet string
var revoked bool
var updateTime time.Time
uidObj := s.keyMngr.GetUid()
var token sql.NullString
var tokenExpiresAt sql.NullTime
err = rows.Scan(
&fng, &packet, &revoked, &updateTime,
&uidObj.UIDString, &uidObj.Email, &uidObj.Verify, &token, &tokenExpiresAt,
)
if err != nil {
s.keyMngr.ReleaseUid(uidObj)
return nil, err
}
uidObj.Token = token.String
uidObj.TokenExpires = tokenExpiresAt.Time
uidObj.Fingerprint = fng
if currentKey == nil || currentKey.Fingerprint != fng {
currentKey = s.keyMngr.Get()
currentKey.Fingerprint = fng
currentKey.Packet = packet
currentKey.Revoked = revoked
currentKey.UpdateTime = updateTime
resultKeys = append(resultKeys, currentKey)
}
currentKey.Uids = append(currentKey.Uids, uidObj)
}
if err = rows.Err(); err != nil {
return nil, fmt.Errorf("postgresql: Index rows iteration error: %w", err)
}
return resultKeys, nil
}
func (s PostgresqlStorageRepo) CleanupStaleKeys(ctx context.Context) (int, error) {
// The SQL query will delete keys from pgp_keys that have no corresponding entries in pgp_uids.
result, err := s.poll.Exec(ctx, `
delete from pgp_keys pk
where not exists (
select 1 from pgp_uids pu where pu.fingerprint = pk.fingerprint
);
`)
result, err := s.poll.Exec(ctx, cleanupStaleKeys)
if err != nil {
return 0, fmt.Errorf("error postgresql: CleanupStaleKeys: failed to delete stale keys: %w", err)
}

View File

@ -12,9 +12,9 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
repository "gadrid/internal/repo/storage"
psqlrepo "gadrid/internal/repo/storage/postgresql"
service "gadrid/internal/service/storage"
"hkp-clavis/internal/model"
repository "hkp-clavis/internal/repo/storage"
psqlrepo "hkp-clavis/internal/repo/storage/postgresql"
)
//go:embed structure.sql
@ -25,21 +25,23 @@ func TestPostgresqlStorageRepo_AddKey(t *testing.T) {
defer pool.Close()
defer cleanupTestDB(t, pool)
repo := psqlrepo.New(context.Background(), pool)
mgr := model.NewKeyManager()
repo := psqlrepo.New(context.Background(), pool, mgr)
ctx := context.Background()
// --- Test Case 1: Add a new key ---
t.Run("Add new key successfully", func(t *testing.T) {
key := &service.PGPkey{
key := &model.PGPKey{
Fingerprint: "FINGERPRINT_NEW_KEY",
Packet: "-----BEGIN PGP PUBLIC KEY BLOCK-----NEW_KEY_PACKET-----END PGP PUBLIC KEY BLOCK-----",
Revoked: false,
Uids: []*service.PGPUid{
Uids: []*model.PGPUid{
{UIDString: "New User <new@example.com>", Email: "new@example.com", Verify: false, Token: "token1", TokenExpires: time.Now().Add(time.Hour)},
},
}
err := repo.AddKey(ctx, []*service.PGPkey{key})
err := repo.AddKey(ctx, []*model.PGPKey{key})
require.NoError(t, err)
// Verify key was added
@ -60,27 +62,27 @@ func TestPostgresqlStorageRepo_AddKey(t *testing.T) {
// --- Test Case 2: Update an existing key (packet, revoked status) ---
t.Run("Update existing key packet and revoked status", func(t *testing.T) {
initialKey := &service.PGPkey{
initialKey := &model.PGPKey{
Fingerprint: "FINGERPRINT_EXISTING",
Packet: "-----BEGIN PGP PUBLIC KEY BLOCK-----INITIAL_PACKET-----END PGP PUBLIC KEY BLOCK-----",
Revoked: false,
Uids: []*service.PGPUid{
Uids: []*model.PGPUid{
{UIDString: "Existing User <existing@example.com>", Email: "existing@example.com", Verify: false, Token: "initial_token", TokenExpires: time.Now().Add(time.Hour)},
},
}
err := repo.AddKey(ctx, []*service.PGPkey{initialKey})
err := repo.AddKey(ctx, []*model.PGPKey{initialKey})
require.NoError(t, err)
// Simulate an update: new packet, revoked true
updatedKey := &service.PGPkey{
updatedKey := &model.PGPKey{
Fingerprint: "FINGERPRINT_EXISTING",
Packet: "-----BEGIN PGP PUBLIC KEY BLOCK-----UPDATED_PACKET-----END PGP PUBLIC KEY BLOCK-----",
Revoked: true,
Uids: []*service.PGPUid{
Uids: []*model.PGPUid{
{UIDString: "Existing User <existing@example.com>", Email: "existing@example.com", Verify: false, Token: "updated_token", TokenExpires: time.Now().Add(time.Hour * 2)},
},
}
err = repo.AddKey(ctx, []*service.PGPkey{updatedKey})
err = repo.AddKey(ctx, []*model.PGPKey{updatedKey})
require.NoError(t, err)
// Verify key was updated
@ -100,28 +102,28 @@ func TestPostgresqlStorageRepo_AddKey(t *testing.T) {
// --- Test Case 3: Add new UID to existing key ---
t.Run("Add new UID to existing key", func(t *testing.T) {
key := &service.PGPkey{
key := &model.PGPKey{
Fingerprint: "FINGERPRINT_ADD_UID",
Packet: "-----BEGIN PGP PUBLIC KEY BLOCK-----ADD_UID_KEY-----END PGP PUBLIC KEY BLOCK-----",
Revoked: false,
Uids: []*service.PGPUid{
Uids: []*model.PGPUid{
{UIDString: "First UID <first@example.com>", Email: "first@example.com", Verify: false, Token: "token_first", TokenExpires: time.Now().Add(time.Hour)},
},
}
err := repo.AddKey(ctx, []*service.PGPkey{key})
err := repo.AddKey(ctx, []*model.PGPKey{key})
require.NoError(t, err)
// Add a second UID to the same key
updatedKey := &service.PGPkey{
updatedKey := &model.PGPKey{
Fingerprint: "FINGERPRINT_ADD_UID",
Packet: "-----BEGIN PGP PUBLIC KEY BLOCK-----ADD_UID_KEY_UPDATED-----END PGP PUBLIC KEY BLOCK-----",
Revoked: false,
Uids: []*service.PGPUid{
Uids: []*model.PGPUid{
{UIDString: "First UID <first@example.com>", Email: "first@example.com", Verify: false, Token: "token_first", TokenExpires: time.Now().Add(time.Hour)},
{UIDString: "Second UID <second@example.com>", Email: "second@example.com", Verify: false, Token: "token_second", TokenExpires: time.Now().Add(time.Hour)},
},
}
err = repo.AddKey(ctx, []*service.PGPkey{updatedKey})
err = repo.AddKey(ctx, []*model.PGPKey{updatedKey})
require.NoError(t, err)
// Verify both UIDs exist
@ -137,7 +139,9 @@ func TestPostgresqlStorageRepo_VerifyUID(t *testing.T) {
defer pool.Close()
defer cleanupTestDB(t, pool)
repo := psqlrepo.New(context.Background(), pool)
mgr := model.NewKeyManager()
repo := psqlrepo.New(context.Background(), pool, mgr)
ctx := context.Background()
// Add a key with an unverified UID
@ -146,12 +150,12 @@ func TestPostgresqlStorageRepo_VerifyUID(t *testing.T) {
uidToken := "verifytoken123"
uidExpires := time.Now().Add(time.Hour)
err := repo.AddKey(ctx, []*service.PGPkey{
err := repo.AddKey(ctx, []*model.PGPKey{
{
Fingerprint: keyFingerprint,
Packet: "...",
Revoked: false,
Uids: []*service.PGPUid{
Uids: []*model.PGPUid{
{UIDString: "Verify Me <" + uidEmail + ">", Email: uidEmail, Verify: false, Token: uidToken, TokenExpires: uidExpires},
},
},
@ -198,12 +202,12 @@ func TestPostgresqlStorageRepo_VerifyUID(t *testing.T) {
expiredToken := "expiredtoken"
expiredTime := time.Now().Add(-time.Hour)
err := repo.AddKey(ctx, []*service.PGPkey{
err := repo.AddKey(ctx, []*model.PGPKey{
{
Fingerprint: expiredFingerprint,
Packet: "...",
Revoked: false,
Uids: []*service.PGPUid{
Uids: []*model.PGPUid{
{UIDString: "Expired <" + expiredEmail + ">", Email: expiredEmail, Verify: false, Token: expiredToken, TokenExpires: expiredTime},
},
},
@ -227,17 +231,18 @@ func TestPostgresqlStorageRepo_GetKey(t *testing.T) {
defer pool.Close()
defer cleanupTestDB(t, pool)
repo := psqlrepo.New(context.Background(), pool)
mgr := model.NewKeyManager()
repo := psqlrepo.New(context.Background(), pool, mgr)
ctx := context.Background()
// Add a key with mixed UIDs (verified and unverified)
testFingerprint := "FINGERPRINT_GET_KEY"
err := repo.AddKey(ctx, []*service.PGPkey{
err := repo.AddKey(ctx, []*model.PGPKey{
{
Fingerprint: testFingerprint,
Packet: "PACKET_DATA",
Revoked: false,
Uids: []*service.PGPUid{
Uids: []*model.PGPUid{
{UIDString: "Verified User <verified@example.com>", Email: "verified@example.com", Verify: false, Token: "tok_v", TokenExpires: time.Now().Add(time.Hour)},
{UIDString: "Unverified User <unverified@example.com>", Email: "unverified@example.com", Verify: false, Token: "tok_uv", TokenExpires: time.Now().Add(time.Hour)},
{UIDString: "Another Verified <another@example.com>", Email: "another@example.com", Verify: false, Token: "tok_av", TokenExpires: time.Now().Add(time.Hour)},
@ -285,27 +290,29 @@ func TestPostgresqlStorageRepo_Index(t *testing.T) {
defer pool.Close()
defer cleanupTestDB(t, pool)
repo := psqlrepo.New(context.Background(), pool)
mgr := model.NewKeyManager()
repo := psqlrepo.New(context.Background(), pool, mgr)
ctx := context.Background()
// Add multiple keys with various UIDs
keysToLoad := []*service.PGPkey{
keysToLoad := []*model.PGPKey{
{
Fingerprint: "FINGERPRINT_ALPHA", Packet: "alpha_packet", Revoked: false,
Uids: []*service.PGPUid{
Uids: []*model.PGPUid{
{UIDString: "Alpha User <alpha@example.com>", Email: "alpha@example.com", Verify: false, Token: "t1", TokenExpires: time.Now().Add(time.Hour)},
{UIDString: "Test Alpha <testalpha@mail.com>", Email: "testalpha@mail.com", Verify: false, Token: "t2", TokenExpires: time.Now().Add(time.Hour)},
},
},
{
Fingerprint: "FINGERPRINT_BETA", Packet: "beta_packet", Revoked: false,
Uids: []*service.PGPUid{
Uids: []*model.PGPUid{
{UIDString: "Beta User <beta@example.com>", Email: "beta@example.com", Verify: false, Token: "t3", TokenExpires: time.Now().Add(time.Hour)},
},
},
{
Fingerprint: "FINGERPRINT_GAMMA", Packet: "gamma_packet", Revoked: false,
Uids: []*service.PGPUid{
Uids: []*model.PGPUid{
{UIDString: "Gamma User <gamma@example.com>", Email: "gamma@example.com", Verify: false, Token: "t4", TokenExpires: time.Now().Add(time.Hour)},
{UIDString: "Test Gamma <testgamma@mail.com>", Email: "testgamma@mail.com", Verify: false, Token: "t5", TokenExpires: time.Now().Add(time.Hour)},
},

View File

@ -1,5 +0,0 @@
package mail
type MailServiceInterface interface {
SendMail(to []string, msg []byte)
}

View File

@ -1,111 +0,0 @@
package storage
import (
"bytes"
"crypto/rand"
"encoding/hex"
"fmt"
"log"
"regexp"
"strings"
"time"
"github.com/ProtonMail/go-crypto/openpgp"
"github.com/ProtonMail/go-crypto/openpgp/armor"
)
func extractEmail(fullNameWithEmail string) string {
re := regexp.MustCompile(`<([^>]+)>`)
matches := re.FindStringSubmatch(fullNameWithEmail)
if len(matches) > 1 {
return matches[1]
}
return ""
}
// ConverterPgpToService converts a raw openpgp.EntityList into a slice of service.PGPkey objects.
// It parses key data, generates verification tokens for UIDs, and extracts email addresses.
//
// Parameters:
//
// entities: The openpgp.EntityList obtained from parsing an armored PGP block.
//
// Returns:
//
// []*PGPkey: A slice of converted PGPkey objects. Returns an empty (non-nil) slice if no valid entities are found.
// error: An error if any entity fails to be processed or contains invalid data.
func ConverterPgpToService(entities openpgp.EntityList) ([]*PGPkey, error) {
var pgpKeys []*PGPkey
if entities == nil {
return pgpKeys, nil
}
for i, entity := range entities {
// Test on correct data
if entity == nil {
log.Printf("Warning: ConverterPgpToService: encountered nil entity at index %d, skipping.", i)
continue
}
if entity.PrimaryKey == nil {
log.Printf("Warning: ConverterPgpToService: entity at index %d has no primary key, skipping.", i)
continue
}
publicKeyBuf := bytes.NewBuffer(nil)
publicKeyWriter, err := armor.Encode(publicKeyBuf, openpgp.PublicKeyType, nil)
if err != nil {
return nil, fmt.Errorf("converter error: entity %d: failed to create armor encoder: %w", i, err)
}
if err = entity.Serialize(publicKeyWriter); err != nil {
return nil, fmt.Errorf("converter error: entity %d: failed to serialize public key: %w", i, err)
}
publicKeyWriter.Close()
// Write it
pgpKey := PGPkey{
Revoked: entity.Revoked(time.Now()),
UpdateTime: entity.PrimaryKey.CreationTime.UTC(), // <--- FIX IS HERE! Use PrimaryKey's CreationTime
Fingerprint: strings.ToUpper(hex.EncodeToString(entity.PrimaryKey.Fingerprint)),
Packet: publicKeyBuf.String(),
Uids: []*PGPUid{},
}
// Process each UID for the current entity
for _, uid := range entity.Identities {
if uid == nil || uid.UserId == nil {
log.Printf("Warning: ConverterPgpToService: encountered nil Identity or UserId for key %s, skipping.", pgpKey.Fingerprint)
continue
}
if uid.Name == "" {
log.Printf("Warning: ConverterPgpToService: encountered empty User ID string for key %s, skipping.", pgpKey.Fingerprint)
continue
}
randBytes := make([]byte, 16)
if _, err := rand.Read(randBytes); err != nil {
return nil, fmt.Errorf("converter error: key %s: failed to generate random bytes for token: %w", pgpKey.Fingerprint, err)
}
token := hex.EncodeToString(randBytes)
extractedEmail := extractEmail(uid.Name)
if extractedEmail == "" {
log.Printf("Warning: ConverterPgpToService: extracted empty email from UID '%s' for key %s, skipping UID.", uid.Name, pgpKey.Fingerprint)
continue
}
pgpKey.Uids = append(pgpKey.Uids, &PGPUid{
Verify: false,
UIDString: uid.Name,
Email: extractedEmail,
Token: token,
TokenExpires: time.Now().Add(time.Hour * 5),
Fingerprint: pgpKey.Fingerprint,
})
}
pgpKeys = append(pgpKeys, &pgpKey)
}
return pgpKeys, nil
}

View File

@ -1,30 +1,15 @@
package storage
import (
"time"
"context"
"hkp-clavis/internal/model"
"github.com/ProtonMail/go-crypto/openpgp"
hkp "github.com/emersion/go-openpgp-hkp"
)
type StorageInterface interface {
AddKey(openpgp.EntityList) error
GetKeyByUID(uid string) openpgp.EntityList
GetKeyByFingerprint(fngr string) openpgp.EntityList
}
type PGPkey struct {
Revoked bool
UpdateTime time.Time
Fingerprint string
Packet string
Uids []*PGPUid
}
type PGPUid struct {
Verify bool
Token string
TokenExpires time.Time
Email string
UIDString string // "Full Name (comment) <email@example.com>"
Fingerprint string
Add(ctx context.Context, el openpgp.EntityList) ([]*model.PGPKey, error)
Get(ctx context.Context, r *hkp.LookupRequest) (openpgp.EntityList, error)
Index(ctx context.Context, r *hkp.LookupRequest) ([]hkp.IndexKey, error)
}

View File

@ -1,14 +1,13 @@
package storage
package serv
import (
"context"
"errors"
"fmt"
"log"
"strings"
storeRepo "gadrid/internal/repo/storage"
service "gadrid/internal/service/storage"
"hkp-clavis/internal/model"
storeRepo "hkp-clavis/internal/repo/storage"
service "hkp-clavis/internal/service/storage"
"github.com/ProtonMail/go-crypto/openpgp"
hkp "github.com/emersion/go-openpgp-hkp"
@ -18,79 +17,82 @@ type StorageService struct {
hkp.Adder
hkp.Lookuper
// mailService interface{}
storage storeRepo.StorageRepositotyInterface
storage storeRepo.StorageRepositotyInterface
config StorageServiceConfig
keyManager *model.KeyManager
}
func New(storageRepository storeRepo.StorageRepositotyInterface) StorageService {
type StorageServiceConfig struct {
defaultVerify bool
}
func New(storageRepository storeRepo.StorageRepositotyInterface, keyManager *model.KeyManager, defaultVerifyValue bool) service.StorageInterface {
return StorageService{
storage: storageRepository,
storage: storageRepository,
keyManager: keyManager,
config: StorageServiceConfig{
defaultVerify: defaultVerifyValue,
},
// Allocation manager
}
}
func (s StorageService) Add(el openpgp.EntityList) error {
fmt.Println(el)
pgpKeys, err := service.ConverterPgpToService(el) // Assuming ConverterPgpToService might return an error now.
func (s StorageService) Add(ctx context.Context, el openpgp.EntityList) ([]*model.PGPKey, error) {
pgpKeys, release, err := s.keyManager.ConverterPgpToService(el, s.config.defaultVerify) // Assuming ConverterPgpToService might return an error now.
defer release()
if err != nil {
return fmt.Errorf("service error: Add: failed to convert PGP entities: %w", err)
return []*model.PGPKey{}, fmt.Errorf("service error: Add: failed to convert PGP entities: %w", err)
}
if len(pgpKeys) == 0 {
return nil // No keys to add, nothing to do.
return []*model.PGPKey{}, nil // No keys to add, nothing to do.
}
// 2. Pass the context from this service method down to the storage layer.
// 3. ALWAYS check the error returned by the storage layer.
err = s.storage.AddKey(context.Background(), pgpKeys) // Pass context here
err = s.storage.AddKey(ctx, pgpKeys) // Pass context here
if err != nil {
return fmt.Errorf("service error: Add: failed to add keys to storage: %w", err)
return []*model.PGPKey{}, fmt.Errorf("service error: Add: failed to add keys to storage: %w", err)
}
return nil // Successfully processed
return pgpKeys, nil
}
// Get key by fingerprint
// In Specification "op=get" fingerprint and uid
// FUTUE TE IPSUM specification! Only fingerprint search!
func (s StorageService) Get(req *hkp.LookupRequest) (openpgp.EntityList, error) {
req.Search = strings.Trim(req.Search, "0x")
if len(req.Search) <= 15 {
return nil, fmt.Errorf("service error: Get: Low entropy string for search")
}
dbKeys, err := s.storage.GetKey(context.Background(), req.Search)
func (s StorageService) Get(ctx context.Context, req *hkp.LookupRequest) (openpgp.EntityList, error) {
dbKeys, err := s.storage.Index(ctx, req.Search)
if err != nil {
if errors.Is(err, storeRepo.ErrKeyNotFound) {
return nil, fmt.Errorf("service error: Get: key %s not found: %w", req.Search, err)
}
return nil, fmt.Errorf("service error: Get: db request failed for key %s: %w", req.Search, err)
return nil, fmt.Errorf("service error: Get: search failed: %w", err)
}
if len(dbKeys) == 0 {
return nil, fmt.Errorf("service error: Get: key %s not found (repository returned empty list): %w", req.Search, storeRepo.ErrKeyNotFound)
return nil, storeRepo.ErrKeyNotFound
}
dbKey := dbKeys[0]
defer s.keyManager.Release(dbKeys...)
filteredEntity, err := s.sanitizeAndFilterKey(dbKey)
if err != nil {
return nil, fmt.Errorf("service error: Get: failed to sanitize and filter key %s: %w", dbKey.Fingerprint, err)
var finalEntityList openpgp.EntityList
for _, dbKey := range dbKeys {
filteredEntities, err := model.SanitizeAndFilterKey(dbKey)
if err != nil {
log.Printf("Warning: Get: failed to sanitize key %s: %v", dbKey.Fingerprint, err)
continue
}
finalEntityList = append(finalEntityList, filteredEntities...)
}
return filteredEntity, nil
if len(finalEntityList) == 0 {
return nil, storeRepo.ErrKeyNotFound
}
return finalEntityList, nil
}
func (s StorageService) Index(req *hkp.LookupRequest) ([]hkp.IndexKey, error) {
if len(req.Search) < 5 {
return nil, fmt.Errorf("service error: Index: short string for search")
}
dbKeys, err := s.storage.Index(context.Background(), req.Search) // Pass ctx here
func (s StorageService) Index(ctx context.Context, req *hkp.LookupRequest) ([]hkp.IndexKey, error) {
dbKeys, err := s.storage.Index(ctx, req.Search)
if err != nil {
return nil, fmt.Errorf("service error: Index: db index request for '%s': %w", req.Search, err) // Include original error
return nil, fmt.Errorf("service error: Index: db index request for '%s': %w", req.Search, err)
}
defer s.keyManager.Release(dbKeys...)
if len(dbKeys) == 0 {
return nil, nil
@ -98,7 +100,7 @@ func (s StorageService) Index(req *hkp.LookupRequest) ([]hkp.IndexKey, error) {
var respIndex []hkp.IndexKey
for _, dbKey := range dbKeys {
filteredEntity, err := s.sanitizeAndFilterKey(dbKey)
filteredEntity, err := model.SanitizeAndFilterKey(dbKey)
if err != nil {
log.Printf("Warning: service error: Index: failed to sanitize and filter key %s: %v, skipping.", dbKey.Fingerprint, err)
continue // Skip this problematic key
@ -120,34 +122,3 @@ func (s StorageService) Index(req *hkp.LookupRequest) ([]hkp.IndexKey, error) {
return respIndex, nil
}
func (s StorageService) sanitizeAndFilterKey(dbKey *service.PGPkey) (openpgp.EntityList, error) {
parsedEntities, err := openpgp.ReadArmoredKeyRing(strings.NewReader(dbKey.Packet))
if err != nil {
return nil, fmt.Errorf("failed to parse key packet for %s: %w", dbKey.Fingerprint, err)
}
if len(parsedEntities) == 0 {
return nil, fmt.Errorf("parsed key packet for %s yielded no entities", dbKey.Fingerprint)
}
var respEntities openpgp.EntityList
for _, e := range parsedEntities {
if e == nil {
return nil, fmt.Errorf("no entity with matching fingerprint %s found in parsed packet from DB", dbKey.Fingerprint)
}
verifiedUIDStringsFromDB := make(map[string]struct{})
for _, dbUID := range dbKey.Uids {
verifiedUIDStringsFromDB[dbUID.UIDString] = struct{}{}
}
filteredIdentities := make(map[string]*openpgp.Identity)
for identityKey, identity := range e.Identities {
if _, isVerified := verifiedUIDStringsFromDB[identity.Name]; isVerified {
filteredIdentities[identityKey] = identity // Keep this identity
}
}
e.Identities = filteredIdentities
respEntities = append(respEntities, e)
}
return respEntities, nil
}

View File

@ -0,0 +1,15 @@
package serv
import (
"context"
"hkp-clavis/internal/model"
service "hkp-clavis/internal/service/verify"
)
type VerifyMailService struct {
service.VerifyServiceInterface
}
func (s VerifyMailService) SendVerifyMessage(ctx context.Context, el []*model.PGPKey) error {
return nil
}

View File

@ -0,0 +1,10 @@
package mail
import (
"context"
"hkp-clavis/internal/model"
)
type VerifyServiceInterface interface {
SendVerifyMessage(ctx context.Context, el []*model.PGPKey) error
}

BIN
main

Binary file not shown.

View File

195
pkg/mail/smtp/pool/smtp.go Normal file
View File

@ -0,0 +1,195 @@
package pool
import (
"bytes"
"crypto/sha256"
"crypto/tls"
"errors"
"fmt"
"net/smtp"
"sync"
"sync/atomic"
"time"
)
type Message struct {
From string
To []string
Message bytes.Buffer
inProcess bool
}
type MessageTransaction struct {
done bool
key string
msg *Message
pool *MessagePool
}
func (s MessageTransaction) Done() {
s.done = true
}
func (s MessageTransaction) Close() {
if s.done {
s.pool.Del(s.key)
return
}
s.msg.inProcess = false
}
type MessagePool struct {
m *sync.Mutex
messagePool map[string]*Message
}
func (p MessagePool) Put(m *Message) {
if m == nil {
return
}
p.m.Lock()
h := sha256.New()
h.Write(m.Message.Bytes())
k := string(h.Sum(nil))
p.messagePool[k] = m
p.m.Unlock()
}
func (p MessagePool) Del(key string) {
p.m.Lock()
delete(p.messagePool, key)
p.m.Unlock()
}
func (p MessagePool) Begin() chan MessageTransaction {
c := make(chan MessageTransaction)
go func() {
for k, m := range p.messagePool {
if !m.inProcess {
p.m.Lock()
m.inProcess = true
c <- MessageTransaction{
done: false,
key: k,
msg: m,
pool: &p,
}
p.m.Unlock()
}
close(c)
}
}()
return c
}
type PoolConfig struct {
CountOfConnection int32
Identity, Username, Password string
Host string
}
type SMTPPool struct {
config PoolConfig
countOfConnection int32
connections chan *smtp.Client
MessagePool *MessagePool
}
// Get SMTPPoolConfig return SMTPPool
func NewSMTPPool(conf PoolConfig) (*SMTPPool, error) {
auth := smtp.PlainAuth(conf.Identity, conf.Username, conf.Password, conf.Host)
pool := &SMTPPool{
config: conf,
connections: make(chan *smtp.Client, conf.CountOfConnection),
countOfConnection: 0,
}
go pool.healthChecker(auth)
// Init Connections
for range conf.CountOfConnection {
c, err := newClient(conf, auth)
if err != nil {
return nil, fmt.Errorf("pkg smtp pool: NewSMTPPool: Init Connections: %w", err)
}
// Add to pool
pool.connections <- c
pool.countOfConnection++
}
return pool, nil
}
func newClient(conf PoolConfig, auth smtp.Auth) (*smtp.Client, error) {
c, err := smtp.Dial(conf.Host)
if err != nil {
return nil, fmt.Errorf("newClient: %w", err)
}
if err = c.Hello(conf.Identity); err != nil {
return nil, err
}
// Check TLS
if ok, _ := c.Extension("STARTTLS"); ok {
config := &tls.Config{ServerName: conf.Host}
if err = c.StartTLS(config); err != nil {
return nil, fmt.Errorf("newClient: %w", err)
}
}
// Auth
if ok, _ := c.Extension("AUTH"); ok {
if auth == nil {
return nil, errors.New("newClient: auth is nil")
}
if err := c.Auth(auth); err != nil {
return nil, fmt.Errorf("newClient: %w", err)
}
}
if err := c.Noop(); err != nil {
return nil, fmt.Errorf("newClient: c.Noop() error: %w", err)
}
return c, nil
}
// Daemon. Doing Helth-Check and Auto-Heal of SMTPPool
func (p *SMTPPool) healthChecker(auth smtp.Auth) {
ticker := time.NewTicker(30 * time.Second)
for range ticker.C {
// Auto-Heal Pool
current := atomic.LoadInt32(&p.countOfConnection)
needed := int32(p.config.CountOfConnection) - current
if needed > 0 {
for range p.config.CountOfConnection - p.countOfConnection {
newConn, err := newClient(p.config, auth)
if err != nil {
fmt.Println("WARNING: pkg smtp pool: healthChecker: Create connection: %w", err)
continue
}
p.connections <- newConn
atomic.AddInt32(&p.countOfConnection, 1)
}
}
select {
case conn := <-p.connections:
if err := conn.Noop(); err != nil {
atomic.AddInt32(&p.countOfConnection, -1)
conn.Close()
} else {
p.connections <- conn
}
default:
// All Conns is busy
}
}
}
func (p *SMTPPool) SendMail(from string, to []string, mess []byte) error {
return nil
}