1
0
Fork 0
mirror of https://github.com/imjasonh/webpush synced 2026-07-06 23:52:25 +00:00
No description
Find a file
copilot-swe-agent[bot] d30c4b1906 Use SHA-256 hash for key ID to prevent collisions
Co-authored-by: imjasonh <210737+imjasonh@users.noreply.github.com>
2025-11-27 21:35:52 +00:00
example Use SHA-256 hash for key ID to prevent collisions 2025-11-27 21:35:52 +00:00
keys Add KMS key rotation and unused key cleanup 2025-11-27 21:05:53 +00:00
storage Add KMS key rotation and unused key cleanup 2025-11-27 21:05:53 +00:00
vapid Implement Web Push API Go library with VAPID support and pluggable storage 2025-11-24 21:19:16 +00:00
deploy.sh deploy script, use kms 2025-11-25 11:04:19 -06:00
go.mod deploy script, use kms 2025-11-25 11:04:19 -06:00
go.sum deploy script, use kms 2025-11-25 11:04:19 -06:00
integration_test.go Implement Web Push API Go library with VAPID support and pluggable storage 2025-11-24 21:19:16 +00:00
LICENSE Initial commit 2025-11-24 15:08:56 -06:00
README.md Add KMS key rotation and unused key cleanup 2025-11-27 21:05:53 +00:00
webpush.go Implement Web Push API Go library with VAPID support and pluggable storage 2025-11-24 21:19:16 +00:00
webpush_test.go Implement Web Push API Go library with VAPID support and pluggable storage 2025-11-24 21:19:16 +00:00

webpush

A Go library for sending Web Push API notifications with VAPID authentication.

Features

  • RFC 8291 compliant message encryption (aes128gcm)
  • RFC 8292 VAPID authentication
  • Pluggable VAPID key providers:
    • File-based (PEM or base64 encoded)
    • Google Cloud KMS
    • Rotating key manager for key rotation
  • Pluggable subscription storage:
    • In-memory (for testing/development)
    • SQLite
  • Key rotation support with subscription tracking
  • Easy integration with JavaScript Push API clients

Installation

go get github.com/imjasonh/webpush

Quick Start

See the example/ directory for a complete working demo with:

  • VAPID key generation and storage on disk
  • SQLite subscription storage
  • Web client with service worker for push notifications
  • Automatic periodic notifications and manual /ping endpoint

To run the example:

cd example
go run main.go

Then open http://localhost:8080 in your browser.

Basic Usage

package main

import (
    "context"
    "github.com/imjasonh/webpush"
    "github.com/imjasonh/webpush/keys"
)

func main() {
    // Load or generate VAPID keys
    signer, err := keys.NewFileSigner("vapid-private.pem")
    if err != nil {
        panic(err)
    }

    // Create client with VAPID subject (must be mailto: or https: URL)
    client := webpush.NewClient(signer, "mailto:admin@example.com")

    // Parse subscription from client (received from browser)
    sub, err := webpush.ParseSubscription([]byte(`{
        "endpoint": "https://fcm.googleapis.com/fcm/send/...",
        "keys": {
            "p256dh": "...",
            "auth": "..."
        }
    }`))
    if err != nil {
        panic(err)
    }

    // Send notification
    err = client.Send(context.Background(), sub, []byte(`{"title":"Hello","body":"World"}`), &webpush.Options{
        TTL:     3600,
        Urgency: "high",
    })
    if err != nil {
        panic(err)
    }
}

Using Google Cloud KMS

For production environments, you can store your VAPID private key in Google Cloud KMS:

signer, err := keys.NewKMSSigner(ctx, "projects/my-project/locations/global/keyRings/webpush/cryptoKeys/vapid/cryptoKeyVersions/1")
if err != nil {
    panic(err)
}
defer signer.Close()

client := webpush.NewClient(signer, "mailto:admin@example.com")

Create a KMS key:

gcloud kms keyrings create webpush --location=global
gcloud kms keys create vapid \
    --keyring=webpush \
    --location=global \
    --purpose=asymmetric-signing \
    --default-algorithm=ec-sign-p256-sha256

API Reference

Types

type Subscription struct {
    Endpoint string `json:"endpoint"`
    Keys     Keys   `json:"keys"`
}

