LoginSignup
1

More than 5 years have passed since last update.

Go + RFC 8219 Message Encryption for Web Push

Posted at

RFC 8219 Message Encryption for Web Push 理解のため読みつつ Go での Application Server 側の実装をなぞる。

実装コード

参考にしたコードは以下。

  • gauntface/web-push-go は aes128gcm が実装されているがアーカイブされている
  • SherClockHolmes/webpush-go は aesgcm のみで aes128gcm が実装されていない :question:
    • 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 ExampleAppendix 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 は ikmsalt を 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)
    }
}

ceknonce を利用して暗号化する。
他に 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 の公開鍵を指定する。

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1