Add VAPID key parsing and generation
The public key is derived from the scalar rather than stored beside it, so an app provisions exactly one secret; the key id is on every row so a rotated key shows up per subscription instead of as a silent signing failure. Padded input is refused outright: two spellings of one key would be two things an operator could paste, and only one is what aviso-key printed. Also records Codex's eleven plan-review findings as amendments at the top of the plan; each is applied in the task it names.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
3 files changed,
+186
−0
docs/superpowers/plans/2026-09-07-aviso.md+55 −0vapid.go+60 −0vapid_test.go+71 −0
diff --git a/docs/superpowers/plans/2026-09-07-aviso.md b/docs/superpowers/plans/2026-09-07-aviso.md| index da6c6bf..32d7e56 100644 |
| --- a/docs/superpowers/plans/2026-09-07-aviso.md |
| +++ b/docs/superpowers/plans/2026-09-07-aviso.md |
| @@ -36,6 +36,61 @@ Address every finding or record in the branch discussion why not. A task is done |
| --- |
| +## Review amendments (Codex, 2026-09-07, before Task 2) |
| + |
| +Codex reviewed this plan against the spec, rastrillo's source and |
| +webpush-go v1.4.0. Eleven findings; every one is applied to the task |
| +it names, and the task text below is superseded where they conflict. |
| + |
| +1. **Task 6, Send.** The sketch raced (`countSends` read results the |
| + goroutines were writing) and leaked goroutines on cancellation. |
| + Send now decides up front which rows it will attempt, uses a |
| + `sync.WaitGroup`, marks rows it never started with `ctx.Err()` when |
| + cancelled while waiting for a slot, always waits for started |
| + goroutines, and returns `ctx.Err()` as the batch error whenever the |
| + context was cancelled at any point. |
| +2. **Task 5, guard.** `guardedIP` admitted `0.0.0.0/8`, `192.0.0.0/24`, |
| + `192.0.2.0/24`, `198.18.0.0/15`, `198.51.100.0/24`, `203.0.113.0/24`, |
| + `240.0.0.0/4`, `2001:db8::/32`, `64:ff9b::/96`. It now carries an |
| + explicit reserved-CIDR list on top of the net.IP predicates, and the |
| + test corpus names each range. |
| +3. **Task 6.** `sendOne` calls `validateEndpoint` before sending: a |
| + caller can hand `Send` a `Stored` it edited. |
| +4. **Task 6.** webpush-go prefixes `mailto:` to any subscriber not |
| + starting with `https:` (`vapid.go:74`). `New` keeps `Contact` as |
| + given for validation and stores the mailto-stripped form for the |
| + wire; the header test decodes the JWT and checks `sub`. |
| +5. **Task 4/6 tests.** `sub()` generates a real P-256 point for |
| + `P256dh` and 16 random bytes for `Auth`; webpush-go decodes both |
| + before any HTTP happens. |
| +6. **Task 7/9/11.** Browser `PushSubscription.toJSON()` carries |
| + `expirationTime`. The handler no longer uses `DisallowUnknownFields` |
| + (the byte cap and field validation are the defence), and every |
| + client path projects to `{endpoint, keys}` anyway. |
| +7. **Task 8 test.** `KEY` is a real 65-byte point, base64url'd in the |
| + test from bytes, so `sameKey` round-trips. |
| +8. **Task 6, TTL.** webpush-go always sends the `TTL` header and `0` |
| + means "deliver now or drop", not "service default". Ruling: |
| + `Options.TTL == 0` means 24 hours. Recorded as a spec deviation in |
| + the branch discussion. |
| +9. **Task 8/9, callbacks.** `save`/`remove` may resolve to nothing (a |
| + callback that validated its own response); only an explicit |
| + `{ok: false}`-shaped return, or a rejection, counts as failure. |
| +10. **Task 9/11/12, worker key.** A worker forgets a module-level |
| + variable when it is terminated. `handleSubscriptionChange` takes |
| + `{publicKey, save}` where `publicKey()` fetches the key on demand; |
| + the helper performs the renewal subscribe itself (from |
| + `event.newSubscription`, else `pushManager.subscribe` with the |
| + fetched key) and builds the full subscribe body, so the app's |
| + `save` only posts. |
| +11. **Task 13/1, CI.** A `browser` make target |
| + (`go test -tags browser -count=1 -run Browser .`) joins `ci` and |
| + gets `.amadan/ci.d/50-browser`; it fails, not skips, without |
| + Chromium — rastrillo's own rule (`RASTRILLO_BROWSER_OPTIONAL` |
| + unset). |
| + |
| +--- |
| + |
| ## File structure |
| | File | Responsibility | |
diff --git a/vapid.go b/vapid.go| new file mode 100644 |
| index 0000000..9326733 |
| --- /dev/null |
| +++ b/vapid.go |
| @@ -0,0 +1,60 @@ |
| +package aviso |
| + |
| +import ( |
| + "crypto/ecdh" |
| + "crypto/rand" |
| + "crypto/sha256" |
| + "encoding/base64" |
| + "errors" |
| + "strings" |
| +) |
| + |
| +// ErrEmptyPrivateKey means Config.PrivateKey was empty. It is refused |
| +// rather than minted: a key generated at boot into local state is a |
| +// key lost at the next restore, and every browser then holds a |
| +// subscription nobody can sign for. |
| +var ErrEmptyPrivateKey = errors.New("aviso: Config.PrivateKey must not be empty; mint one with `go run amadan.net/rastrillo/aviso/cmd/aviso-key`") |
| + |
| +// ErrInvalidPrivateKey means Config.PrivateKey is not an unpadded |
| +// base64url 32-byte P-256 scalar in range. |
| +var ErrInvalidPrivateKey = errors.New("aviso: Config.PrivateKey is not an unpadded base64url 32-byte P-256 scalar") |
| + |
| +// GenerateKey mints a VAPID private key in Config.PrivateKey's format. |
| +// The public half is derived, never stored: one secret to provision. |
| +func GenerateKey() (string, error) { |
| + k, err := ecdh.P256().GenerateKey(rand.Reader) |
| + if err != nil { |
| + return "", err |
| + } |
| + return base64.RawURLEncoding.EncodeToString(k.Bytes()), nil |
| +} |
| + |
| +// parsePrivateKey validates the scalar and derives the two things the |
| +// rest of the package needs from it: the uncompressed public point |
| +// (applicationServerKey on the browser side, the VAPID public key on |
| +// the wire) and a key id — SHA-256 of that point — stored on every |
| +// row so a rotated key is visible per subscription. |
| +func parsePrivateKey(s string) (pub, keyID string, err error) { |
| + if s == "" { |
| + return "", "", ErrEmptyPrivateKey |
| + } |
| + // Padding is refused, not tolerated: two encodings of one key |
| + // would be two strings an operator could paste, and only one of |
| + // them is what aviso-key printed. |
| + if strings.ContainsAny(s, "=+/") { |
| + return "", "", ErrInvalidPrivateKey |
| + } |
| + raw, err := base64.RawURLEncoding.DecodeString(s) |
| + if err != nil || len(raw) != 32 { |
| + return "", "", ErrInvalidPrivateKey |
| + } |
| + // NewPrivateKey rejects zero and out-of-range scalars. |
| + k, err := ecdh.P256().NewPrivateKey(raw) |
| + if err != nil { |
| + return "", "", ErrInvalidPrivateKey |
| + } |
| + point := k.PublicKey().Bytes() |
| + sum := sha256.Sum256(point) |
| + return base64.RawURLEncoding.EncodeToString(point), |
| + base64.RawURLEncoding.EncodeToString(sum[:]), nil |
| +} |
diff --git a/vapid_test.go b/vapid_test.go| new file mode 100644 |
| index 0000000..59af685 |
| --- /dev/null |
| +++ b/vapid_test.go |
| @@ -0,0 +1,71 @@ |
| +package aviso |
| + |
| +import ( |
| + "crypto/ecdh" |
| + "crypto/rand" |
| + "encoding/base64" |
| + "errors" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +func TestGenerateKeyRoundTrips(t *testing.T) { |
| + priv, err := GenerateKey() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + raw, err := base64.RawURLEncoding.DecodeString(priv) |
| + if err != nil || len(raw) != 32 { |
| + t.Fatalf("private key = %q: want 32 unpadded base64url bytes (err %v)", priv, err) |
| + } |
| + pub, id, err := parsePrivateKey(priv) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + pubRaw, err := base64.RawURLEncoding.DecodeString(pub) |
| + if err != nil || len(pubRaw) != 65 || pubRaw[0] != 0x04 { |
| + t.Fatalf("public key = %q: want 65-byte uncompressed point", pub) |
| + } |
| + if id == "" || strings.ContainsAny(id, "+/=") { |
| + t.Fatalf("key id = %q: want unpadded base64url", id) |
| + } |
| + // The same key must yield the same id after a restart — Sweep and |
| + // Send match rows on it. |
| + _, id2, _ := parsePrivateKey(priv) |
| + if id != id2 { |
| + t.Fatal("key id not deterministic") |
| + } |
| +} |
| + |
| +func TestParsePrivateKeyRefusesBadInput(t *testing.T) { |
| + if _, _, err := parsePrivateKey(""); !errors.Is(err, ErrEmptyPrivateKey) { |
| + t.Fatalf("empty: got %v, want ErrEmptyPrivateKey", err) |
| + } |
| + zero := base64.RawURLEncoding.EncodeToString(make([]byte, 32)) |
| + for name, in := range map[string]string{ |
| + "not base64": "!!!", |
| + "short": base64.RawURLEncoding.EncodeToString([]byte("short")), |
| + "zero scalar": zero, |
| + "padded": zero + "=", |
| + } { |
| + if _, _, err := parsePrivateKey(in); !errors.Is(err, ErrInvalidPrivateKey) { |
| + t.Errorf("%s: got %v, want ErrInvalidPrivateKey", name, err) |
| + } |
| + } |
| +} |
| + |
| +func TestParsePrivateKeyAgreesWithECDH(t *testing.T) { |
| + k, err := ecdh.P256().GenerateKey(rand.Reader) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + priv := base64.RawURLEncoding.EncodeToString(k.Bytes()) |
| + pub, _, err := parsePrivateKey(priv) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + want := base64.RawURLEncoding.EncodeToString(k.PublicKey().Bytes()) |
| + if pub != want { |
| + t.Fatalf("public key = %s, want %s", pub, want) |
| + } |
| +} |