vapid

Crystal Vapid Client

VAPID - Crystal Web Push Library

Crystal CI License

A Crystal shard for VAPID (Voluntary Application Server Identification) protocol implementation. Generate ES256 keypairs, create JWT tokens, and send authenticated Web Push notifications following RFC 8292.

Features

  • ES256 Cryptographic Keys: Generate and manage ECDSA P-256 key pairs for VAPID authentication
  • JWT Token Creation: Automatic JWT token generation and signing with ES256 algorithm
  • VAPID Header Generation: RFC 8292-compliant Authorization and Crypto-Key headers
  • Web Push Client: Send push notifications to RFC 8030 compliant push services (FCM, Mozilla AutoPush, etc.)
  • Type Safety: Full Crystal type system support with compile-time type checking
  • Comprehensive Error Handling: Specific error types for different failure scenarios
  • Extensible Design: Clean, modular architecture for easy customization

Installation

Add this to your application's shard.yml:

dependencies:
  vapid:
    github: russ/vapid-crystal
    version: ~> 0.1.0

Then run:

shards install

Quick Start

require "vapid"

# Generate a new ES256 key pair
key_pair = Vapid::KeyPair.generate

# Save your private key securely for reuse
File.write("private_key.pem", key_pair.private_key_pem)

# Send a push notification
client = Vapid::Client.new

response = client.send_notification(
  endpoint: "https://fcm.googleapis.com/fcm/send/subscription-id",
  subject: "mailto:admin@example.com",
  private_key: key_pair,
  payload: {
    "title" => "Hello from VAPID!",
    "body" => "Your first push notification"
  }.to_json,
  ttl: 3600
)

puts "Notification sent! Status: #{response.status_code}"

Usage

Key Pair Generation

# Generate a new P-256 (prime256v1/secp256r1) key pair
key_pair = Vapid::KeyPair.generate

# Export public key in URL-safe Base64 format (for browser subscription)
public_key = key_pair.public_key_base64
# => "BKj3vCS4mC3pP6..."

# Export private key in PEM format (for secure storage)
private_key_pem = key_pair.private_key_pem
File.write("vapid_private.pem", private_key_pem)

# Load existing key from PEM file
existing_key = Vapid::KeyPair.from_pem(File.read("vapid_private.pem"))

JWT Token Creation

key_pair = Vapid::KeyPair.generate

# Create a JWT token with required VAPID claims
token = Vapid::JWT.encode(
  audience: "https://fcm.googleapis.com",      # Push service origin
  subject: "mailto:admin@example.com",         # Your contact information
  private_key: key_pair,
  expiration: 12.hours                         # Token validity (max 24 hours)
)

# The token includes:
# - aud: Audience (push service origin)
# - exp: Expiration time
# - sub: Subject (contact info)

VAPID Headers

key_pair = Vapid::KeyPair.generate

# Create VAPID authentication headers
headers = Vapid::RequestHeaders.new(
  audience: "https://fcm.googleapis.com",
  subject: "mailto:admin@example.com",
  private_key: key_pair,
  expiration: 12.hours  # Optional, defaults to 12 hours
)

# Get headers as a Hash
http_headers = headers.to_h
# => {
#   "Authorization" => "vapid t=eyJ0eXAi..., k=BKj3vCS4mC3pP6...",
#   "Crypto-Key" => "p256ecdsa=BKj3vCS4mC3pP6..."
# }

# Or as HTTP::Headers object
http_headers = headers.to_http_headers

Sending Push Notifications

client = Vapid::Client.new(timeout: 30.seconds)
key_pair = Vapid::KeyPair.from_pem(File.read("private_key.pem"))

# Send notification with payload
response = client.send_notification(
  endpoint: "https://fcm.googleapis.com/fcm/send/abc123...",
  subject: "mailto:admin@example.com",
  private_key: key_pair,
  payload: {
    "notification" => {
      "title" => "New Message",
      "body" => "You have a new message!",
      "icon" => "/icon.png"
    }
  }.to_json,
  ttl: 3600,              # Time to live in seconds
  urgency: "high",        # very-low, low, normal, or high
  topic: "updates"        # Optional topic for message replacement
)

# Send trigger notification (no payload)
response = client.send_notification(
  endpoint: subscription_endpoint,
  subject: "mailto:admin@example.com",
  private_key: key_pair,
  ttl: 86400
)