type Options struct {
    TTL     int    // Time-to-live in seconds (default: 2419200 = 4 weeks)
    Urgency string // very-low, low, normal, high
    Topic   string // Topic for message replacement
}

Key Providers

// File-based (PEM)
signer, err := keys.NewFileSigner("path/to/key.pem")

// File-based (base64)
signer, err := keys.NewFileSignerFromBase64("base64-private-key")

// Google Cloud KMS
signer, err := keys.NewKMSSigner(ctx, "projects/.../cryptoKeyVersions/1")

// Generate new keys
signer, err := keys.GenerateKey("path/to/save.pem")
privateB64, publicB64, err := keys.GenerateKeyPair()

// Rotating signer for key rotation (file-based or any Signer)
rotating := keys.NewRotatingSigner(currentSigner)

// Rotating KMS signer for key rotation (KMS-backed)
rotatingKMS, err := keys.NewRotatingKMSSigner(ctx, "projects/.../cryptoKeyVersions/1")

Storage

// In-memory (for testing)
store := storage.NewMemory()

// SQLite
store, err := storage.NewSQLite("subscriptions.db")

// Operations
store.Save(ctx, record)
store.Get(ctx, id)
store.GetByEndpoint(ctx, endpoint)
store.GetByUserID(ctx, userID)
store.GetByVAPIDKey(ctx, vapidKeyB64)   // Get subscriptions for a specific VAPID key
store.CountByVAPIDKey(ctx, vapidKeyB64) // Count subscriptions for a specific VAPID key
store.List(ctx, limit, offset)
store.Delete(ctx, id)
store.DeleteByEndpoint(ctx, endpoint)

Key Rotation

VAPID keys should be rotated periodically for security. When rotating keys, existing browser subscriptions become invalid because they are tied to the VAPID public key (applicationServerKey). The RotatingSigner helps manage this transition.

How Key Rotation Works

  1. Browser subscriptions are created using a specific VAPID public key
  2. When the VAPID key changes, push services reject notifications with the old key
  3. Clients must re-subscribe with the new applicationServerKey

Using RotatingSigner

// Create a rotating signer with the current key
currentKey, _ := keys.NewFileSigner("current-key.pem")
rotating := keys.NewRotatingSigner(currentKey)

// Create web push client
client := webpush.NewClient(rotating, "mailto:admin@example.com")

// When storing new subscriptions, track which key was used
record := &storage.Record{
    ID:           uuid.New().String(),
    Subscription: sub,
    VAPIDKey:     rotating.PublicKeyBase64(),  // Track the key used
}
store.Save(ctx, record)

// When it's time to rotate (e.g., annually):
newKey, _ := keys.GenerateKey("new-key.pem")
rotating.Rotate(newKey)

// Find subscriptions that need re-subscription
oldKeyB64 := rotating.PreviousKeysBase64()[0]
oldSubscriptions, _ := store.GetByVAPIDKey(ctx, oldKeyB64)
// Notify users to re-subscribe, or delete old subscriptions

// Send notifications - each subscription uses the correct key
records, _ := store.List(ctx, 100, 0)
for _, record := range records {
    if rotating.IsCurrentKeyBase64(record.VAPIDKey) {
        // Can send with current client
        client.Send(ctx, record.Subscription, payload, nil)
    } else {
        // Need to use the old key's signer
        oldSigner := rotating.GetSignerForKeyBase64(record.VAPIDKey)
        if oldSigner != nil {
            oldClient := webpush.NewClient(oldSigner, "mailto:admin@example.com")
            oldClient.Send(ctx, record.Subscription, payload, nil)
        }
    }
}

// After all clients have re-subscribed, remove old keys
rotating.ClearPreviousKeys()

// Or automatically remove only keys with no subscriptions
result, _ := rotating.RemoveUnusedKeys(ctx, store)
fmt.Printf("Removed %d keys, retained %d keys\n", len(result.RemovedKeys), len(result.RetainedKeys))

Using RotatingKMSSigner (Google Cloud KMS)

// Create a rotating KMS signer with a shared client
rotatingKMS, _ := keys.NewRotatingKMSSigner(ctx, "projects/my-project/locations/global/keyRings/webpush/cryptoKeys/vapid/cryptoKeyVersions/1")
defer rotatingKMS.Close()

