commit d8f48970c20b0e7b3af081a1beda89b31fd56d10 Author: kaiyga Date: Wed Aug 6 22:31:43 2025 +0500 Init commnit, First work build diff --git a/.air.toml b/.air.toml new file mode 100644 index 0000000..9626a59 --- /dev/null +++ b/.air.toml @@ -0,0 +1,52 @@ +root = "." +testdata_dir = "testdata" +tmp_dir = "tmp" + +[build] + args_bin = [] + bin = "./tmp/main" + cmd = "go build -o ./tmp/main ./internal/cmd/server/" + delay = 1000 + exclude_dir = ["assets", "tmp", "vendor", "testdata"] + exclude_file = [] + exclude_regex = ["_test.go"] + exclude_unchanged = false + follow_symlink = false + full_bin = "" + include_dir = [] + include_ext = ["go", "tpl", "tmpl", "html"] + include_file = [] + kill_delay = "0s" + log = "build-errors.log" + poll = false + poll_interval = 0 + post_cmd = [] + pre_cmd = [] + rerun = false + rerun_delay = 500 + send_interrupt = false + stop_on_error = false + +[color] + app = "" + build = "yellow" + main = "magenta" + runner = "green" + watcher = "cyan" + +[log] + main_only = false + silent = false + time = false + +[misc] + clean_on_exit = false + +[proxy] + app_port = 0 + enabled = false + proxy_port = 0 + +[screen] + clear_on_rebuild = false + keep_scroll = true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..856dfd9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +tmp/ +.env +config.yaml +config.yml + diff --git a/design.md b/design.md new file mode 100644 index 0000000..5219854 --- /dev/null +++ b/design.md @@ -0,0 +1,54 @@ +## Key storage + +``` +| fingerprint string | keyring | +``` + +## Verify + +Verify uri `/pks/v/{fingerprint}/{email}/{token}` + +Verify pipe::> [!WARNING] +- User send key to server +- If key new in server + - Send Message to emails in uids with token + +TODO: Clear keys with unverify uids + +UID Verify Table + +``` +| fingerprint string | uid string | email string | verify_token string | verify bool | +``` + +- uid "Full Name (comment) " +- email email@example.com + +## Get key + +URI `/pks/lookup?op={op}&search={search}` + +### Sanitaze key + +Key max-size + +#### Sanitaze key and cache it + +Before send key we can verify all signatures in our server + +And send key with self-storade key only + +It can so slow and loading our database. + +Before sanitaze we can look on + +PGP entity.SelfSignature.CreationTime + +If time < cached_time +- Use cache +Else +- Put verify state uids of fingerprints of signatures on getting key + +// PGP entity.Identities[0].Signatures[0].CreationTime + + diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..2db8d04 --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,9 @@ +services: + postgres_test: + image: postgres:17-alpine + environment: + POSTGRES_DB: test_hagrid_db + POSTGRES_USER: test_user + POSTGRES_PASSWORD: test_password + ports: + - "5433:5432" diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..b4c1faf --- /dev/null +++ b/go.mod @@ -0,0 +1,26 @@ +module gadrid + +go 1.24.4 + +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/stretchr/testify v1.8.1 +) + +require ( + github.com/cloudflare/circl v1.6.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/rogpeppe/go-internal v1.14.1 // indirect + golang.org/x/crypto v0.37.0 // indirect + golang.org/x/sync v0.13.0 // indirect + golang.org/x/sys v0.32.0 // indirect + golang.org/x/text v0.24.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..ca469a8 --- /dev/null +++ b/go.sum @@ -0,0 +1,49 @@ +github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw= +github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE= +github.com/cloudflare/circl v1.6.0 h1:cr5JKic4HI+LkINy2lg3W2jF8sHCVTBncJr5gIIq7qk= +github.com/cloudflare/circl v1.6.0/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/emersion/go-openpgp-hkp v0.0.0-20231106091114-bb7c1dad7252 h1:SREvbqIjPJ+lRkk3RXkFhioEkTZgLM8GKYUHYYP1iTE= +github.com/emersion/go-openpgp-hkp v0.0.0-20231106091114-bb7c1dad7252/go.mod h1:NrHtbDUqUo2n/tib6zrQ7TzUvUf8mD0GZHQck0wcLfw= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +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/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/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= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= +golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= +golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= +golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= +golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= +golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/cmd/server/main.go b/internal/cmd/server/main.go new file mode 100644 index 0000000..6639768 --- /dev/null +++ b/internal/cmd/server/main.go @@ -0,0 +1,45 @@ +package main + +import ( + "context" + "fmt" + "net/http" + + "gadrid/internal/repo/storage/postgresql" + storageService "gadrid/internal/service/storage/serv" + + hkp "github.com/emersion/go-openpgp-hkp" + "github.com/jackc/pgx/v5/pgxpool" +) + +const ( + listenAddress = "localhost:8181" +) + +func main() { + + dbpool, err := pgxpool.New(context.TODO(), "postgresql://test_user:test_password@localhost:5433/test_hagrid_db") + if err != nil { + panic(err) + } + + dbRepo := postgresql.New(context.TODO(), dbpool) + storageService := storageService.New(dbRepo) + + handler := hkp.Handler{ + Adder: storageService, + Lookuper: storageService, + } + + http.HandleFunc("/pks/lookup", func(w http.ResponseWriter, r *http.Request) { + handler.ServeHTTP(w, r) + }) + http.HandleFunc("/pks/add", func(w http.ResponseWriter, r *http.Request) { + handler.ServeHTTP(w, r) + }) + http.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) +} diff --git a/internal/controller/hkp/controller.go b/internal/controller/hkp/controller.go new file mode 100644 index 0000000..d37c37e --- /dev/null +++ b/internal/controller/hkp/controller.go @@ -0,0 +1,33 @@ +package hkp + +import ( + "gadrid/internal/service/storage" + + "github.com/ProtonMail/go-crypto/openpgp" + hkp "github.com/emersion/go-openpgp-hkp" +) + +type HkpController struct { + hkp.Adder + hkp.Lookuper + storage storage.StorageInterface + // verify +} + +func New(st storage.StorageInterface) (HkpController, error) { + return HkpController{ + storage: st, + }, nil +} + +func (s HkpController) Add(k openpgp.EntityList) error { + return nil +} + +func (s HkpController) Get(r *hkp.LookupRequest) (openpgp.EntityList, error) { + return openpgp.EntityList{}, nil +} + +func (s HkpController) Index(r *hkp.LookupRequest) ([]hkp.IndexKey, error) { + return []hkp.IndexKey{}, nil +} diff --git a/internal/repo/storage/interface.go b/internal/repo/storage/interface.go new file mode 100644 index 0000000..ea37816 --- /dev/null +++ b/internal/repo/storage/interface.go @@ -0,0 +1,81 @@ +package storage + +import ( + "context" + "fmt" + service "gadrid/internal/service/storage" +) + +var ( + ErrKeyNotFound = fmt.Errorf("pgp key not found") +) + +type StorageRepositotyInterface interface { + // AddKey handles the insertion or update of PGP keys and their associated UIDs. + // It performs an UPSERT on pgp_keys and an INSERT (ON CONFLICT DO NOTHING) on pgp_uids. + // The service layer is responsible for merging UID data before calling this method. + // + // Parameters: + // ctx: Context for the operation. + // keys: A slice of service.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 + + // 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. + // + // Parameters: + // ctx: Context for the operation. + // fingerprint: The fingerprint of the PGP key. + // email: The email address associated with the UID to verify. + // token: The verification token provided by the user. + // + // Returns: + // error: Returns an error if: + // - No matching UID entry is found (e.g., wrong fingerprint or email). + // - The UID is already verified. + // - The provided token does not match the stored token. + // - The token has expired. + // - Database operation fails (e.g., connection issues, SQL update errors). + // Specific errors like "invalid verification token or link" are used for security reasons. + VerifyUID(ctx context.Context, fingerprint, email, token string) error + + // GetKey retrieves a PGP key and its associated UIDs by fingerprint. + // It only includes UIDs that are marked as 'verified = true' in the database. + // + // Parameters: + // ctx: Context for the operation. + // 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). + // 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) + + // Index searches for PGP keys based on a query string. + // It matches against UID strings and emails using case-insensitive regular expressions. + // Only keys with at least one verified UID matching the search will be returned. + // + // Parameters: + // ctx: Context for the operation. + // 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. + // error: Returns an error if database query or scanning fails. + Index(ctx context.Context, q string) ([]*service.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. + // + // Parameters: + // ctx: Context for the operation. + // + // Returns: + // int: The number of keys deleted. + // error: An error if the cleanup operation fails. + CleanupStaleKeys(ctx context.Context) (int, error) +} diff --git a/internal/repo/storage/mock.go b/internal/repo/storage/mock.go new file mode 100644 index 0000000..cf35b49 --- /dev/null +++ b/internal/repo/storage/mock.go @@ -0,0 +1,179 @@ +package storage + +import ( + "context" + "fmt" + "strings" + "sync" + "time" + + service "gadrid/internal/service/storage" // 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 +} + +// 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), + } +} + +// AddKey implements PGPKeyStorage interface for the mock. +// See PGPKeyStorage interface for documentation. +func (m *InMemStorageMock) AddKey(ctx context.Context, keys []*service.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 + Fingerprint: k.Fingerprint, + Packet: k.Packet, + Revoked: k.Revoked, + // Uids are handled separately, so this slice will be empty here in the key itself + } + + // Ensure the inner map exists for UIDs + if _, ok := m.uids[k.Fingerprint]; !ok { + m.uids[k.Fingerprint] = make(map[string]*service.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, + Email: u.Email, + Verify: u.Verify, + Token: u.Token, + TokenExpires: u.TokenExpires, + } + } + } + } + return nil +} + +// VerifyUID implements PGPKeyStorage interface for the mock. +// See PGPKeyStorage interface for documentation. +func (m *InMemStorageMock) VerifyUID(ctx context.Context, fingerprint, email, token string) error { + m.mu.Lock() + defer m.mu.Unlock() + + keyUids, ok := m.uids[fingerprint] + if !ok { + return fmt.Errorf("invalid verification token or link") // Mimic no matching entry + } + + var targetUID *service.PGPUid + var targetUIDString string // To be used as key in the map for update + for uidStr, uid := range keyUids { + if uid.Email == email { + targetUID = uid + targetUIDString = uidStr + break + } + } + + if targetUID == nil { + return fmt.Errorf("invalid verification token or link") // Mimic no matching entry by email + } + + if targetUID.Verify { + return fmt.Errorf("UID for %s is already verified", email) + } + if targetUID.Token == "" || targetUID.Token != token { + return fmt.Errorf("invalid verification token or link") + } + if targetUID.TokenExpires.Before(time.Now()) { + return fmt.Errorf("verification token has expired") + } + + // Simulate update + targetUID.Verify = true + 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 + m.uids[fingerprint][targetUIDString] = targetUID + + return nil +} + +// GetKey implements PGPKeyStorage interface for the mock. +// See PGPKeyStorage interface for documentation. +func (m *InMemStorageMock) GetKey(ctx context.Context, fingerprint string) ([]*service.PGPkey, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + pgpKey, ok := m.keys[fingerprint] + if !ok { + return nil, ErrKeyNotFound // Use the specific error defined in the interface package + } + + // Create a deep copy of the key + resultKey := &service.PGPkey{ + Fingerprint: pgpKey.Fingerprint, + Packet: pgpKey.Packet, + Revoked: pgpKey.Revoked, + Uids: []*service.PGPUid{}, // Only add verified UIDs + } + + // Filter UIDs by 'verified = true' + if keyUids, ok := m.uids[fingerprint]; ok { + for _, uid := range keyUids { + if uid.Verify { + // Deep copy the UID + resultKey.Uids = append(resultKey.Uids, &service.PGPUid{ + UIDString: uid.UIDString, + Email: uid.Email, + Verify: uid.Verify, + Token: uid.Token, + TokenExpires: uid.TokenExpires, + }) + } + } + } + + return []*service.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) { + m.mu.RLock() + defer m.mu.RUnlock() + + matchedFingerprints := make(map[string]bool) + lowerQ := strings.ToLower(q) // Simulate case-insensitive search + + for f, uidMap := range m.uids { + for _, uid := range uidMap { + // 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) { + matchedFingerprints[f] = true + } + } + } + + var resultKeys []*service.PGPkey + for f := range matchedFingerprints { + // Reuse GetKey logic from mock (which filters for verified UIDs) + keys, err := m.GetKey(ctx, f) + if err == nil && len(keys) > 0 { // Check error from GetKey (e.g., ErrKeyNotFound) + resultKeys = append(resultKeys, keys[0]) + } + } + + return resultKeys, nil +} diff --git a/internal/repo/storage/postgresql/repository.go b/internal/repo/storage/postgresql/repository.go new file mode 100644 index 0000000..790a3df --- /dev/null +++ b/internal/repo/storage/postgresql/repository.go @@ -0,0 +1,273 @@ +package postgresql + +import ( + "context" + "database/sql" + "errors" + "fmt" + repo "gadrid/internal/repo/storage" + service "gadrid/internal/service/storage" + "strings" + "time" + + _ "embed" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +//go:embed structure.sql +var db_struct string + +type PostgresqlStorageRepo struct { + poll *pgxpool.Pool +} + +// Get context, url +// Return postgresql repository +func New(ctx context.Context, pool *pgxpool.Pool) repo.StorageRepositotyInterface { + _, err := pool.Exec(ctx, db_struct) + if err != nil { + panic(err) + } + + return PostgresqlStorageRepo{ + poll: pool, + } +} + +func (s PostgresqlStorageRepo) AddKey(ctx context.Context, keys []*service.PGPkey) error { + tx, err := s.poll.Begin(ctx) + if err != nil { + return fmt.Errorf("error postgresql: AddKey: begin transaction: %w", err) + } + defer tx.Rollback(ctx) + + 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) + 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) + } + + br := tx.SendBatch(ctx, batch) + // Consume all results from the batch to ensure completion + for i := 0; i < len(k.Uids); i++ { + _, err = br.Exec() + if err != nil { + br.Close() + return fmt.Errorf("error postgresql: AddKey: batch insert uid %d for key %s: %w", i, fingerprint, err) + } + } + br.Close() + } + + return tx.Commit(ctx) +} + +func (s PostgresqlStorageRepo) VerifyUID(ctx context.Context, fingerprint, email, token string) error { + tx, err := s.poll.Begin(ctx) + if err != nil { + return fmt.Errorf("error postgresql: VerifyUID: begin transaction: %w", err) + } + defer tx.Rollback(ctx) + + // SELECT the UID to verify. + var storedUID string // The full UID string (e.g., "Name ") + var storedToken sql.NullString + 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) + + if err = row.Scan(&storedUID, &storedToken, &tokenExpiresAt, &isVerified); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + // Not found + return fmt.Errorf("error postgresql: VerifyUID: invalid verification token or link") + } + return fmt.Errorf("error postgresql: VerifyUID: failed to scan UID data: %w", err) + } + + if isVerified { + return fmt.Errorf("error postgresql: VerifyUID: UID for %s is already verified", email) + } + + if storedToken.String == "" || storedToken.String != token { + return fmt.Errorf("error postgresql: VerifyUID: invalid verification token or link") + } + + if tokenExpiresAt.Time.Before(time.Now()) { + return fmt.Errorf("error postgresql: VerifyUID: verification token has expired") + } + + // 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) + if err != nil { + return fmt.Errorf("error postgresql: VerifyUID: failed to update UID verification status: %w", err) + } + + rowsAffected := result.RowsAffected() + if rowsAffected == 0 { + return fmt.Errorf("error postgresql: VerifyUID: verification failed, token might be invalid or already used") + } + if rowsAffected > 1 { + return fmt.Errorf("error postgresql: VerifyUID: multiple UIDs affected by verification (data integrity error!)") + } + + 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 + + row := s.poll.QueryRow(ctx, ` + select fingerprint, packet, revoked, update_time + from pgp_keys + where fingerprint ~* $1; + `, fngprt) + + if err := row.Scan(&fingerprint, &packet, &revoked, &update_time); 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) + } + + if !strings.Contains(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) + if err != nil { + return nil, fmt.Errorf("error postgresql: GetKey: failed to query UIDs for key %s: %w", fngprt, err) + } + defer rows.Close() + + var uids []*service.PGPUid + for rows.Next() { + + uid := &service.PGPUid{} + 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) + } + + uid.Token = token.String + uid.TokenExpires = tokenExpiresAt.Time + + uids = append(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) + } + + pgpKey := &service.PGPkey{ + Fingerprint: fngprt, + UpdateTime: update_time, + Packet: packet, + Revoked: revoked, + Uids: uids, // Only verified UIDs included + } + return []*service.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) + if err != nil { + return nil, fmt.Errorf("error postgresql: Index: failed to query matching fingerprints for '%s': %w", q, err) + } + defer fingerprintRows.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) + 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) + } + if len(keys) > 0 { + resultKeys = append(resultKeys, keys[0]) + } + } + + 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 + ); + `) + if err != nil { + return 0, fmt.Errorf("error postgresql: CleanupStaleKeys: failed to delete stale keys: %w", err) + } + + rowsAffected := int(result.RowsAffected()) + return rowsAffected, nil +} diff --git a/internal/repo/storage/postgresql/structure.sql b/internal/repo/storage/postgresql/structure.sql new file mode 100644 index 0000000..26b241a --- /dev/null +++ b/internal/repo/storage/postgresql/structure.sql @@ -0,0 +1,35 @@ +-- Table for PGP keys +CREATE TABLE IF NOT EXISTS pgp_keys ( + fingerprint VARCHAR(64) PRIMARY KEY, + packet TEXT NOT NULL, -- Armored public key block + revoked BOOLEAN NOT NULL DEFAULT FALSE, + update_time TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_pgp_keys_fingerprint ON pgp_keys (fingerprint); + + +-- Table for User IDs associated with PGP keys +CREATE TABLE IF NOT EXISTS pgp_uids ( + fingerprint VARCHAR(64) NOT NULL, -- Foreign key to pgp_keys.fingerprint + uid TEXT NOT NULL, -- Full User ID string (e.g., "Full Name (Comment) ") + email TEXT NOT NULL, + verified BOOLEAN NOT NULL DEFAULT FALSE, + verification_token TEXT UNIQUE, -- Token for email verification + token_expires_at TIMESTAMP WITH TIME ZONE, -- Token expiry timestamp + + UNIQUE (fingerprint, uid), -- Ensures unique User ID for a given key + + CONSTRAINT fk_pgp_uids_fingerprint + FOREIGN KEY (fingerprint) + REFERENCES pgp_keys (fingerprint) + ON DELETE CASCADE +); + +-- Indexes for efficient lookups +CREATE INDEX IF NOT EXISTS idx_pgp_uids_email ON pgp_uids (email); +CREATE INDEX IF NOT EXISTS idx_pgp_uids_verified ON pgp_uids (verified); + +-- For advanced text search on 'uid' (user_id_string) using LIKE or regex, consider pg_trgm. +CREATE EXTENSION IF NOT EXISTS pg_trgm; +CREATE INDEX IF NOT EXISTS idx_pgp_uids_uid_gin ON pgp_uids USING GIN (uid gin_trgm_ops); diff --git a/internal/repo/storage/postgresql/test/initDB_test.go b/internal/repo/storage/postgresql/test/initDB_test.go new file mode 100644 index 0000000..7c032e8 --- /dev/null +++ b/internal/repo/storage/postgresql/test/initDB_test.go @@ -0,0 +1,73 @@ +package postgresql_test + +import ( + "context" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +const ( + testDBConnString = "postgresql://test_user:test_password@localhost:5433/test_hagrid_db" +) + +// setupTestDBPool initializes a connection pool for testing and ensures schema is created. +func setupTestDBPool(t testing.TB) *pgxpool.Pool { + // Allow time for Docker container to start + time.Sleep(2 * time.Second) + + pool, err := pgxpool.New(context.Background(), testDBConnString) + if err != nil { + t.Fatalf("Failed to create test DB pool: %v", err) + } + + // Ping to ensure connection + if err := pool.Ping(context.Background()); err != nil { + pool.Close() + t.Fatalf("Failed to ping test DB: %v", err) + } + + // Initialize schema (your dbStructureSQL from //go:embed) + // Ensure dbStructureSQL is accessible here. + // If it's embedded in main repository.go, you might need to make NewPostgresqlStorageRepo public + // or re-embed it directly in the test file (less ideal). + // Best: embed it in a separate package for common DB utils or make a separate TestInitDB function. + _, err = pool.Exec(context.Background(), dbStructureSQL) + if err != nil { + pool.Close() + t.Fatalf("Failed to initialize test DB schema: %v", err) + } + + // Enable pg_trgm for testing Index method if needed + _, err = pool.Exec(context.Background(), "CREATE EXTENSION IF NOT EXISTS pg_trgm;") + if err != nil { + pool.Close() + t.Fatalf("Failed to enable pg_trgm extension: %v", err) + } + // Also create the specific GIN indexes + _, err = pool.Exec(context.Background(), "CREATE INDEX IF NOT EXISTS idx_pgp_uids_uid_gin ON pgp_uids USING GIN (uid gin_trgm_ops);") + if err != nil { + pool.Close() + t.Fatalf("Failed to create idx_pgp_uids_uid_gin: %v", err) + } + _, err = pool.Exec(context.Background(), "CREATE INDEX IF NOT EXISTS idx_pgp_uids_email_gin ON pgp_uids USING GIN (email gin_trgm_ops);") + if err != nil { + pool.Close() + t.Fatalf("Failed to create idx_pgp_uids_email_gin: %v", err) + } + + return pool +} + +// cleanupTestDB clears all data from relevant tables. +func cleanupTestDB(t testing.TB, pool *pgxpool.Pool) { + // Truncate tables, CASCADE if needed + _, err := pool.Exec(context.Background(), ` + truncate table pgp_uids cascade; + truncate table pgp_keys cascade; + `) + if err != nil { + t.Errorf("Failed to cleanup test DB: %v", err) + } +} diff --git a/internal/repo/storage/postgresql/test/postgresql_test.go b/internal/repo/storage/postgresql/test/postgresql_test.go new file mode 100644 index 0000000..ae4f4d4 --- /dev/null +++ b/internal/repo/storage/postgresql/test/postgresql_test.go @@ -0,0 +1,360 @@ +package postgresql_test + +import ( + "context" + "database/sql" + "errors" + "testing" + "time" + + _ "embed" + + "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" +) + +//go:embed structure.sql +var dbStructureSQL string + +func TestPostgresqlStorageRepo_AddKey(t *testing.T) { + pool := setupTestDBPool(t) + defer pool.Close() + defer cleanupTestDB(t, pool) + + repo := psqlrepo.New(context.Background(), pool) + + ctx := context.Background() + + // --- Test Case 1: Add a new key --- + t.Run("Add new key successfully", func(t *testing.T) { + key := &service.PGPkey{ + Fingerprint: "FINGERPRINT_NEW_KEY", + Packet: "-----BEGIN PGP PUBLIC KEY BLOCK-----NEW_KEY_PACKET-----END PGP PUBLIC KEY BLOCK-----", + Revoked: false, + Uids: []*service.PGPUid{ + {UIDString: "New User ", Email: "new@example.com", Verify: false, Token: "token1", TokenExpires: time.Now().Add(time.Hour)}, + }, + } + err := repo.AddKey(ctx, []*service.PGPkey{key}) + require.NoError(t, err) + + // Verify key was added + retrievedKeys, err := repo.GetKey(ctx, key.Fingerprint) + require.NoError(t, err) + require.Len(t, retrievedKeys, 1) + assert.Equal(t, key.Fingerprint, retrievedKeys[0].Fingerprint) + assert.Equal(t, key.Packet, retrievedKeys[0].Packet) + assert.False(t, retrievedKeys[0].Revoked) + assert.Empty(t, retrievedKeys[0].Uids) // GetKey only returns verified UIDs + + // Verify UID was added (check DB directly for unverified) + var uidCount int + err = pool.QueryRow(ctx, "select count(*) from pgp_uids where fingerprint = $1 and uid = $2", key.Fingerprint, "New User ").Scan(&uidCount) + require.NoError(t, err) + assert.Equal(t, 1, uidCount) + }) + + // --- 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{ + Fingerprint: "FINGERPRINT_EXISTING", + Packet: "-----BEGIN PGP PUBLIC KEY BLOCK-----INITIAL_PACKET-----END PGP PUBLIC KEY BLOCK-----", + Revoked: false, + Uids: []*service.PGPUid{ + {UIDString: "Existing User ", Email: "existing@example.com", Verify: false, Token: "initial_token", TokenExpires: time.Now().Add(time.Hour)}, + }, + } + err := repo.AddKey(ctx, []*service.PGPkey{initialKey}) + require.NoError(t, err) + + // Simulate an update: new packet, revoked true + updatedKey := &service.PGPkey{ + Fingerprint: "FINGERPRINT_EXISTING", + Packet: "-----BEGIN PGP PUBLIC KEY BLOCK-----UPDATED_PACKET-----END PGP PUBLIC KEY BLOCK-----", + Revoked: true, + Uids: []*service.PGPUid{ + {UIDString: "Existing User ", Email: "existing@example.com", Verify: false, Token: "updated_token", TokenExpires: time.Now().Add(time.Hour * 2)}, + }, + } + err = repo.AddKey(ctx, []*service.PGPkey{updatedKey}) + require.NoError(t, err) + + // Verify key was updated + retrievedKeys, err := repo.GetKey(ctx, updatedKey.Fingerprint) + require.NoError(t, err) + require.Len(t, retrievedKeys, 1) + assert.Equal(t, updatedKey.Fingerprint, retrievedKeys[0].Fingerprint) + assert.Equal(t, updatedKey.Packet, retrievedKeys[0].Packet) + assert.True(t, retrievedKeys[0].Revoked) + + // Verify UID was NOT updated (due to on conflict do nothing) + var storedToken string + err = pool.QueryRow(ctx, "select verification_token from pgp_uids where fingerprint = $1 and uid = $2", updatedKey.Fingerprint, "Existing User ").Scan(&storedToken) + require.NoError(t, err) + assert.Equal(t, "initial_token", storedToken) // Should still be initial_token + }) + + // --- Test Case 3: Add new UID to existing key --- + t.Run("Add new UID to existing key", func(t *testing.T) { + key := &service.PGPkey{ + Fingerprint: "FINGERPRINT_ADD_UID", + Packet: "-----BEGIN PGP PUBLIC KEY BLOCK-----ADD_UID_KEY-----END PGP PUBLIC KEY BLOCK-----", + Revoked: false, + Uids: []*service.PGPUid{ + {UIDString: "First UID ", Email: "first@example.com", Verify: false, Token: "token_first", TokenExpires: time.Now().Add(time.Hour)}, + }, + } + err := repo.AddKey(ctx, []*service.PGPkey{key}) + require.NoError(t, err) + + // Add a second UID to the same key + updatedKey := &service.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{ + {UIDString: "First UID ", Email: "first@example.com", Verify: false, Token: "token_first", TokenExpires: time.Now().Add(time.Hour)}, + {UIDString: "Second UID ", Email: "second@example.com", Verify: false, Token: "token_second", TokenExpires: time.Now().Add(time.Hour)}, + }, + } + err = repo.AddKey(ctx, []*service.PGPkey{updatedKey}) + require.NoError(t, err) + + // Verify both UIDs exist + var uidCount int + err = pool.QueryRow(ctx, "select count(*) from pgp_uids where fingerprint = $1", updatedKey.Fingerprint).Scan(&uidCount) + require.NoError(t, err) + assert.Equal(t, 2, uidCount) + }) +} + +func TestPostgresqlStorageRepo_VerifyUID(t *testing.T) { + pool := setupTestDBPool(t) + defer pool.Close() + defer cleanupTestDB(t, pool) + + repo := psqlrepo.New(context.Background(), pool) + ctx := context.Background() + + // Add a key with an unverified UID + keyFingerprint := "FINGERPRINT_VERIFY_KEY" + uidEmail := "verify@test.com" + uidToken := "verifytoken123" + uidExpires := time.Now().Add(time.Hour) + + err := repo.AddKey(ctx, []*service.PGPkey{ + { + Fingerprint: keyFingerprint, + Packet: "...", + Revoked: false, + Uids: []*service.PGPUid{ + {UIDString: "Verify Me <" + uidEmail + ">", Email: uidEmail, Verify: false, Token: uidToken, TokenExpires: uidExpires}, + }, + }, + }) + require.NoError(t, err) + + t.Run("Successfully verify UID", func(t *testing.T) { + err := repo.VerifyUID(ctx, keyFingerprint, uidEmail, uidToken) + require.NoError(t, err) + + // Verify status in DB + var verified bool + var token sql.NullString + var expires sql.NullTime + err = pool.QueryRow(ctx, "select verified, verification_token, token_expires_at from pgp_uids where fingerprint = $1 and email = $2", keyFingerprint, uidEmail).Scan(&verified, &token, &expires) + require.NoError(t, err) + assert.True(t, verified) + assert.False(t, token.Valid) // Should be null + assert.False(t, expires.Valid) // Should be null + }) + + t.Run("Fail verification with wrong token", func(t *testing.T) { + err := repo.VerifyUID(ctx, keyFingerprint, "wrongverify@email.io", "wrongtoken") + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid verification token or link") + + // Ensure status remains verified from previous successful run + var verified bool + err = pool.QueryRow(ctx, "select verified from pgp_uids where fingerprint = $1 and email = $2", keyFingerprint, uidEmail).Scan(&verified) + require.NoError(t, err) + assert.True(t, verified) + }) + + t.Run("Fail verification for already verified UID", func(t *testing.T) { + err := repo.VerifyUID(ctx, keyFingerprint, uidEmail, uidToken) + require.Error(t, err) + assert.Contains(t, err.Error(), "already verified") + }) + + t.Run("Fail verification with expired token", func(t *testing.T) { + // Add another key with an expired token + expiredFingerprint := "FINGERPRINT_EXPIRED_TOKEN" + expiredEmail := "expired@test.com" + expiredToken := "expiredtoken" + expiredTime := time.Now().Add(-time.Hour) + + err := repo.AddKey(ctx, []*service.PGPkey{ + { + Fingerprint: expiredFingerprint, + Packet: "...", + Revoked: false, + Uids: []*service.PGPUid{ + {UIDString: "Expired <" + expiredEmail + ">", Email: expiredEmail, Verify: false, Token: expiredToken, TokenExpires: expiredTime}, + }, + }, + }) + require.NoError(t, err) + + err = repo.VerifyUID(ctx, expiredFingerprint, expiredEmail, expiredToken) + require.Error(t, err) + assert.Contains(t, err.Error(), "token has expired") + + // Ensure it's still unverified + var verified bool + err = pool.QueryRow(ctx, "select verified from pgp_uids where fingerprint = $1 and email = $2", expiredFingerprint, expiredEmail).Scan(&verified) + require.NoError(t, err) + assert.False(t, verified) + }) +} + +func TestPostgresqlStorageRepo_GetKey(t *testing.T) { + pool := setupTestDBPool(t) + defer pool.Close() + defer cleanupTestDB(t, pool) + + repo := psqlrepo.New(context.Background(), pool) + ctx := context.Background() + + // Add a key with mixed UIDs (verified and unverified) + testFingerprint := "FINGERPRINT_GET_KEY" + err := repo.AddKey(ctx, []*service.PGPkey{ + { + Fingerprint: testFingerprint, + Packet: "PACKET_DATA", + Revoked: false, + Uids: []*service.PGPUid{ + {UIDString: "Verified User ", Email: "verified@example.com", Verify: false, Token: "tok_v", TokenExpires: time.Now().Add(time.Hour)}, + {UIDString: "Unverified User ", Email: "unverified@example.com", Verify: false, Token: "tok_uv", TokenExpires: time.Now().Add(time.Hour)}, + {UIDString: "Another Verified ", Email: "another@example.com", Verify: false, Token: "tok_av", TokenExpires: time.Now().Add(time.Hour)}, + }, + }, + }) + require.NoError(t, err) + + // Manually verify two UIDs for the test + require.NoError(t, repo.VerifyUID(ctx, testFingerprint, "verified@example.com", "tok_v")) + require.NoError(t, repo.VerifyUID(ctx, testFingerprint, "another@example.com", "tok_av")) + + t.Run("Retrieve key with only verified UIDs", func(t *testing.T) { + retrievedKeys, err := repo.GetKey(ctx, testFingerprint) + require.NoError(t, err) + require.Len(t, retrievedKeys, 1) + + key := retrievedKeys[0] + assert.Equal(t, testFingerprint, key.Fingerprint) + assert.Equal(t, "PACKET_DATA", key.Packet) + assert.False(t, key.Revoked) + assert.Len(t, key.Uids, 2) // Only 2 verified UIDs should be returned + + // Check UIDs content + foundEmails := make(map[string]bool) + for _, uid := range key.Uids { + assert.True(t, uid.Verify) // All returned UIDs must be verified + foundEmails[uid.Email] = true + } + assert.True(t, foundEmails["verified@example.com"]) + assert.True(t, foundEmails["another@example.com"]) + assert.False(t, foundEmails["unverified@example.com"]) // Should not be present + }) + + t.Run("Retrieve non-existent key", func(t *testing.T) { + k, err := repo.GetKey(ctx, "NON_EXISTENT_FINGERPRINT") + assert.Len(t, k, 0) + require.Error(t, err) + assert.True(t, errors.Is(err, repository.ErrKeyNotFound)) + }) +} + +func TestPostgresqlStorageRepo_Index(t *testing.T) { + pool := setupTestDBPool(t) + defer pool.Close() + defer cleanupTestDB(t, pool) + + repo := psqlrepo.New(context.Background(), pool) + ctx := context.Background() + + // Add multiple keys with various UIDs + keysToLoad := []*service.PGPkey{ + { + Fingerprint: "FINGERPRINT_ALPHA", Packet: "alpha_packet", Revoked: false, + Uids: []*service.PGPUid{ + {UIDString: "Alpha User ", Email: "alpha@example.com", Verify: false, Token: "t1", TokenExpires: time.Now().Add(time.Hour)}, + {UIDString: "Test Alpha ", 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{ + {UIDString: "Beta User ", 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{ + {UIDString: "Gamma User ", Email: "gamma@example.com", Verify: false, Token: "t4", TokenExpires: time.Now().Add(time.Hour)}, + {UIDString: "Test Gamma ", Email: "testgamma@mail.com", Verify: false, Token: "t5", TokenExpires: time.Now().Add(time.Hour)}, + }, + }, + } + require.NoError(t, repo.AddKey(ctx, keysToLoad)) + + // Manually verify some UIDs + require.NoError(t, repo.VerifyUID(ctx, "FINGERPRINT_ALPHA", "alpha@example.com", "t1")) + require.NoError(t, repo.VerifyUID(ctx, "FINGERPRINT_BETA", "beta@example.com", "t3")) + require.NoError(t, repo.VerifyUID(ctx, "FINGERPRINT_GAMMA", "testgamma@mail.com", "t5")) // Verify only one UID for gamma + + t.Run("Search by full UID string", func(t *testing.T) { + results, err := repo.Index(ctx, "Alpha User") + require.NoError(t, err) + require.Len(t, results, 1) + assert.Equal(t, "FINGERPRINT_ALPHA", results[0].Fingerprint) + assert.Len(t, results[0].Uids, 1) // Only 1 verified UID from alpha + assert.Equal(t, "alpha@example.com", results[0].Uids[0].Email) + }) + + t.Run("Search by email part", func(t *testing.T) { + results, err := repo.Index(ctx, "mail") + require.NoError(t, err) + require.Len(t, results, 1) // Only FINGERPRINT_GAMMA has a *verified* UID with @mail.com + assert.Equal(t, "FINGERPRINT_GAMMA", results[0].Fingerprint) + assert.Len(t, results[0].Uids, 1) + assert.Equal(t, "testgamma@mail.com", results[0].Uids[0].Email) + }) + + t.Run("Search by part of name (case-insensitive)", func(t *testing.T) { + results, err := repo.Index(ctx, "beta") + require.NoError(t, err) + require.Len(t, results, 1) + assert.Equal(t, "FINGERPRINT_BETA", results[0].Fingerprint) + assert.Len(t, results[0].Uids, 1) + assert.Equal(t, "beta@example.com", results[0].Uids[0].Email) + }) + + t.Run("Search for non-existent query", func(t *testing.T) { + results, err := repo.Index(ctx, "nonexistent") + require.NoError(t, err) + assert.Empty(t, results) + }) + + t.Run("Search for unverified UID (should not return key)", func(t *testing.T) { + // FINGERPRINT_ALPHA has an unverified UID "Test Alpha " + results, err := repo.Index(ctx, "Test Alpha") + require.NoError(t, err) + assert.Empty(t, results) + }) +} diff --git a/internal/repo/storage/postgresql/test/structure.sql b/internal/repo/storage/postgresql/test/structure.sql new file mode 100644 index 0000000..26b241a --- /dev/null +++ b/internal/repo/storage/postgresql/test/structure.sql @@ -0,0 +1,35 @@ +-- Table for PGP keys +CREATE TABLE IF NOT EXISTS pgp_keys ( + fingerprint VARCHAR(64) PRIMARY KEY, + packet TEXT NOT NULL, -- Armored public key block + revoked BOOLEAN NOT NULL DEFAULT FALSE, + update_time TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_pgp_keys_fingerprint ON pgp_keys (fingerprint); + + +-- Table for User IDs associated with PGP keys +CREATE TABLE IF NOT EXISTS pgp_uids ( + fingerprint VARCHAR(64) NOT NULL, -- Foreign key to pgp_keys.fingerprint + uid TEXT NOT NULL, -- Full User ID string (e.g., "Full Name (Comment) ") + email TEXT NOT NULL, + verified BOOLEAN NOT NULL DEFAULT FALSE, + verification_token TEXT UNIQUE, -- Token for email verification + token_expires_at TIMESTAMP WITH TIME ZONE, -- Token expiry timestamp + + UNIQUE (fingerprint, uid), -- Ensures unique User ID for a given key + + CONSTRAINT fk_pgp_uids_fingerprint + FOREIGN KEY (fingerprint) + REFERENCES pgp_keys (fingerprint) + ON DELETE CASCADE +); + +-- Indexes for efficient lookups +CREATE INDEX IF NOT EXISTS idx_pgp_uids_email ON pgp_uids (email); +CREATE INDEX IF NOT EXISTS idx_pgp_uids_verified ON pgp_uids (verified); + +-- For advanced text search on 'uid' (user_id_string) using LIKE or regex, consider pg_trgm. +CREATE EXTENSION IF NOT EXISTS pg_trgm; +CREATE INDEX IF NOT EXISTS idx_pgp_uids_uid_gin ON pgp_uids USING GIN (uid gin_trgm_ops); diff --git a/internal/service/mail/service.go b/internal/service/mail/service.go new file mode 100644 index 0000000..90d3492 --- /dev/null +++ b/internal/service/mail/service.go @@ -0,0 +1,5 @@ +package mail + +type MailServiceInterface interface { + SendMail(to []string, msg []byte) +} diff --git a/internal/service/storage/converter.go b/internal/service/storage/converter.go new file mode 100644 index 0000000..f7429b5 --- /dev/null +++ b/internal/service/storage/converter.go @@ -0,0 +1,111 @@ +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 +} diff --git a/internal/service/storage/interface.go b/internal/service/storage/interface.go new file mode 100644 index 0000000..8559e79 --- /dev/null +++ b/internal/service/storage/interface.go @@ -0,0 +1,30 @@ +package storage + +import ( + "time" + + "github.com/ProtonMail/go-crypto/openpgp" +) + +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) " + Fingerprint string +} diff --git a/internal/service/storage/serv/service.go b/internal/service/storage/serv/service.go new file mode 100644 index 0000000..bb8dc9e --- /dev/null +++ b/internal/service/storage/serv/service.go @@ -0,0 +1,153 @@ +package storage + +import ( + "context" + "errors" + "fmt" + "log" + "strings" + + storeRepo "gadrid/internal/repo/storage" + service "gadrid/internal/service/storage" + + "github.com/ProtonMail/go-crypto/openpgp" + hkp "github.com/emersion/go-openpgp-hkp" +) + +type StorageService struct { + hkp.Adder + hkp.Lookuper + // mailService interface{} + storage storeRepo.StorageRepositotyInterface +} + +func New(storageRepository storeRepo.StorageRepositotyInterface) StorageService { + return StorageService{ + storage: storageRepository, + } +} +func (s StorageService) Add(el openpgp.EntityList) error { + fmt.Println(el) + pgpKeys, err := service.ConverterPgpToService(el) // Assuming ConverterPgpToService might return an error now. + if err != nil { + return 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. + } + + // 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 + if err != nil { + return fmt.Errorf("service error: Add: failed to add keys to storage: %w", err) + } + + return nil // Successfully processed +} + +// 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) + 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) + } + + if len(dbKeys) == 0 { + return nil, fmt.Errorf("service error: Get: key %s not found (repository returned empty list): %w", req.Search, storeRepo.ErrKeyNotFound) + } + + dbKey := dbKeys[0] + + 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) + } + + return filteredEntity, 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 + if err != nil { + return nil, fmt.Errorf("service error: Index: db index request for '%s': %w", req.Search, err) // Include original error + } + + if len(dbKeys) == 0 { + return nil, nil + } + + var respIndex []hkp.IndexKey + for _, dbKey := range dbKeys { + filteredEntity, err := s.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 + } + + for _, fpk := range filteredEntity { + idxKey, err := hkp.IndexKeyFromEntity(fpk) + if err != nil { + log.Printf("Warning: service error: Index: failed to convert entity %s to IndexKey: %v, skipping.", dbKey.Fingerprint, err) + continue + } + respIndex = append(respIndex, *idxKey) + } + } + + if len(respIndex) == 0 { + return nil, fmt.Errorf("service error: Index: no valid index keys found after processing for '%s': %w", req.Search, storeRepo.ErrKeyNotFound) + } + + 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 +} diff --git a/main b/main new file mode 100755 index 0000000..90450dd Binary files /dev/null and b/main differ