# Custom audience (usually auto-extracted from endpoint)
response = client.send_notification(
  endpoint: subscription_endpoint,
  audience: "https://custom.push.service",
  subject: "mailto:admin@example.com",
  private_key: key_pair
)

Error Handling

begin
  client.send_notification(
    endpoint: endpoint,
    subject: "mailto:admin@example.com",
    private_key: key_pair
  )
rescue ex : Vapid::PushError
  puts "Push failed: #{ex.message}"
  puts "Status code: #{ex.status_code}"
  puts "Response: #{ex.response_body}"
rescue ex : Vapid::ValidationError
  puts "Invalid parameters: #{ex.message}"
rescue ex : Vapid::JWTError
  puts "JWT error: #{ex.message}"
rescue ex : Vapid::CryptoError
  puts "Cryptography error: #{ex.message}"
end

API Documentation

Vapid::KeyPair

Manages ES256 (ECDSA P-256) cryptographic key pairs.

Class Methods:

  • .generate : KeyPair - Generate a new P-256 key pair
  • .from_pem(pem : String) : KeyPair - Load from PEM-encoded private key
  • .from_der(der : Bytes) : KeyPair - Load from DER-encoded private key

Instance Methods:

  • #public_key_bytes : Bytes - Get raw public key (65 bytes, uncompressed)
  • #public_key_base64 : String - Get public key as URL-safe base64
  • #private_key_pem : String - Export private key as PEM
  • #private_key_der : Bytes - Export private key as DER
  • #to_openssl : OpenSSL::PKey::EC - Get underlying OpenSSL key

Vapid::JWT

Creates and verifies JWT tokens for VAPID authentication.

Module Methods:

  • .encode(audience, subject, private_key, expiration = 12.hours, extra_claims = nil) : String

    • Create and sign a VAPID JWT token
    • audience: Push service origin (e.g., "https://fcm.googleapis.com")
    • subject: Contact info as mailto: or https: URL
    • private_key: KeyPair for signing
    • expiration: Token validity period (max 24 hours)
    • extra_claims: Optional additional JWT claims
  • .decode(token : String, public_key : KeyPair) : JSON::Any

    • Decode and verify a JWT token (for testing)

Vapid::RequestHeaders

Generates VAPID authentication headers for push requests.

Constructor:

RequestHeaders.new(
  audience : String,
  subject : String,
  private_key : KeyPair,
  expiration : Time::Span = 12.hours
)

Instance Methods:

  • #authorization_header : String - Get Authorization header value
  • #crypto_key_header : String - Get Crypto-Key header value
  • #to_h : Hash(String, String) - Get headers as Hash
  • #to_http_headers : HTTP::Headers - Get headers as HTTP::Headers

Vapid::Client

HTTP client for sending VAPID-authenticated push notifications.

Constructor:

Client.new(timeout : Time::Span = 30.seconds)

Instance Methods:

  • #send_notification - Send a push notification

    • endpoint: Push subscription endpoint URL
    • audience: Push service origin (auto-extracted if not provided)
    • subject: Contact information
    • private_key: KeyPair for authentication
    • payload: Notification payload (JSON string or nil)
    • ttl: Time to live in seconds (default: 28 days)
    • urgency: Urgency level: very-low, low, normal, high (default: normal)
    • topic: Topic for message replacement (optional)
    • headers: Pre-built RequestHeaders (alternative to private_key/subject)
  • #extract_audience(endpoint : String) : String - Extract origin from endpoint URL

Error Classes

  • Vapid::Error - Base error class
  • Vapid::CryptoError - Cryptographic operation failures
  • Vapid::JWTError - JWT encoding/decoding failures
  • Vapid::PushError - HTTP push request failures (includes status_code and response_body)
  • Vapid::ValidationError - Invalid parameter errors

VAPID Specification Compliance

This library implements RFC 8292 - Voluntary Application Server Identification (VAPID) for Web Push.

Key Requirements:

  • ✅ ES256 (ECDSA P-256 + SHA-256) signatures
  • ✅ JWT tokens with required claims (aud, exp, sub)
  • ✅ Maximum 24-hour token expiration
  • ✅ URL-safe base64 encoding without padding
  • ✅ RFC 8292 compliant Authorization header format
  • ✅ Backward-compatible Crypto-Key header

Supported Push Services:

  • Firebase Cloud Messaging (FCM)
  • Mozilla AutoPush
  • Any RFC 8030 compliant push service

Development

# Install dependencies
shards install

# Run tests
crystal spec