// Create web push client
client := webpush.NewClient(rotatingKMS, "mailto:admin@example.com")

// When rotating, add a new KMS key version
rotatingKMS.Rotate(ctx, "projects/my-project/locations/global/keyRings/webpush/cryptoKeys/vapid/cryptoKeyVersions/2")

// Or add previous keys that have existing subscriptions
rotatingKMS.AddPreviousKey(ctx, "projects/.../cryptoKeyVersions/1")

// All the same operations work as RotatingSigner
result, _ := rotatingKMS.RemoveUnusedKeys(ctx, store)

RotatingSigner API

// Create and manage
rotating := keys.NewRotatingSigner(currentKey)
rotating.Rotate(newKey)                    // Add new key, move current to previous
rotating.RemoveOldestKey()                 // Remove the oldest previous key
rotating.RemoveKey(pubKey)                 // Remove a specific key by public key
rotating.RemoveKeyBase64(b64Key)           // Remove a specific key by base64 public key
rotating.ClearPreviousKeys()               // Remove all previous keys
rotating.RemoveUnusedKeys(ctx, store)      // Remove keys with no subscriptions

// Query keys
rotating.PublicKey()                       // Current key bytes
rotating.PublicKeyBase64()                 // Current key as base64
rotating.PreviousKeys()                    // Previous key bytes
rotating.PreviousKeysBase64()              // Previous keys as base64
rotating.AllKeys()                         // All keys (current first)
rotating.AllKeysBase64()                   // All keys as base64
rotating.KeyCount()                        // Total number of keys

// Check keys
rotating.IsCurrentKey(pubKey)              // Check if pubKey is current
rotating.IsCurrentKeyBase64(b64Key)        // Check if base64 key is current  
rotating.IsKnownKey(pubKey)                // Check if pubKey is any known key
rotating.IsKnownKeyBase64(b64Key)          // Check if base64 key is known

// Get signer for specific key (for sending to old subscriptions)
signer := rotating.GetSignerForKey(pubKey)
signer := rotating.GetSignerForKeyBase64(b64Key)

RotatingKMSSigner API (additional methods)

// Create
rotatingKMS, err := keys.NewRotatingKMSSigner(ctx, keyVersionName)
defer rotatingKMS.Close()

// KMS-specific operations
rotatingKMS.Rotate(ctx, newKeyVersionName)           // Rotate to new KMS key version
rotatingKMS.AddPreviousKey(ctx, oldKeyVersionName)   // Add existing key to previous list
rotatingKMS.SignWithKey(ctx, pubKey, data)           // Sign with specific key
rotatingKMS.SignWithKeyBase64(ctx, b64Key, data)     // Sign with specific key (base64)

Custom Implementations

Custom Storage

Implement the storage.Storage interface:

type Storage interface {
    Save(ctx context.Context, record *Record) error
    Get(ctx context.Context, id string) (*Record, error)
    GetByEndpoint(ctx context.Context, endpoint string) (*Record, error)
    GetByUserID(ctx context.Context, userID string) ([]*Record, error)
    GetByVAPIDKey(ctx context.Context, vapidKey string) ([]*Record, error)
    CountByVAPIDKey(ctx context.Context, vapidKey string) (int, error)
    Delete(ctx context.Context, id string) error
    DeleteByEndpoint(ctx context.Context, endpoint string) error
    List(ctx context.Context, limit, offset int) ([]*Record, error)
    Close() error
}

The Record struct includes a VAPIDKey field to track which VAPID key was used when the subscription was created:

type Record struct {
    ID           string
    UserID       string
    Subscription *webpush.Subscription
    CreatedAt    time.Time
    UpdatedAt    time.Time
    VAPIDKey     string  // Base64-encoded VAPID public key used for this subscription
}

Custom Key Provider

Implement the webpush.Signer interface:

type Signer interface {
    Sign(ctx context.Context, data []byte) ([]byte, error)
    PublicKey() []byte
}

The Sign method receives a SHA-256 hash and should return an IEEE P1363 format signature (64 bytes for P-256).

License

Apache 2.0 - See LICENSE for details.