Skip to content

Commit d475962

Browse files
committed
feat: Implement VAPID key generation and configuration for Web Push notifications
1 parent b778181 commit d475962

File tree

7 files changed

+153
-10
lines changed

7 files changed

+153
-10
lines changed

cmd/generate-vapid-keys/main.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
package main
2+
3+
import (
4+
"fmt"
5+
"log"
6+
7+
"trakrlog/internal/auth"
8+
)
9+
10+
func main() {
11+
// Generate VAPID keys
12+
keys, err := auth.GenerateVAPIDKeys()
13+
if err != nil {
14+
log.Fatalf("Failed to generate VAPID keys: %v", err)
15+
}
16+
17+
fmt.Println("=== VAPID Keys Generated ===")
18+
fmt.Println("\nAdd these to your environment variables (.env file):")
19+
fmt.Println()
20+
fmt.Printf("VAPID_PUBLIC_KEY=%s\n", keys.PublicKey)
21+
fmt.Printf("VAPID_PRIVATE_KEY=%s\n", keys.PrivateKey)
22+
fmt.Printf("VAPID_SUBJECT=mailto:[email protected]\n")
23+
fmt.Println()
24+
fmt.Println("Note: Replace '[email protected]' with your actual email or website URL")
25+
fmt.Println("Example: mailto:[email protected] or https://trakrlog.com")
26+
}

