init
This commit is contained in:
commit
4dac778ae3
|
|
@ -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.
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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"`
|
||||||
|
}
|
||||||
|
|
@ -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))
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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=
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue