This commit is contained in:
shinosaki 2025-02-27 13:00:06 +00:00
commit 4dac778ae3
14 changed files with 1164 additions and 0 deletions

21
LICENSE Normal file
View File

@ -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.

59
README.md Normal file
View File

@ -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)

179
autopush/client.go Normal file
View File

@ -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
}

88
autopush/types.go Normal file
View File

@ -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"`
}

69
examples/ece/main.go Normal file
View File

@ -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))
}

151
examples/nicopush/main.go Normal file
View File

@ -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)
}
}

12
go.mod Normal file
View File

@ -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

10
go.sum Normal file
View File

@ -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=

54
rfc8291/payload.go Normal file
View File

@ -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
}

209
rfc8291/rfc8291.go Normal file
View File

@ -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
}

157
sites/nicopush/client.go Normal file
View File

@ -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
}

36
sites/nicopush/types.go Normal file
View File

@ -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"
}

108
sites/nicopush/utils.go Normal file
View File

@ -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
}

11
webpush/types.go Normal file
View File

@ -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
}