| 1 | package aviso |
| 2 | |
| 3 | import ( |
| 4 | "crypto/ecdh" |
| 5 | "crypto/rand" |
| 6 | "crypto/sha256" |
| 7 | "encoding/base64" |
| 8 | "errors" |
| 9 | "strings" |
| 10 | ) |
| 11 | |
| 12 | // ErrEmptyPrivateKey means Config.PrivateKey was empty. It is refused |
| 13 | // rather than minted: a key generated at boot into local state is a |
| 14 | // key lost at the next restore, and every browser then holds a |
| 15 | // subscription nobody can sign for. |
| 16 | var ErrEmptyPrivateKey = errors.New("aviso: Config.PrivateKey must not be empty; mint one with `go run amadan.net/rastrillo/aviso/cmd/aviso-key`") |
| 17 | |
| 18 | // ErrInvalidPrivateKey means Config.PrivateKey is not an unpadded |
| 19 | // base64url 32-byte P-256 scalar in range. |
| 20 | var ErrInvalidPrivateKey = errors.New("aviso: Config.PrivateKey is not an unpadded base64url 32-byte P-256 scalar") |
| 21 | |
| 22 | // GenerateKey mints a VAPID private key in Config.PrivateKey's format. |
| 23 | // The public half is derived, never stored: one secret to provision. |
| 24 | func GenerateKey() (string, error) { |
| 25 | k, err := ecdh.P256().GenerateKey(rand.Reader) |
| 26 | if err != nil { |
| 27 | return "", err |
| 28 | } |
| 29 | return base64.RawURLEncoding.EncodeToString(k.Bytes()), nil |
| 30 | } |
| 31 | |
| 32 | // parsePrivateKey validates the scalar and derives the two things the |
| 33 | // rest of the package needs from it: the uncompressed public point |
| 34 | // (applicationServerKey on the browser side, the VAPID public key on |
| 35 | // the wire) and a key id — SHA-256 of that point — stored on every |
| 36 | // row so a rotated key is visible per subscription. |
| 37 | func parsePrivateKey(s string) (pub, keyID string, err error) { |
| 38 | if s == "" { |
| 39 | return "", "", ErrEmptyPrivateKey |
| 40 | } |
| 41 | // Padding is refused, not tolerated: two encodings of one key |
| 42 | // would be two strings an operator could paste, and only one of |
| 43 | // them is what aviso-key printed. |
| 44 | if strings.ContainsAny(s, "=+/") { |
| 45 | return "", "", ErrInvalidPrivateKey |
| 46 | } |
| 47 | raw, err := base64.RawURLEncoding.DecodeString(s) |
| 48 | if err != nil || len(raw) != 32 { |
| 49 | return "", "", ErrInvalidPrivateKey |
| 50 | } |
| 51 | // The decoder tolerates newlines and non-zero trailing bits; only |
| 52 | // the spelling that round-trips is the one aviso-key printed. |
| 53 | if base64.RawURLEncoding.EncodeToString(raw) != s { |
| 54 | return "", "", ErrInvalidPrivateKey |
| 55 | } |
| 56 | // NewPrivateKey rejects zero and out-of-range scalars. |
| 57 | k, err := ecdh.P256().NewPrivateKey(raw) |
| 58 | if err != nil { |
| 59 | return "", "", ErrInvalidPrivateKey |
| 60 | } |
| 61 | point := k.PublicKey().Bytes() |
| 62 | sum := sha256.Sum256(point) |
| 63 | return base64.RawURLEncoding.EncodeToString(point), |
| 64 | base64.RawURLEncoding.EncodeToString(sum[:]), nil |
| 65 | } |
| 66 | |