commit 4dac778ae364dda5be5e440b422768c905148519 Author: shinosaki Date: Thu Feb 27 13:00:06 2025 +0000 init diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..7fde2f6 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 shinosaki + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..5d55f14 --- /dev/null +++ b/README.md @@ -0,0 +1,59 @@ +# webpush-client-go + +`webpush-client-go`は、Golangで実装されたWebPushを受信するためのクライアントおよびライブラリです。 + +## Features +- RFC8188メッセージの暗号化・復号 + - `aes128gcm`のみのサポート +- RFC8291メッセージの暗号化・復号 +- AutoPush (Mozilla Push Service) のクライアント +- Application Serverごとのクライアント + - NicoPush: https://www.nicovideo.jp + +## Examples + +[examples](./examples)を参照してください。 + +- [examples/ece](./examples/ece/main.go): 単純なRFC8291メッセージの暗号化・復号 +- [examples/nicopush](./examples/nicopush/main.go): ニコニコのWebPush通知を受信します(AutoPush) + +### NicoPush +`NICONICO_USER_SESSION_VALUE`に`nicovideo.jp`の`user_session`クッキーの値を指定してください。 + +```bash +echo '{"user_session":"NICONICO_USER_SESSION_VALUE"}' > config.json + +go run ./examples/nicopush/main.go +``` + +## References +- RFCs + - [RFC8188](https://tools.ietf.org/html/rfc8188): HTTP用のコンテンツ暗号化の仕様 + - [Content-Encoding: aes128gcm とは (RFC8188) - ASnoKaze blog](https://asnokaze.hatenablog.com/entry/20170202/1486046514): RFC8188の要旨を日本語で解説している + - [RFC8291](https://tools.ietf.org/html/rfc8291): RFC8188を拡張した、プッシュ通知の暗号化の仕様 +- Encrypt Content-Encoding + - [web-push-libs/ecec](https://github.com/web-push-libs/ecec): RFC8188/RFC8291のC言語実装 + - [web-push-libs/encrypted-content-encoding](https://github.com/web-push-libs/encrypted-content-encoding): RFC8188/RFC8291のPython/Node.js実装(`http_ece`) + - [web-push-libs/web-push-php](https://github.com/web-push-libs/web-push-php): ウェブプッシュサーバのPHP実装 + - [web-push-php/src/Encryption.php](https://github.com/web-push-libs/web-push-php/blob/7b6d1e9d202c31dd9d53929ae33be3f704df7034/src/Encryption.php): 暗号化ペイロード構築部分 +- AutoPush + - [Design - Mozilla Push Service Documentation](https://mozilla-push-service.readthedocs.io/en/latest/design/): Firefoxで使用されるプッシュサーバ(Mozilla Push Service)のドキュメント + - [Architecture - Mozilla AutoPush Server](https://mozilla-services.github.io/autopush-rs/architecture.html): Mozilla Push ServiceのサーバであるAutoPushのドキュメント + - [Architecture - autopush documentation](https://autopush.readthedocs.io/en/latest/architecture.html): 廃止されたAutoPushのPython実装のドキュメント +- WebPush + - [Магия WebPush в Mozilla Firefox. Взгляд изнутри - Habr](https://habr.com/ru/articles/487494/): FirefoxのWebPush実装を解説している + - [MANKAのBlog](https://blog.nest.moe) + - [通过 Web Push 接收最新的推文](https://blog.nest.moe/posts/receive-latest-tweets-by-web-push): Twitterのプッシュ通知を例に、詳細を解説しているブログポスト + - [解密来自 Web Push 的 AES-GCM 消息](https://blog.nest.moe/posts/decrypt-aesgcm-messages-from-web-push): 上記のポストの暗号化周りを詳細に解説している + - [tomoyukilabs - Qiita](https://qiita.com/tomoyukilabs) + - [Web Pushでブラウザにプッシュ通知を送ってみる](https://qiita.com/tomoyukilabs/items/217915676603fda73b0a) + - [[改訂版] Web Pushでブラウザにプッシュ通知を送ってみる](https://qiita.com/tomoyukilabs/items/2ae4a0f708a1af75f13e) + - [SherClockHolmes/webpush-go](https://github.com/SherClockHolmes/webpush-go): WebPush暗号化のGo実装 +- NicoPush + - [ニコ生のプッシュ通知の受信の手順 - nicoLiveCheckTool/push.md](https://github.com/guest-nico/nicoLiveCheckTool/blob/master/push.md): C#でニコニコのWebPush通知を受信する手順を解説している + +## LICENSE +[MIT](./LICENSE) + +## Author +[shinosaki](https://shinosaki.com) diff --git a/autopush/client.go b/autopush/client.go new file mode 100644 index 0000000..7a10173 --- /dev/null +++ b/autopush/client.go @@ -0,0 +1,179 @@ +package autopush + +import ( + "crypto/ecdh" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "log" + "time" + + "github.com/shinosaki/webpush-client-go/rfc8291" + "github.com/shinosaki/webpush-client-go/webpush" + "github.com/shinosaki/websocket-client-go/websocket" +) + +const MOZILLA_PUSH_SERVICE = "wss://push.services.mozilla.com" + +type AutoPushClient struct { + *websocket.WebSocketClient + ece *rfc8291.RFC8291 + helloChan chan HelloResponse + notificationChan chan Notification + registerChan chan RegisterResponse + unregisterChan chan UnregisterResponse +} + +func request[T any](c *AutoPushClient, ch chan T, timeout time.Duration, timeoutErr string, payload any) (res T, err error) { + if err := c.SendJSON(payload); err != nil { + log.Println("websocket request failed", err) + } + + select { + case res := <-ch: + return res, err + case <-time.After(timeout * time.Second): + return res, errors.New(timeoutErr) + } +} + +func unmarshaler[T any](payload json.RawMessage, label MessageType) (data *T) { + // log.Println("autopush: before unmarshal payload", payload) + if err := json.Unmarshal(payload, &data); err != nil { + log.Printf("AutoPush: failed to unmarshal %s payload: %v", label, err) + } + return data +} + +func (c *AutoPushClient) Hello(uaid string, channelIDs []string) (HelloResponse, error) { + return request(c, c.helloChan, 5, "hello timeout", HelloRequest{ + Type: HELLO, + UAID: uaid, + ChannelIDs: channelIDs, + UseWebPush: true, + }) +} + +func (c *AutoPushClient) Register(channelID string, vapidKey string) (RegisterResponse, error) { + return request(c, c.registerChan, 5, "register timeout", RegisterRequest{ + Type: REGISTER, + ChannelID: channelID, + Key: vapidKey, + }) +} + +func (c *AutoPushClient) Unregister(channelID string) (UnregisterResponse, error) { + return request(c, c.unregisterChan, 5, "unregister timeout", UnregisterRequest{ + Type: UNREGISTER, + ChannelID: channelID, + }) +} + +func (c *AutoPushClient) Decrypt( + curve ecdh.Curve, + authSecret []byte, + useragentPrivateKey *ecdh.PrivateKey, + notification Notification, +) (*webpush.WebPushPayload, error) { + data, err := base64.RawURLEncoding.DecodeString(notification.Data) + if err != nil { + return nil, fmt.Errorf("base64 decode error: %v", err) + } + + payload, err := rfc8291.Unmarshal(data) + if err != nil { + return nil, fmt.Errorf("rfc8291 decode error: %v", err) + } + + appserverPublicKey, err := curve.NewPublicKey(payload.KeyId) + if err != nil { + return nil, fmt.Errorf("ecdh public key load error: %v", err) + } + + plaintext, err := c.ece.Decrypt( + payload.CipherText, + payload.Salt, + authSecret, + useragentPrivateKey, + appserverPublicKey, + ) + if err != nil { + return nil, fmt.Errorf("rfc8291 decrypt error: %v", err) + } + + var result webpush.WebPushPayload + if err := json.Unmarshal(plaintext, &result); err != nil { + return nil, fmt.Errorf("failed to unmarshal json: %v", err) + } + return &result, nil +} + +func NewAutoPushClient() (ap *AutoPushClient, ch chan Notification) { + ap = &AutoPushClient{ + ece: rfc8291.NewRFC8291(sha256.New), + helloChan: make(chan HelloResponse), + notificationChan: make(chan Notification), + registerChan: make(chan RegisterResponse), + unregisterChan: make(chan UnregisterResponse), + WebSocketClient: websocket.NewWebSocketClient( + nil, + func(ws *websocket.WebSocketClient, isReconnecting bool) { + if !isReconnecting { + close(ap.helloChan) + close(ap.notificationChan) + close(ap.registerChan) + close(ap.unregisterChan) + } + }, + func(ws *websocket.WebSocketClient, payload []byte) { + // log.Println("AutoPush Received Message:", string(payload)) + var message Message + if err := json.Unmarshal(payload, &message); err != nil { + log.Println("AutoPush: failed to unmarshal payload", err) + return + } + + switch message.Type { + case PING: + ws.SendJSON("{}") + + case HELLO: + if data := unmarshaler[HelloResponse](payload, HELLO); data != nil { + ap.helloChan <- *data + } + + case REGISTER: + if data := unmarshaler[RegisterResponse](payload, REGISTER); data != nil { + ap.registerChan <- *data + } + + case UNREGISTER: + if data := unmarshaler[UnregisterResponse](payload, UNREGISTER); data != nil { + ap.unregisterChan <- *data + } + + case NOTIFICATION: + if data := unmarshaler[Notification](payload, NOTIFICATION); data != nil { + ws.SendJSON(Ack{ + Type: ACK, + Updates: []AckUpdate{ + { + ChannelID: data.ChannelID, + Version: data.Version, + }, + }, + }) + ap.notificationChan <- *data + } + + default: + log.Println("AutoPush: unknown data type", message.Type) + } + }, + ), + } + + return ap, ap.notificationChan +} diff --git a/autopush/types.go b/autopush/types.go new file mode 100644 index 0000000..56ac4ec --- /dev/null +++ b/autopush/types.go @@ -0,0 +1,88 @@ +package autopush + +type Status int + +const ( + OK Status = 200 + CONFLICT Status = 409 + SERVER_ERROR Status = 500 +) + +type MessageType string + +const ( + PING MessageType = "ping" + ACK MessageType = "ack" + HELLO MessageType = "hello" + REGISTER MessageType = "register" + UNREGISTER MessageType = "unregister" + NOTIFICATION MessageType = "notification" +) + +type Message struct { + Type MessageType `json:"messageType"` + // Data json.RawMessage `json:"-"` +} + +type HelloRequest struct { + Type MessageType `json:"messageType"` + UAID string `json:"uaid"` + ChannelIDs []string `json:"channelIDs"` + UseWebPush bool `json:"use_webpush,omitempty"` +} + +type HelloResponse struct { + Type MessageType `json:"messageType"` + UAID string `json:"uaid"` + Status Status `json:"status"` + UseWebPush bool `json:"use_webpush,omitempty"` + // Broadcasts map[string]any `json:"broadcasts"` +} + +type RegisterRequest struct { + Type MessageType `json:"messageType"` + ChannelID string `json:"channelID"` + Key string `json:"key"` +} + +type RegisterResponse struct { + Type MessageType `json:"messageType"` + ChannelID string `json:"channelID"` + Status Status `json:"status"` + PushEndpoint string `json:"pushEndpoint"` +} + +type UnregisterRequest struct { + Type MessageType `json:"messageType"` + ChannelID string `json:"channelID"` +} + +type UnregisterResponse struct { + Type MessageType `json:"messageType"` + ChannelID string `json:"channelID"` + Status Status `json:"status"` +} + +type Notification struct { + Type MessageType `json:"messageType"` + ChannelID string `json:"channelID"` + Version string `json:"version"` + Data string `json:"data"` + Headers NotificationHeaders `json:"headers"` +} + +type NotificationHeaders struct { + Encryption string `json:"encryption"` + CryptoKey string `json:"crypto_key"` + Encoding string `json:"encoding"` +} + +type Ack struct { + Type MessageType `json:"messageType"` + Updates []AckUpdate `json:"updates"` +} + +type AckUpdate struct { + ChannelID string `json:"channelID"` + Version string `json:"version"` +} diff --git a/examples/ece/main.go b/examples/ece/main.go new file mode 100644 index 0000000..433b1ad --- /dev/null +++ b/examples/ece/main.go @@ -0,0 +1,69 @@ +package main + +import ( + "crypto/ecdh" + "crypto/sha256" + "log" + + "github.com/shinosaki/webpush-client-go/rfc8291" +) + +var ( + CURVE = ecdh.P256() + HASH = sha256.New + + PLAINTEXT = "Plain text message!!!" +) + +func appserver(authSecret []byte, useragentPublicKey *ecdh.PublicKey) []byte { + _, salt, privateKey := rfc8291.NewSecrets(CURVE) + ece := rfc8291.NewRFC8291(HASH) + + encrypted, err := ece.Encrypt( + []byte(PLAINTEXT), + salt, + authSecret, + useragentPublicKey, + privateKey, + ) + if err != nil { + log.Panicln("Encryption Error:", err) + } + + return encrypted +} + +func main() { + // UserAgent + authSecret, _, useragentPrivateKey := rfc8291.NewSecrets(CURVE) + ece := rfc8291.NewRFC8291(HASH) + + // AppServer + encrypted := appserver(authSecret, useragentPrivateKey.PublicKey()) + + // UserAgent + payload, err := rfc8291.Unmarshal(encrypted) + if err != nil { + log.Panicln("RFC8291 Unmarshal Error:", err) + } + + // In RFC8291, KeyID is the AppServer's Public Key + appserverPublicKey, err := ecdh.P256().NewPublicKey(payload.KeyId) + if err != nil { + log.Panicln("Load PublicKey Error", err) + } + + plaintext, err := ece.Decrypt( + payload.CipherText, + payload.Salt, + authSecret, + useragentPrivateKey, + appserverPublicKey, + ) + if err != nil { + log.Panicln("RFC8291 Decrypt Error", err) + } + + log.Println("Valid Message: ", PLAINTEXT) + log.Println("Decrypted Text:", string(plaintext)) +} diff --git a/examples/nicopush/main.go b/examples/nicopush/main.go new file mode 100644 index 0000000..0a2a5a7 --- /dev/null +++ b/examples/nicopush/main.go @@ -0,0 +1,151 @@ +package main + +import ( + "crypto/ecdh" + "encoding/base64" + "encoding/json" + "log" + "os" + + "github.com/shinosaki/webpush-client-go/rfc8291" + "github.com/shinosaki/webpush-client-go/sites/nicopush" +) + +type Config struct { + UserSession string + AuthSecret []byte + PrivateKey *ecdh.PrivateKey + UAID string + ChannelIDs []string +} + +type SerializedConfig struct { + UserSession string `json:"user_session"` + AuthSecret string `json:"auth_secret"` + PrivateKey string `json:"private_key"` + UAID string `json:"uaid"` + ChannelIDs []string `json:"channel_ids"` +} + +func ConfigLoad(path string) (*Config, error) { + file, err := os.Open(path) + if err != nil { + return nil, err + } + defer file.Close() + + config := &Config{} + serialized := &SerializedConfig{} + + if err := json.NewDecoder(file).Decode(&serialized); err != nil { + log.Println("failed to load config:", err) + } + + var ( + authSecret []byte + privateKey *ecdh.PrivateKey + ) + + authSecret, _, privateKey = rfc8291.NewSecrets(ecdh.P256()) + + if serialized.AuthSecret != "" { + authSecret, _ = base64.RawURLEncoding.DecodeString(serialized.AuthSecret) + } + + if serialized.PrivateKey != "" { + b, _ := base64.RawURLEncoding.DecodeString(serialized.PrivateKey) + privateKey, _ = ecdh.P256().NewPrivateKey(b) + } + + config.UserSession = serialized.UserSession + config.AuthSecret = authSecret + config.PrivateKey = privateKey + config.UAID = serialized.UAID + config.ChannelIDs = serialized.ChannelIDs + + return config, nil +} + +func ConfigSave(path string, config *Config) error { + serialized := &SerializedConfig{ + UserSession: config.UserSession, + UAID: config.UAID, + ChannelIDs: config.ChannelIDs, + AuthSecret: base64.RawURLEncoding.EncodeToString(config.AuthSecret), + PrivateKey: base64.RawURLEncoding.EncodeToString(config.PrivateKey.Bytes()), + } + + data, err := json.MarshalIndent(serialized, "", " ") + if err != nil { + return err + } + + return os.WriteFile(path, data, 0644) +} + +func main() { + configPath := "config.json" + + config, err := ConfigLoad(configPath) + if err != nil { + panic(err) + } + + if config.UserSession == "" { + panic("user session is require") + } + + httpClient, err := nicopush.NewLoginSession(config.UserSession) + if err != nil { + panic(err) + } + + nicoPushClient, notificationChan, err := nicopush.NewNicoPushClient( + config.UAID, + config.ChannelIDs, + config.AuthSecret, + config.PrivateKey, + httpClient, + ) + if err != nil { + panic(err) + } + + config.UAID, err = nicoPushClient.Handshake() + if err != nil { + panic(err) + } + log.Println("UAID:", config.UAID) + ConfigSave(configPath, config) + + if len(config.ChannelIDs) == 0 { + channelID, err := nicoPushClient.Register() + if err != nil { + panic(err) + } + log.Println("ChannelID:", channelID) + + config.ChannelIDs = append(config.ChannelIDs, channelID) + ConfigSave(configPath, config) + } + + for data := range notificationChan { + payload, err := nicoPushClient.Decrypt(data) + if err != nil { + log.Println("webpush error:", err) + continue + } + + log.Println("Title:", payload.Title) + log.Println("Body: ", payload.Body) + log.Println("Icon: ", payload.Icon) + + var pushData nicopush.PushData + if err := json.Unmarshal(payload.Data, &pushData); err != nil { + log.Println("push data parse error:", err) + continue + } + log.Println("URL: ", pushData.OnClick) + log.Println("CreatedAt: ", pushData.CreatedAt) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..89e4ff2 --- /dev/null +++ b/go.mod @@ -0,0 +1,12 @@ +module github.com/shinosaki/webpush-client-go + +go 1.23.6 + +require ( + github.com/google/uuid v1.6.0 + github.com/shinosaki/websocket-client-go v1.0.0 + golang.org/x/crypto v0.35.0 + golang.org/x/net v0.35.0 +) + +require golang.org/x/text v0.22.0 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..4a0a519 --- /dev/null +++ b/go.sum @@ -0,0 +1,10 @@ +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/shinosaki/websocket-client-go v1.0.0 h1:k70Nl86Cn1jjhBdYs77MyjWUR9xPHA94Mev8Ual1TiE= +github.com/shinosaki/websocket-client-go v1.0.0/go.mod h1:fzgEpoLabqcgOjDVPo7dh6dTqhbCTgd3SdWO2a+fPxg= +golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs= +golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ= +golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= +golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= +golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= +golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= diff --git a/rfc8291/payload.go b/rfc8291/payload.go new file mode 100644 index 0000000..f5f3355 --- /dev/null +++ b/rfc8291/payload.go @@ -0,0 +1,54 @@ +package rfc8291 + +import ( + "bytes" + "encoding/binary" + "errors" +) + +const ( + BASE_HEADER_LEN = 21 +) + +type Payload struct { + RS uint32 + Salt []byte + KeyId []byte + CipherText []byte +} + +func Marshal(p Payload) (data []byte) { + rs := make([]byte, 4) + binary.BigEndian.PutUint32(rs, p.RS) + return bytes.Join([][]byte{ + p.Salt, + rs, + {uint8(len(p.KeyId))}, + p.KeyId, + p.CipherText, + }, []byte{}) +} + +func Unmarshal(data []byte) (p Payload, err error) { + err = errors.New("data is too short") + + if len(data) < BASE_HEADER_LEN { + return p, err + } + + p.Salt = data[:16] + p.RS = binary.BigEndian.Uint32(data[16:20]) + + idlen := int(data[20]) + if len(data) < BASE_HEADER_LEN+idlen { + return p, err + } + + if idlen > 0 { + p.KeyId = data[BASE_HEADER_LEN : BASE_HEADER_LEN+idlen] + } + + p.CipherText = data[BASE_HEADER_LEN+idlen:] + + return p, nil +} diff --git a/rfc8291/rfc8291.go b/rfc8291/rfc8291.go new file mode 100644 index 0000000..042cfdb --- /dev/null +++ b/rfc8291/rfc8291.go @@ -0,0 +1,209 @@ +package rfc8291 + +import ( + "bytes" + "crypto/aes" + "crypto/cipher" + "crypto/ecdh" + "crypto/rand" + "crypto/sha256" + "fmt" + "hash" + "io" + "log" + + "golang.org/x/crypto/hkdf" +) + +const ( + AUTH_SECRET_LEN = 16 + SALT_LEN = 16 + + AES_GCM_OVERHEAD = 16 + + HKDF_IKM_LEN = 32 + HKDF_CEK_LEN = 16 + HKDF_NONCE_LEN = 12 +) + +type RFC8291 struct { + hash func() hash.Hash +} + +// Default Hash is SHA256 +func NewRFC8291(hash func() hash.Hash) *RFC8291 { + if hash == nil { + hash = sha256.New + } + return &RFC8291{hash: hash} +} + +func NewSecrets(curve ecdh.Curve) (auth, salt []byte, key *ecdh.PrivateKey) { + auth = make([]byte, AUTH_SECRET_LEN) + salt = make([]byte, SALT_LEN) + for _, b := range [][]byte{auth, salt} { + _, err := io.ReadFull(rand.Reader, b) + if err != nil { + log.Panicln("failed to generate random secret", err) + } + } + + key, err := curve.GenerateKey(rand.Reader) + if err != nil { + log.Panicln("failed to generate ecdh key", err) + } + + return auth, salt, key +} + +func (c *RFC8291) Encrypt( + plaintext []byte, + salt []byte, + authSecret []byte, + useragentPublicKey *ecdh.PublicKey, + appserverPrivateKey *ecdh.PrivateKey, +) ([]byte, error) { + if len(authSecret) != AUTH_SECRET_LEN { + return nil, fmt.Errorf("auth_secret must be %d bytes", AUTH_SECRET_LEN) + } + if len(salt) != SALT_LEN { + return nil, fmt.Errorf("salt must be %d bytes", SALT_LEN) + } + + ecdhSecret, err := appserverPrivateKey.ECDH(useragentPublicKey) + if err != nil { + return nil, fmt.Errorf("calculate ecdh_secret failed: %v", err) + } + + ikm, err := c.ikm(authSecret, ecdhSecret, useragentPublicKey, appserverPrivateKey.PublicKey()) + if err != nil { + return nil, err + } + + cek, nonce, err := c.cekAndNonce(ikm, salt) + if err != nil { + return nil, err + } + + gcm, err := c.gcm(cek) + if err != nil { + return nil, err + } + + ciphertext := gcm.Seal(nil, nonce, plaintext, nil) + rs := uint32(len(plaintext) + 1 + AES_GCM_OVERHEAD) + + // RFC8188: 0x01 or 0x02 in tail of plaintext data + // RFC8291: The push message plaintext has the padding delimiter octet (0x02) appended to produce + // ciphertext = bytes.Join([][]byte{ciphertext, {0x02}}, []byte{}) + + return Marshal(Payload{ + RS: rs, + Salt: salt, + KeyId: appserverPrivateKey.PublicKey().Bytes(), + CipherText: ciphertext, + }), nil +} + +func (c *RFC8291) Decrypt( + ciphertext []byte, + salt []byte, + authSecret []byte, + useragentPrivateKey *ecdh.PrivateKey, + appserverPublicKey *ecdh.PublicKey, +) ([]byte, error) { + if len(authSecret) != AUTH_SECRET_LEN { + return nil, fmt.Errorf("auth_secret must be %d bytes", AUTH_SECRET_LEN) + } + if len(salt) != SALT_LEN { + return nil, fmt.Errorf("salt must be %d bytes", SALT_LEN) + } + + ecdhSecret, err := useragentPrivateKey.ECDH(appserverPublicKey) + if err != nil { + return nil, fmt.Errorf("calculate ecdh_secret failed: %v", err) + } + + ikm, err := c.ikm(authSecret, ecdhSecret, useragentPrivateKey.PublicKey(), appserverPublicKey) + if err != nil { + return nil, err + } + + cek, nonce, err := c.cekAndNonce(ikm, salt) + if err != nil { + return nil, err + } + + gcm, err := c.gcm(cek) + if err != nil { + return nil, err + } + + plaintext, err := gcm.Open(nil, nonce, ciphertext, nil) + if err != nil { + return nil, err + } + + // RFC8188: 0x01 or 0x02 in tail of plaintext data + // RFC8291: The push message plaintext has the padding delimiter octet (0x02) appended to produce + if plaintext[len(plaintext)-1] == 0x01 || plaintext[len(plaintext)-1] == 0x02 { + plaintext = plaintext[:len(plaintext)-1] + } + + return plaintext, err +} + +func (c *RFC8291) gcm(cek []byte) (cipher.AEAD, error) { + block, err := aes.NewCipher(cek) + if err != nil { + return nil, fmt.Errorf("create cipher block failed: %v", err) + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, fmt.Errorf("create GCM failed: %v", err) + } + + return gcm, nil +} + +func (c *RFC8291) ikm( + authSecret []byte, + ecdhSecret []byte, + useragentPublicKey *ecdh.PublicKey, + appserverPublicKey *ecdh.PublicKey, +) (ikm []byte, err error) { + prkKey := hkdf.Extract(c.hash, ecdhSecret, authSecret) + + keyInfo := bytes.Join([][]byte{ + []byte("WebPush: info\000"), + useragentPublicKey.Bytes(), + appserverPublicKey.Bytes(), + }, []byte{}) + + ikm = make([]byte, HKDF_IKM_LEN) + if _, err := io.ReadFull(hkdf.Expand(c.hash, prkKey, keyInfo), ikm); err != nil { + return nil, fmt.Errorf("read IKM failed: %v", err) + } + + return ikm, nil +} + +func (c *RFC8291) cekAndNonce(ikm []byte, salt []byte) (cek, nonce []byte, err error) { + prk := hkdf.Extract(c.hash, ikm, salt) + + cekInfo := []byte("Content-Encoding: aes128gcm\000") + nonceInfo := []byte("Content-Encoding: nonce\000") + + cek = make([]byte, HKDF_CEK_LEN) + if _, err := io.ReadFull(hkdf.Expand(c.hash, prk, cekInfo), cek); err != nil { + return nil, nil, fmt.Errorf("read CEK failed: %v", err) + } + + nonce = make([]byte, HKDF_NONCE_LEN) + if _, err := io.ReadFull(hkdf.Expand(c.hash, prk, nonceInfo), nonce); err != nil { + return nil, nil, fmt.Errorf("read Nonce failed: %v", err) + } + + return cek, nonce, nil +} diff --git a/sites/nicopush/client.go b/sites/nicopush/client.go new file mode 100644 index 0000000..9673791 --- /dev/null +++ b/sites/nicopush/client.go @@ -0,0 +1,157 @@ +package nicopush + +import ( + "bytes" + "crypto/ecdh" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/google/uuid" + "github.com/shinosaki/webpush-client-go/autopush" + "github.com/shinosaki/webpush-client-go/rfc8291" + "github.com/shinosaki/webpush-client-go/webpush" +) + +type NicoPushClient struct { + autoPushClient *autopush.AutoPushClient + httpClient *http.Client + ece *rfc8291.RFC8291 + + vapidKey []byte + nicoPushEndpoint string + + uaid string + channelIDs []string + authSecret []byte + privateKey *ecdh.PrivateKey + publicKey *ecdh.PublicKey +} + +func NewNicoPushClient( + uaid string, + channelIDs []string, + authSecret []byte, + privateKey *ecdh.PrivateKey, + httpClient *http.Client, +) (*NicoPushClient, chan autopush.Notification, error) { + vapidKey, nicoPushEndpoint, err := getEndpointAndVapidKey() + if err != nil { + return nil, nil, err + } + + ap, ch := autopush.NewAutoPushClient() + + client := &NicoPushClient{ + autoPushClient: ap, + httpClient: httpClient, + ece: rfc8291.NewRFC8291(sha256.New), + + vapidKey: vapidKey, + nicoPushEndpoint: nicoPushEndpoint, + + uaid: uaid, + channelIDs: channelIDs, + authSecret: authSecret, + privateKey: privateKey, + publicKey: privateKey.PublicKey(), + } + + return client, ch, nil +} + +// Handshake performs a handshake with the AutoPush server and retrieves a UAID. +// The obtained UAID is used for client identification and should be saved. +func (c *NicoPushClient) Handshake() (uaid string, err error) { + if err := c.autoPushClient.Connect(autopush.MOZILLA_PUSH_SERVICE, 3, 2); err != nil { + return "", fmt.Errorf("failed to connect autopush server: %v", err) + } + + data, err := c.autoPushClient.Hello(c.uaid, c.channelIDs) + if err != nil { + return "", fmt.Errorf("failed to handshake autopush server: %v", err) + } + + c.uaid = data.UAID + return c.uaid, nil +} + +func (c *NicoPushClient) Register() (channelID string, err error) { + uuid, err := uuid.NewRandom() + if err != nil { + return "", fmt.Errorf("failed to generate UUIDv4: %v", err) + } + + data, err := c.autoPushClient.Register(uuid.String(), base64.StdEncoding.EncodeToString(c.vapidKey)) + if err != nil { + return "", fmt.Errorf("failed to register autopush: %v", err) + } + + if err := c.registerToAppServer(data.PushEndpoint); err != nil { + return "", fmt.Errorf("failed to register nicopush: %v", err) + } + + return uuid.String(), nil +} + +func (c *NicoPushClient) Decrypt(data autopush.Notification) (*webpush.WebPushPayload, error) { + return c.autoPushClient.Decrypt( + ecdh.P256(), + c.authSecret, + c.privateKey, + data, + ) +} + +// Registration AutoPush's push-endpoint to NicoPush (Application Server) +func (c *NicoPushClient) registerToAppServer(pushEndpoint string) error { + payload, err := json.Marshal(Register{ + DestApp: NICO_ACCOUNT_WEBPUSH, + Endpoint: Endpoint{ + Endpoint: pushEndpoint, + Auth: base64.StdEncoding.EncodeToString(c.authSecret), + P256DH: base64.StdEncoding.EncodeToString(c.publicKey.Bytes()), + }, + }) + if err != nil { + return fmt.Errorf("failed to marshal register payload: %v", err) + } + + req, err := http.NewRequest(http.MethodPost, c.nicoPushEndpoint, bytes.NewBuffer(payload)) + if err != nil { + return fmt.Errorf("failed to bulid register request: %v", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Request-With", "https://account.nicovideo.jp/my/account") + req.Header.Set("X-Frontend-Id", "8") + + res, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("nicopush register request failed: %v", err) + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return fmt.Errorf("invalid http status: %d %s", res.StatusCode, res.Status) + } + + body, err := io.ReadAll(res.Body) + if err != nil { + return fmt.Errorf("read error: %v", err) + } + + var data APIResponse + if err := json.Unmarshal(body, &data); err != nil { + return fmt.Errorf("failed to unmarshal api response: %v", err) + } + + if data.Meta.Status != http.StatusOK { + return fmt.Errorf("invalid response status: %d", data.Meta.Status) + } + + return nil +} diff --git a/sites/nicopush/types.go b/sites/nicopush/types.go new file mode 100644 index 0000000..a18ff72 --- /dev/null +++ b/sites/nicopush/types.go @@ -0,0 +1,36 @@ +package nicopush + +import "time" + +type ( + DestApp string +) + +const ( + NICO_ACCOUNT_WEBPUSH DestApp = "nico_account_webpush" +) + +type APIResponse struct { + Meta struct { + Status int `json:"status"` + } `json:"meta"` +} + +type Register struct { + DestApp DestApp `json:"destApp"` + Endpoint Endpoint `json:"endpoint"` +} + +type Endpoint struct { + Endpoint string `json:"endpoint"` + Auth string `json:"auth"` + P256DH string `json:"p256dh"` +} + +// "data" property in WebPush Notification Message +type PushData struct { + TTL time.Duration `json:"ttl"` // e.g. 600 + CreatedAt time.Time `json:"created_at"` + OnClick string `json:"on_click"` // Program URL (e.g. "https://live.nicovideo.jp/watch/lv123456?from=webpush&_topic=live_user_program_onairs") + TrackingParameter string `json:"tracking_parameter"` // e.g. "live_onair-lv123456-webpush-nico_account_webpush" +} diff --git a/sites/nicopush/utils.go b/sites/nicopush/utils.go new file mode 100644 index 0000000..b81226c --- /dev/null +++ b/sites/nicopush/utils.go @@ -0,0 +1,108 @@ +package nicopush + +import ( + "errors" + "fmt" + "io" + "log" + "net/http" + "net/http/cookiejar" + "net/url" + "regexp" + "strconv" + "strings" + + "golang.org/x/net/http2" +) + +func NewLoginSession(userSession string) (*http.Client, error) { + jar, err := cookiejar.New(nil) + if err != nil { + return nil, err + } + + origin, _ := url.Parse("https://nicovideo.jp/") + jar.SetCookies(origin, []*http.Cookie{ + { + Name: "user_session", + Value: "user_session_125600936_50e1b073aa6a9b7ce4065265c9ef89b7a1b6f8aeec322cb748551e2515682e73", + Path: "/", + Domain: ".nicovideo.jp", + }, + }) + + client := &http.Client{ + Transport: &http2.Transport{}, + Jar: jar, + } + + return client, nil +} + +func getEndpointAndVapidKey() (vapidKey []byte, nicoPushUrl string, err error) { + fetchString := func(url string) (string, error) { + res, err := http.Get(url) + if err != nil { + return "", err + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return "", fmt.Errorf("invalid http status %d %s", res.StatusCode, res.Status) + } + + body, err := io.ReadAll(res.Body) + if err != nil { + return "", err + } + + return string(body), nil + } + + getServiceWorkerUrl := func() (string, error) { + body, err := fetchString("https://account.nicovideo.jp/sw.js") + if err != nil { + return "", err + } + + re := regexp.MustCompile(`importScripts\(['"](.*?)['"]\)`) + match := re.FindStringSubmatch(string(body)) + if len(match) != 2 { + return "", errors.New("importScript is not contain") + } + + return match[1], nil + } + + swUrl, err := getServiceWorkerUrl() + if err != nil { + return vapidKey, nicoPushUrl, err + } + log.Println("swURL:", swUrl) + + swFile, err := fetchString(swUrl) + if err != nil { + return vapidKey, nicoPushUrl, err + } + + re := regexp.MustCompile(`Uint8Array\(\[([\d,]+)\]\);[\w.]+={URL:"(https:\/\/api\.push\.nicovideo\.jp.*?)"`) + match := re.FindStringSubmatch(swFile) + if len(match) != 3 { + return vapidKey, nicoPushUrl, errors.New("VAPID key and NicoPush url is not contain") + } + + byteStrings := match[1] + nicoPushUrl = match[2] + + nums := strings.Split(byteStrings, ",") + vapidKey = make([]byte, len(nums)) + for i, s := range nums { + n, err := strconv.Atoi(strings.TrimSpace(s)) + if err != nil { + return vapidKey, nicoPushUrl, fmt.Errorf("failed to convert byte arrays of Uint8Array strings: %v", err) + } + vapidKey[i] = byte(n) + } + + return vapidKey, nicoPushUrl, nil +} diff --git a/webpush/types.go b/webpush/types.go new file mode 100644 index 0000000..1351b9f --- /dev/null +++ b/webpush/types.go @@ -0,0 +1,11 @@ +package webpush + +import "encoding/json" + +// https://developer.mozilla.org/docs/Web/API/Notification +type WebPushPayload struct { + Title string `json:"title"` + Body string `json:"body"` + Icon string `json:"icon"` // icon url + Data json.RawMessage `json:"data"` // custom data field +}