RFC 8219 Message Encryption for Web Push 理解のため読みつつ Go での Application Server 側の実装をなぞる。
参考にしたコードは以下。
- gauntface/web-push-go は aes128gcm が実装されているがアーカイブされている
-
SherClockHolmes/webpush-go は aesgcm のみで aes128gcm が実装されていない
- aesgcm は draft04 時のもので古いが現時点での PushService 側での対応具合は要調査
Encryption Summary
以下を実装し、必要なキーを生成する必要がある。
-- For an application server:
ecdh_secret = ECDH(as_private, ua_public)
auth_secret = <from user agent>
salt = random(16)
-- For both:
## Use HKDF to combine the ECDH and authentication secrets
# HKDF-Extract(salt=auth_secret, IKM=ecdh_secret)
PRK_key = HMAC-SHA-256(auth_secret, ecdh_secret)
# HKDF-Expand(PRK_key, key_info, L_key=32)
key_info = "WebPush: info" || 0x00 || ua_public || as_public
IKM = HMAC-SHA-256(PRK_key, key_info || 0x01)
## HKDF calculations from RFC 8188
# HKDF-Extract(salt, IKM)
PRK = HMAC-SHA-256(salt, IKM)
# HKDF-Expand(PRK, cek_info, L_cek=16)
cek_info = "Content-Encoding: aes128gcm" || 0x00
CEK = HMAC-SHA-256(PRK, cek_info || 0x01)[0..15]
# HKDF-Expand(PRK, nonce_info, L_nonce=12)
nonce_info = "Content-Encoding: nonce" || 0x00
NONCE = HMAC-SHA-256(PRK, nonce_info || 0x01)[0..11]
Push Message Encryption Example と Appendix A. Intermediate Values for Encryption により、それぞれの値を再現するテストを書くことが可能。
ecdh_secret = ECDH(as_private, ua_public)
Diffie-Hellman Key Agreement を参照。
var curve = elliptic.P256()
func newApplicationServerKeys() (*applicationServerKeys, error) {
priv, x, y, err := elliptic.GenerateKey(curve, rand.Reader)
if err != nil {
return nil, err
}
pub := elliptic.Marshal(curve, x, y)
return &applicationServerKeys{private: priv, public: pub}, nil
}
まず as_private
をとして使うため ApplicationServer のキーペアを作成する。
ここで出来た公開鍵は後で keyid
に入れる。
func newSharedSecret(as *applicationServerKeys, p256dh []byte) ([]byte, error) {
x, y := elliptic.Unmarshal(curve, p256dh)
if x == nil {
return nil, errors.New("elliptic.Unmarshal failed")
}
secret, _ := curve.ScalarMult(x, y, as.private)
return secret.Bytes(), nil
}
func TestEncrypt_ecdh_secret(t *testing.T) {
expected := "kyrL1jIIOHEzg3sM2ZWRHDRB62YACZhhSlknJ672kSs"
actual, _ := newSharedSecret(as(), uaPublic)
if s := encode(actual); s != expected {
t.Fatalf("ecdh_secret expected %s but actual %s", expected, s)
}
}
ua_public
つまり UserAgent の公開鍵(p256dh として PushSubscription から得られる)と as_private
を合わせると shared secret が出来上がる(ここら辺はたぶん ECDH のドキュメントに書いてあるが読んでいないので理解していない)。
auth_secret = <from user agent>
UserAgent から PushSubscription を取得した際に得られる auth
が必要となる。
salt = random(16)
ランダムな 16 バイト。
IKM = HMAC-SHA-256(PRK_key, key_info || 0x01)
Combining Shared and Authentication Secrets を参照。
func newKeyInfo(ua, as []byte) []byte {
prefix := []byte("WebPush: info\x00")
bytes := make([]byte, len(prefix) + len(ua) + len(as))
n := copy(bytes, prefix)
n += copy(bytes[n:], ua)
copy(bytes[n:], as)
return bytes
}
func TestEncrypt_key_info(t *testing.T) {
expected := "V2ViUHVzaDogaW5mbwAEJXGyvs3942BVGq8e0PTNNmwRzr5VX4m8t7GGpTM5FzFo7OLr4BhZe9MEebhuPI-OztV3ylkYfpJGmQ22ggCLDgT-M_SrDepxkU21WCP3O1SUj0EwbZIHMtu5pZpTKGSCIA5Zent7wmC6HCJ5mFgJkuk5cwAvMBKiiujwa7t45ewP"
actual := newKeyInfo(uaPublic, as().public)
if s := encode(actual); s != expected {
t.Fatalf("key_info expected %s but actual %s", expected, s)
}
}
info として上記のバイトを生成する。
as_public
は上で生成した ApplicationServer の公開鍵を指定する。
func newIkm(ua, as, auth, ecdh []byte) ([]byte, error) {
keyInfo := newKeyInfo(ua, as)
return _hkdf(ecdh, auth, keyInfo, 32)
}
func TestEncrypt_ikm(t *testing.T) {
expected := "S4lYMb_L0FxCeq0WhDx813KgSYqU26kOyzWUdsXYyrg"
sharedSecret, _ := newSharedSecret(as(), uaPublic)
ikm, _ := newIkm(uaPublic, as().public, authSecret, sharedSecret)
if s := encode(ikm); s != expected {
t.Fatalf("ikm expected %s but actual %s", expected, s)
}
}
IKM は auth_secret
, ecdh_secret
, key_info
を HMAC-based key derivation function(HKDF) を利用して合わせる。
CEK = HMAC-SHA-256(PRK, cek_info || 0x01)[0..15]
CEK (Content Encryption Key) を生成する。
var cekInfo = []byte("Content-Encoding: aes128gcm\x00")
func TestEncrypt_cek_info(t *testing.T) {
expected := "Q29udGVudC1FbmNvZGluZzogYWVzMTI4Z2NtAA"
if s := encode(cekInfo); s != expected {
t.Fatalf("cek_info expected %s but actual %s", expected, s)
}
}
func newCek(salt, ikm []byte) ([]byte, error) {
return _hkdf(ikm, salt, cekInfo, 16)
}
func TestEncrypt_cek(t *testing.T) {
expected := "oIhVW04MRdy2XN9CiKLxTg"
sharedSecret, _ := newSharedSecret(as(), uaPublic)
ikm, _ := newIkm(uaPublic, as().public, authSecret, sharedSecret)
cek, _ := newCek(salt, ikm)
if s := encode(cek); s != expected {
t.Fatalf("cek expected %s but actual %s", expected, s)
}
}
CEK は ikm
と salt
を HKDF で合わせる。
NONCE = HMAC-SHA-256(PRK, nonce_info || 0x01)[0..11]
var nonceInfo = []byte("Content-Encoding: nonce\x00")
func TestEncrypt_nonce_info(t *testing.T) {
expected := "Q29udGVudC1FbmNvZGluZzogbm9uY2UA"
if s := encode(nonceInfo); s != expected {
t.Fatalf("nonce_info expected %s but actual %s", expected, s)
}
}
func newNonce(salt, ikm []byte) ([]byte, error) {
return _hkdf(ikm, salt, nonceInfo, 12)
}
func TestEncrypt_nonce(t *testing.T) {
expected := "4h_95klXJ5E_qnoN"
sharedSecret, _ := newSharedSecret(as(), uaPublic)
ikm, _ := newIkm(uaPublic, as().public, authSecret, sharedSecret)
nonce, _ := newNonce(salt, ikm)
if s := encode(nonce); s != expected {
t.Fatalf("nonce expected %s but actual %s", expected, s)
}
}
The "aes128gcm" HTTP Content Coding
aes128gcm は RFC8188 Encrypted Content-Encoding for HTTP に書かれている。
Webpush Encryption における制限は Restrictions on Use of "aes128gcm" Content Coding にある。
func newCiphertext(plaintext string, cek, nonce []byte) ([]byte, error) {
b := []byte(plaintext)
b = append(b, 0x02)
block, err := aes.NewCipher(cek)
if err != nil {
return nil, err
}
aesgcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
return aesgcm.Seal(nil, nonce, b, nil), nil
}
func TestEncrypt_ciphertext(t *testing.T) {
expected := "8pfeW0KbunFT06SuDKoJH9Ql87S1QUrdirN6GcG7sFz1y1sqLgVi1VhjVkHsUoEsbI_0LpXMuGvnzQ"
sharedSecret, _ := newSharedSecret(as(), uaPublic)
ikm, _ := newIkm(uaPublic, as().public, authSecret, sharedSecret)
cek, _ := newCek(salt, ikm)
nonce, _ := newNonce(salt, ikm)
ciphertext, err := newCiphertext(body, cek, nonce)
if err != nil {
t.Fatal(err)
}
if s := encode(ciphertext); s != expected {
t.Fatalf("ciphertext expected %s but actual %s", expected, s)
}
}
cek
と nonce
を利用して暗号化する。
他に 0x02 を padding delimiter として利用する、 plaintext が 3993 octets までであるなどが rfc から読み取れる。
func newHeader(salt, keyid []byte) []byte {
var b []byte
b = append(b, salt...)
rs := make([]byte, 4)
binary.BigEndian.PutUint32(rs, 4096)
b = append(b, rs...)
b = append(b, byte(len(keyid)))
b = append(b, keyid...)
return b
}
func TestEncrypt_header(t *testing.T) {
expected := "DGv6ra1nlYgDCS1FRnbzlwAAEABBBP4z9KsN6nGRTbVYI_c7VJSPQTBtkgcy27mlmlMoZIIgDll6e3vCYLocInmYWAmS6TlzAC8wEqKK6PBru3jl7A8"
header := newHeader(salt, as().public)
if s := encode(header); s != expected {
t.Fatalf("header expected %s but actual %s", expected, s)
}
}
rs
については、
An application server MUST set the "rs" parameter in the "aes128gcm" content coding header to a size that is greater than the sum of the lengths of the plaintext, the padding delimiter (1 octet), any padding, and the authentication tag (16 octets).
とあるので正確である必要はないのかもしれない(Example は 4096 になっている)。
keyid
として前述した ApplicationServer の公開鍵を指定する。