Add VAPID config and the push subscription API
CI / ci (push) Successful in 6m3s
CI / ci (pull_request) Successful in 5m17s

Require MEALQUEST_VAPID_PUBLIC_KEY, MEALQUEST_VAPID_PRIVATE_KEY_FILE and
MEALQUEST_VAPID_SUBJECT at boot. Both keys are unpadded base64url P-256
keys, the encoding webpush-go reads; boot derives the public point from the
private key and refuses a pair that does not match. The subject must be
mailto:<address> or an https:// URL.

internal/push stores subscriptions by endpoint: GET
/api/v1/push/vapid-public-key serves only the public key, POST
/api/v1/push/subscriptions upserts {endpoint, keys.p256dh, keys.auth,
user_agent}, and DELETE removes by endpoint, answering 204 whether or not
it was stored. cmd/vapidgen prints a new key pair. deploy/compose.yaml
mounts the vapid_private_key secret.

Maestro task e8a7b1f4-327a-478b-88c7-5d265afda153.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-09 08:03:59 -04:00
co-authored by Claude Opus 5.5
parent cb451baed4
commit f1e7503f94
30 changed files with 1526 additions and 36 deletions
+87
View File
@@ -0,0 +1,87 @@
// Package vapid generates and validates the VAPID key pair Web Push signs
// with (RFC 8292): a P-256 (ES256) key pair in the encoding webpush-go
// reads. Both keys are unpadded base64url: the public key is the 65-byte
// uncompressed point and the private key the 32-byte scalar.
package vapid
import (
"crypto/ecdh"
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
)
// encoding is unpadded base64url. Strict refuses non-zero trailing bits, so
// every key has exactly one accepted spelling.
var encoding = base64.RawURLEncoding.Strict()
const (
publicKeySize = 65 // 0x04 || X || Y
privateKeySize = 32
)
// KeyPair is a VAPID key pair in its stored encoding.
type KeyPair struct {
PublicKey string
PrivateKey string
}
// Generate returns a new random key pair.
func Generate() (KeyPair, error) {
k, err := ecdh.P256().GenerateKey(rand.Reader)
if err != nil {
return KeyPair{}, fmt.Errorf("vapid: generate key: %w", err)
}
return KeyPair{
PublicKey: encoding.EncodeToString(k.PublicKey().Bytes()),
PrivateKey: encoding.EncodeToString(k.Bytes()),
}, nil
}
// ParsePublicKey parses an encoded public key, which must be a point on
// P-256.
func ParsePublicKey(s string) (*ecdh.PublicKey, error) {
b, err := encoding.DecodeString(s)
if err != nil {
return nil, errors.New("not unpadded base64url")
}
if len(b) != publicKeySize || b[0] != 0x04 {
return nil, fmt.Errorf("want the %d-byte uncompressed P-256 point, got %d bytes", publicKeySize, len(b))
}
k, err := ecdh.P256().NewPublicKey(b)
if err != nil {
return nil, errors.New("not a point on P-256")
}
return k, nil
}
// ParsePrivateKey parses an encoded private key. Its errors never quote the
// key.
func ParsePrivateKey(s string) (*ecdh.PrivateKey, error) {
b, err := encoding.DecodeString(s)
if err != nil {
return nil, errors.New("not unpadded base64url")
}
if len(b) != privateKeySize {
return nil, fmt.Errorf("want a %d-byte P-256 scalar, got %d bytes", privateKeySize, len(b))
}
k, err := ecdh.P256().NewPrivateKey(b)
if err != nil {
return nil, errors.New("not a valid P-256 scalar")
}
return k, nil
}
// ErrMismatch is returned by CheckPair when the private key's public key is
// not the given public key.
var ErrMismatch = errors.New("private key does not belong to the public key")
// CheckPair reports whether private is the private key of public, by
// deriving the public point from private and comparing.
func CheckPair(public *ecdh.PublicKey, private *ecdh.PrivateKey) error {
if !private.PublicKey().Equal(public) {
return ErrMismatch
}
return nil
}
+92
View File
@@ -0,0 +1,92 @@
package vapid
import (
"encoding/base64"
"strings"
"testing"
)
func TestGenerateRoundTrips(t *testing.T) {
t.Parallel()
a, err := Generate()
if err != nil {
t.Fatal(err)
}
b, err := Generate()
if err != nil {
t.Fatal(err)
}
if a == b {
t.Fatal("two generated key pairs are equal")
}
// 65 and 32 bytes in unpadded base64url.
if len(a.PublicKey) != 87 || len(a.PrivateKey) != 43 {
t.Errorf("key lengths = %d, %d; want 87, 43", len(a.PublicKey), len(a.PrivateKey))
}
pub, err := ParsePublicKey(a.PublicKey)
if err != nil {
t.Fatal(err)
}
priv, err := ParsePrivateKey(a.PrivateKey)
if err != nil {
t.Fatal(err)
}
if err := CheckPair(pub, priv); err != nil {
t.Errorf("CheckPair of a generated pair: %v", err)
}
other, err := ParsePrivateKey(b.PrivateKey)
if err != nil {
t.Fatal(err)
}
if err := CheckPair(pub, other); err != ErrMismatch {
t.Errorf("CheckPair of mismatched keys = %v, want ErrMismatch", err)
}
}
func TestParseRejectsMalformedKeys(t *testing.T) {
t.Parallel()
pair, err := Generate()
if err != nil {
t.Fatal(err)
}
raw := func(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) }
compressed, _ := base64.RawURLEncoding.DecodeString(pair.PublicKey)
compressed = append([]byte{0x02}, compressed[1:33]...)
offCurve := make([]byte, 65)
offCurve[0] = 0x04
offCurve[64] = 1
order := []byte{ // the P-256 group order n, not a valid scalar
0xff, 0xff, 0xff, 0xff, 0x00, 0x00, 0x00, 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xbc, 0xe6, 0xfa, 0xad, 0xa7, 0x17, 0x9e, 0x84, 0xf3, 0xb9, 0xca, 0xc2, 0xfc, 0x63, 0x25, 0x51,
}
for name, s := range map[string]string{
"empty": "",
"padded": pair.PublicKey + "=",
"standard alpha": strings.NewReplacer("-", "+", "_", "/").Replace(pair.PublicKey) + "+/",
"compressed": raw(compressed),
"off curve": raw(offCurve),
"private key": pair.PrivateKey,
} {
if _, err := ParsePublicKey(s); err == nil {
t.Errorf("ParsePublicKey(%s) succeeded", name)
}
}
for name, s := range map[string]string{
"empty": "",
"padded": pair.PrivateKey + "=",
"PEM": "-----BEGIN EC PRIVATE KEY-----",
"zero": raw(make([]byte, 32)),
"group size": raw(order),
"public key": pair.PublicKey,
} {
_, err := ParsePrivateKey(s)
if err == nil {
t.Errorf("ParsePrivateKey(%s) succeeded", name)
continue
}
if s != "" && strings.Contains(err.Error(), s) {
t.Errorf("ParsePrivateKey(%s) error quotes the key: %v", name, err)
}
}
}