go.mod

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ go 1.25.4
55
require (
66
github.com/gin-contrib/cors v1.7.6
77
github.com/gin-gonic/gin v1.11.0
8+
github.com/gorilla/sessions v1.1.1
89
github.com/joho/godotenv v1.5.1
910
github.com/testcontainers/testcontainers-go v0.40.0
1011
github.com/testcontainers/testcontainers-go/modules/mongodb v0.40.0
@@ -17,7 +18,6 @@ require (
1718
github.com/gorilla/context v1.1.1 // indirect
1819
github.com/gorilla/mux v1.6.2 // indirect
1920
github.com/gorilla/securecookie v1.1.1 // indirect
20-
github.com/gorilla/sessions v1.1.1 // indirect
2121
golang.org/x/oauth2 v0.33.0 // indirect
2222
)
2323

@@ -44,7 +44,7 @@ require (
4444
github.com/felixge/httpsnoop v1.0.4 // indirect
4545
github.com/gabriel-vasile/mimetype v1.4.11 // indirect
4646
github.com/gin-contrib/sse v1.1.0 // indirect
47-
github.com/gin-contrib/static v1.1.5 // indirect
47+
github.com/gin-contrib/static v1.1.5
4848
github.com/go-logr/logr v1.4.3 // indirect
4949
github.com/go-logr/stdr v1.2.2 // indirect
5050
github.com/go-ole/go-ole v1.2.6 // indirect
@@ -54,7 +54,7 @@ require (
5454
github.com/goccy/go-json v0.10.5 // indirect
5555
github.com/goccy/go-yaml v1.18.0 // indirect
5656
github.com/golang/snappy v1.0.0 // indirect
57-
github.com/google/uuid v1.6.0 // indirect
57+
github.com/google/uuid v1.6.0
5858
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // indirect
5959
github.com/json-iterator/go v1.1.12 // indirect
6060
github.com/klauspost/compress v1.18.0 // indirect
@@ -85,7 +85,7 @@ require (
8585
github.com/quic-go/quic-go v0.57.1 // indirect
8686
github.com/shirou/gopsutil/v4 v4.25.6 // indirect
8787
github.com/sirupsen/logrus v1.9.3 // indirect
88-
github.com/stretchr/testify v1.11.1 // indirect
88+
github.com/stretchr/testify v1.11.1
8989
github.com/tklauser/go-sysconf v0.3.12 // indirect
9090
github.com/tklauser/numcpus v0.6.1 // indirect
9191
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect

go.sum

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -235,8 +235,6 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v
235235
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
236236
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
237237
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
238-
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
239-
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
240238
golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo=
241239
golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
242240
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=

internal/auth/vapid.go

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
package auth
2+
3+
import (
4+
"crypto/ecdsa"
5+
"crypto/elliptic"
6+
"crypto/rand"
7+
"encoding/base64"
8+
"fmt"
9+
"math/big"
10+
)
11+
12+
// VapidKeys represents a VAPID public/private key pair
13+
type VapidKeys struct {
14+
PublicKey string
15+
PrivateKey string
16+
}
17+
18+
// GenerateVAPIDKeys generates a new VAPID key pair for Web Push notifications
19+
// This should be run once during initial setup and the keys stored securely
20+
func GenerateVAPIDKeys() (*VapidKeys, error) {
21+
// Generate P-256 elliptic curve private key
22+
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
23+
if err != nil {
24+
return nil, fmt.Errorf("failed to generate private key: %w", err)
25+
}
26+
27+
// Encode private key to base64 URL-safe format
28+
privateKeyBytes := privateKey.D.Bytes()
29+
// Ensure the key is 32 bytes (pad with zeros if needed)
30+
if len(privateKeyBytes) < 32 {
31+
paddedKey := make([]byte, 32)
32+
copy(paddedKey[32-len(privateKeyBytes):], privateKeyBytes)
33+
privateKeyBytes = paddedKey
34+
}
35+
privateKeyEncoded := base64.RawURLEncoding.EncodeToString(privateKeyBytes)
36+
37+
// Encode public key to base64 URL-safe format (uncompressed format)
38+
// First byte is 0x04 (indicating uncompressed point), followed by X and Y coordinates
39+
publicKeyBytes := make([]byte, 65)
40+
publicKeyBytes[0] = 0x04
41+
42+
xBytes := privateKey.PublicKey.X.Bytes()
43+
yBytes := privateKey.PublicKey.Y.Bytes()
44+
45+
// Ensure X and Y are 32 bytes each (pad with zeros if needed)
46+
copy(publicKeyBytes[1+32-len(xBytes):33], xBytes)
47+
copy(publicKeyBytes[33+32-len(yBytes):65], yBytes)
48+
49+
publicKeyEncoded := base64.RawURLEncoding.EncodeToString(publicKeyBytes)
50+
51+
return &VapidKeys{
52+
PublicKey: publicKeyEncoded,
53+
PrivateKey: privateKeyEncoded,
54+
}, nil
55+
}
56+
57+
// ParseVAPIDPrivateKey parses a base64-encoded VAPID private key into an ECDSA private key
58+
func ParseVAPIDPrivateKey(encodedKey string) (*ecdsa.PrivateKey, error) {
59+
// Decode the base64 URL-safe encoded private key
60+
privateKeyBytes, err := base64.RawURLEncoding.DecodeString(encodedKey)
61+
if err != nil {
62+
return nil, fmt.Errorf("failed to decode private key: %w", err)
63+
}
64+
65+
// Convert bytes to big.Int
66+
d := new(big.Int).SetBytes(privateKeyBytes)
67+
68+
// Create the private key
69+
privateKey := new(ecdsa.PrivateKey)
70+
privateKey.PublicKey.Curve = elliptic.P256()
71+
privateKey.D = d
72+
73+
// Calculate the public key from the private key
74+
privateKey.PublicKey.X, privateKey.PublicKey.Y = privateKey.PublicKey.Curve.ScalarBaseMult(d.Bytes())
75+
76+
return privateKey, nil
77+
}

internal/repository/notification_test.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,22 +17,22 @@ import (
1717
// Helper function to setup test database
1818
func setupTestDB(t *testing.T) *mongo.Database {
1919
ctx := context.Background()
20-
20+
2121
// Connect to test MongoDB instance
2222
client, err := mongo.Connect(ctx, options.Client().ApplyURI("mongodb://localhost:27017"))
2323
require.NoError(t, err)
24-
24+
2525
// Use a test database
2626
db := client.Database("trakrlog_test")
27-
27+
2828
// Clean up function
2929
t.Cleanup(func() {
3030
err := db.Drop(ctx)
3131
assert.NoError(t, err)
3232
err = client.Disconnect(ctx)
3333
assert.NoError(t, err)
3434
})
35-
35+
3636
return db
3737
}
3838

internal/server/server.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ type Server struct {
2222
projectService *service.ProjectService
2323
channelService *service.ChannelService
2424
eventService *service.EventService
25+
vapidConfig *VapidConfig
2526
}
2627

2728
func New() *http.Server {
@@ -33,6 +34,9 @@ func New() *http.Server {
3334
port, _ := strconv.Atoi(os.Getenv("PORT"))
3435
log.Printf("[⚡️ Server]: Starting server on port %d\n", port)
3536

37+
// Load VAPID configuration
38+
vapidConfig := LoadVapidConfig()
39+
3640
// Initialize database
3741
db := database.New()
3842

@@ -55,6 +59,7 @@ func New() *http.Server {
5559
projectService: projectService,
5660
channelService: channelService,
5761
eventService: eventService,
62+
vapidConfig: vapidConfig,
5863
}
5964

6065
// Declare Server config

internal/server/vapid.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
package server
2+
3+
import (
4+
"log"
5+
"os"
6+
)
7+
8+
// VapidConfig holds the VAPID configuration for Web Push notifications
9+
type VapidConfig struct {
10+
PublicKey string
11+
PrivateKey string
12+
Subject string
13+
}
14+
15+
// LoadVapidConfig loads VAPID configuration from environment variables
16+
func LoadVapidConfig() *VapidConfig {
17+
config := &VapidConfig{
18+
PublicKey: os.Getenv("VAPID_PUBLIC_KEY"),
19+
PrivateKey: os.Getenv("VAPID_PRIVATE_KEY"),
20+
Subject: os.Getenv("VAPID_SUBJECT"),
21+
}
22+
23+
// Validate VAPID configuration
24+
if config.PublicKey == "" || config.PrivateKey == "" || config.Subject == "" {
25+
log.Println("Warning: VAPID configuration incomplete. Push notifications will not be available.")
26+
log.Println("Run 'go run cmd/generate-vapid-keys/main.go' to generate VAPID keys.")
27+
return config
28+
}
29+
30+
log.Println("[⚡️ Server]: VAPID configuration loaded successfully")
31+
return config
32+
}
33+
34+
// IsValid returns whether the VAPID configuration is complete and valid
35+
func (c *VapidConfig) IsValid() bool {
36+
return c.PublicKey != "" && c.PrivateKey != "" && c.Subject != ""
37+
}

0 commit comments

Comments
 (0)