Init commnit, First work build

This commit is contained in:
kaiyga 2025-08-06 22:31:43 +05:00
commit d8f48970c2
Signed by: kaiyga
GPG Key ID: 62645928AAEC738B
20 changed files with 1608 additions and 0 deletions

52
.air.toml Normal file
View File

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

5
.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
tmp/
.env
config.yaml
config.yml

54
design.md Normal file
View File

@ -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@example.com>"
- 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

9
docker-compose.yaml Normal file
View File

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

26
go.mod Normal file
View File

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

49
go.sum Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 <new@example.com>", 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 <new@example.com>").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 <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})
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 <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})
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 <existing@example.com>").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 <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})
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 <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})
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 <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)},
},
},
})
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 <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{
{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{
{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)},
},
},
}
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 <testalpha@mail.com>"
results, err := repo.Index(ctx, "Test Alpha")
require.NoError(t, err)
assert.Empty(t, results)
})
}

View File

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

View File

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

View File

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

View File

@ -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) <email@example.com>"
Fingerprint string
}

View File

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

BIN
main Executable file

Binary file not shown.