# Run specific test file
crystal spec spec/vapid/key_pair_spec.cr

# Format code
crystal tool format

# Build documentation
crystal docs

# Check for issues
crystal tool ameba  # If you have ameba installed

Testing

The project includes comprehensive specs testing:

  • ✅ ES256 key pair generation and serialization
  • ✅ JWT token creation and verification
  • ✅ VAPID header generation
  • ✅ Push notification sending (with WebMock)
  • ✅ Error handling and edge cases
  • ✅ RFC 8292 compliance

Run tests:

crystal spec

Security Considerations

  1. Private Key Storage: Always store private keys securely (encrypted, environment variables, secret managers)
  2. Token Expiration: Use short-lived tokens (recommended: 1-12 hours)
  3. HTTPS Only: Only use HTTPS endpoints for production
  4. Subject Validation: Ensure subject contains valid contact information
  5. Audience Matching: Verify audience matches the push service origin

Performance

  • Key Generation: ~10-50ms per key pair (one-time operation)
  • JWT Signing: <1ms per token
  • Push Request: Depends on network and push service latency

Known Limitations

  • Requires OpenSSL 1.1.1+ or 3.x
  • Current implementation uses Crystal's standard OpenSSL bindings
  • Some advanced OpenSSL features may require additional dependencies

Examples

Complete Push Notification Flow

require "vapid"

# 1. Generate and save key pair (one-time setup)
key_pair = Vapid::KeyPair.generate
File.write("vapid_private.pem", key_pair.private_key_pem)
puts "Application Server Public Key: #{key_pair.public_key_base64}"

# 2. User subscribes in browser with the public key
# (This happens in your JavaScript)

# 3. Send notification when needed
key_pair = Vapid::KeyPair.from_pem(File.read("vapid_private.pem"))
client = Vapid::Client.new

# Get subscription from your database
subscription_endpoint = get_user_subscription_endpoint(user_id)

begin
  response = client.send_notification(
    endpoint: subscription_endpoint,
    subject: "mailto:support@myapp.com",
    private_key: key_pair,
    payload: {
      "notification" => {
        "title" => "Welcome!",
        "body" => "Thanks for subscribing",
        "data" => {"url" => "/dashboard"}
      }
    }.to_json,
    ttl: 86400,
    urgency: "normal"
  )

  puts "✓ Notification sent successfully!"
rescue ex : Vapid::PushError
  case ex.status_code
  when 404, 410
    # Subscription expired or invalid - remove from database
    remove_subscription(user_id)
  when 401, 403
    # Authentication error - check your keys
    puts "Auth error: #{ex.message}"
  else
    puts "Push failed: #{ex.message}"
  end
end

Batch Notifications

client = Vapid::Client.new
key_pair = Vapid::KeyPair.from_pem(File.read("vapid_private.pem"))

subscriptions.each do |subscription|
  spawn do
    begin
      client.send_notification(
        endpoint: subscription.endpoint,
        subject: "mailto:admin@example.com",
        private_key: key_pair,
        payload: notification_payload,
        ttl: 3600
      )
    rescue ex
      log_error(subscription.id, ex)
    end
  end
end

# Wait for all fibers to complete
Fiber.yield

Resources

Contributing

  1. Fork it (https://github.com/your-github-user/vapid-crystal/fork)
  2. Create your feature branch (git checkout -b my-new-feature)
  3. Write tests for your changes
  4. Ensure all tests pass (crystal spec)
  5. Format your code (crystal tool format)
  6. Commit your changes (git commit -am 'Add some feature')
  7. Push to the branch (git push origin my-new-feature)
  8. Create a new Pull Request

License

MIT License - see LICENSE file for details.

Contributors

Technical Notes

Dependencies

This library requires openssl_ext version 2.8.2 or later, which includes a critical fix for EC_GROUP preservation when loading elliptic curve keys from PEM format. This ensures reliable operation across all platforms including Docker, Alpine Linux, and musl-based systems.

Platform Support

All Platforms Supported:

  • Linux (glibc and musl)
  • Docker containers (Ubuntu, Alpine)
  • macOS
  • OpenSSL 1.1.x and 3.x

Acknowledgments


Made with ❤️ using Crystal

Repository

vapid

Owner
Statistic
  • 0
  • 0
  • 0
  • 0
  • 3
  • 5 days ago
  • November 20, 2025
License

MIT License

Links
Synced at

Tue, 25 Nov 2025 15:30:53 GMT

Languages