Skip to content

Commit

Permalink
WIP
Browse files Browse the repository at this point in the history
  • Loading branch information
AlexGustafsson committed Feb 10, 2025
1 parent 76a8c96 commit 85a616f
Show file tree
Hide file tree
Showing 3 changed files with 189 additions and 0 deletions.
71 changes: 71 additions & 0 deletions internal/webpush/client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package webpush

import (
"context"
"crypto/ecdh"
"crypto/ecdsa"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"net/http"
"time"

"vendor/golang.org/x/crypto/hkdf"

"github.com/AlexGustafsson/cupdate/internal/webpush/vapid"
)

// Client provides methods of pushing a message to a Web Push service.
// SEE: Generic Event Delivery Using HTTP Push - https://datatracker.ietf.org/doc/html/rfc8030.
// SEE: VAPID - https://datatracker.ietf.org/doc/html/rfc8292#section-3.2.
// SEE: Message Encryption for Web Push - https://www.rfc-editor.org/rfc/rfc8291.html
type Client struct {
Endpoint string
AuthenticationSecret []byte
UserAgentPublicKey *ecdh.PublicKey
ApplicationServerPrivateKey *ecdsa.PrivateKey
}

type PushOptions struct {
TTL int64
ContentType string
Urgency string
Topic string
}

func (c *Client) Push(ctx context.Context, content []byte, options *PushOptions) error {
vapidToken, err := vapid.NewToken("https://cupdate.home.local", time.Now().Add(5*time.Minute), "https://cupdate.home.local", c.ApplicationServerPrivateKey)
if err != nil {
return err
}

privateKey, err := ecdh.P256().GenerateKey(rand.Reader)
if err != nil {
return err
}

// "WebPush: info" || 0x00 || ua_public || as_public
info.WriteString("Content-Encoding: aes128gcm")
info.WriteRune(0x00)
info.WriteRune(0x01)

hkdf := hkdf.New(sha256.New, sharedSecret, c.AuthenticationSecret, info.Bytes())

var ikm [32]byte
hkdf.Read(ikm[:])

var salt [16]byte
rand.Read(salt[:])

req, err := http.NewRequest(http.MethodPost, c.Endpoint, nil)
if err != nil {
return err
}

req.Header.Set("TTL", "30")
req.Header.Set("Content-Encoding", "aes128gcm")
req.Header.Set("Crypto-Key", "dh="+base64.RawURLEncoding.EncodeToString(privateKey.PublicKey().Bytes()))
req.Header.Set("Authorization", vapid.FormatAuthorizationHeader(vapidToken, c.ApplicationServerPrivateKey.PublicKey))

return nil
}
66 changes: 66 additions & 0 deletions internal/webpush/crypto.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package webpush

import (
"bytes"
"crypto/ecdh"
"crypto/hmac"
"crypto/sha256"
"vendor/golang.org/x/crypto/hkdf"
)

func DeriveInputKeyingMaterial(senderPrivateKey *ecdh.PrivateKey, recipientPublicKey *ecdh.PublicKey, authenticationSecret []byte) ([]byte, error) {
// SEE: https://www.rfc-editor.org/rfc/rfc8291.html#section-3.4
sharedSecret, err := senderPrivateKey.ECDH(recipientPublicKey)
if err != nil {
return nil, err
}
prkKey := hkdf.Extract(sha256.New, sharedSecret, authenticationSecret)

hkdf.New()

// "WebPush: info" || 0x00 || ua_public || as_public
var info bytes.Buffer
info.WriteString("WebPush: info")
info.WriteRune(0x00)
info.Write(recipientPublicKey.Bytes())
info.Write(senderPrivateKey.PublicKey().Bytes())
info.WriteRune(0x01)

return hmac.New(sha256.New, prkKey).Sum(info.Bytes()), nil
}

func DeriveContentEncryptionKey(ikm []byte, salt []byte) []byte {
prk := hkdf.Extract(sha256.New, ikm, salt)

// "Content-Encoding: aes128gcm" || 0x00 || 0x01
var info bytes.Buffer
info.WriteString("Content-Encoding: aes128gcm")
info.WriteRune(0x00)
info.WriteRune(0x01)

cek := hmac.New(sha256.New, prk).Sum(info.Bytes())

hkdf.Expand(sha256.New, prk)

hkdf := hkdf.New(sha256.New, ikm, salt, info.Bytes())

var cek [32]byte
hkdf.Read(cek[:])

return cek[:]
}

func DeriveNonce(prk []byte) {
// "Content-Encoding: nonce" || 0x00 || 0x01
var info bytes.Buffer
info.WriteString("Content-Encoding: nonce")
info.WriteRune(0x00)
info.WriteRune(0x01)

hkdf := hkdf.New(sha256.New, prk, salt, info.Bytes())

var cek [32]byte
hkdf.Read(cek[:])

return cek[:]
}
52 changes: 52 additions & 0 deletions internal/webpush/vapid/vapid.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package vapid

import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"time"
)

const AuthorizationScheme = "vapid"

func FormatAuthorizationHeader(token string, key ecdsa.PublicKey) string {
k := base64.RawURLEncoding.EncodeToString(elliptic.MarshalCompressed(elliptic.P256(), key.X, key.Y))
return fmt.Sprintf("vapid t=%s, k=%s", token, k)
}

// - expires MUST be less than 24 hours.
func NewToken(audience string, expires time.Time, subject string, key *ecdsa.PrivateKey) (string, error) {
header := map[string]any{
"typ": "JWT",
"alg": "ES256",
}
headerBytes, err := json.Marshal(header)
if err != nil {
return "", err
}

claims := map[string]any{
"aud": audience,
"exp": expires.Unix(),
"sub": subject,
}
claimsBytes, err := json.Marshal(claims)
if err != nil {
return "", err
}

jwt := base64.RawURLEncoding.EncodeToString(headerBytes) + "." + base64.RawStdEncoding.EncodeToString(claimsBytes)

hash := sha256.Sum256([]byte(jwt))

signature, err := ecdsa.SignASN1(rand.Reader, key, hash[:])
if err != nil {
return "", err
}

return jwt + "." + base64.RawStdEncoding.EncodeToString(signature), nil
}

0 comments on commit 85a616f

Please sign in to comment.