proxy-wasm を使って JWT 検証を go 言語で実装できるのか？


proxy-wasm での JWT 検証の実装を通して proxy-wasm でどこまでできるのかを調べた。
proxy-wasm は将来性に期待できる技術なものの、WebAssembly(wasm) のメインの用途はブラウザ上と思われる。
そのため proxy-wasm は発展途上で、現時点では色々な制約・将来性がある中でその魅力・使いやすさなどを知るために触ってみた。

# 要約
- proxy-wasm で JWT 検証を go 言語で実装するのは下記理由で困難だった。おそらく他言語でも同様だと思われる。
   - proxy-wasm では tinygo を使う必要があるが、[利用可能な package に制約がある](https://tinygo.org/docs/reference/lang-support/stdlib/)
   - proxy-wasm における [http アクセス](https://github.com/proxy-wasm/spec/tree/master/abi-versions/vNEXT#proxy_dispatch_http_call)の制約
- リクエストの内容についての簡単なチェックやメトリクス・ログなどをどこかに送るなどの用途であれば現状でも可能。
例えば protobuf で送られてきたメッセージを解析してメトリクス・ログを送る用途であれば
- proxy 拡張のコードをなれた言語で書け、ユニットテストできるのは魅力的。proxy-wasm 構築用のツール(wasme)もあり、実装しやすい。

# proxy-wasm とは
 [proxy-wasm](https://github.com/proxy-wasm/spec) は L4/L7 プロキシにおける拡張を wasm によって実現するための機能である。制約事項や性能懸念等まだまだあるものの、 wasm コンパイル可能な好きな言語を使って既存のライブラリも活用しながら拡張機能を追加できる点で魅力的に思える。

 proxy 拡張でほかの実現方法といえば、例えば Envoy Lua Filter が考えられる。
 こちらの場合は Lua 言語で実装し、 Envoy を拡張する Filter を追加する形で適用される。
 Lua 言語も個人的には扱いやすい言語ではあるものの、環境によっては Lua パッケージ管理ツールが使えないなどデメリットもあると思われる。


# モチベーション
  今回は proxy-wasm を使って istio における JWT 検証を go 言語で実装できるかトライしたい。
 istio の JWT 検証であれば [JWTRule](https://istio.io/latest/docs/reference/config/security/jwt/) リソースを使えば簡単に実現できるが、header からの値の取得・解析・JWKS URL へのアクセスを介した署名検証などの多くの機能が必要で、 proxy-wasm がどこまでやれるか把握するためにちょうど良いターゲットかなと思う。
 
 具体的には、下記のようなフローの実装をトライする。JWTは [GCP の ServiceAccount を使って生成されたもの](https://cloud.google.com/iam/docs/create-short-lived-credentials-direct#sa-credentials-jwt)を利用し、検証用に [istio install 手順](https://istio.io/latest/docs/setup/getting-started/) にあるサンプルアプリを採用する。
 ![proxy-wasm-arch.drawio.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/269590/76071652-c224-2924-1519-5e2f835eef5c.png)

# 調査
go 言語で proxy-wasm を実装するために、[wasme](https://docs.solo.io/web-assembly-hub/latest/reference/cli/wasme/), [proxy-wasm-go-sdk](https://github.com/tetratelabs/proxy-wasm-go-sdk)を使った。
wasme は proxy-wasm を実装・ビルド・デプロイするための便利なツールである。これを使うと初期ファイルの作成からやってくれるが、go 言語で実装したい場合は tinygo を選択することになる。

## 調査結果 
前述のとおり、下記理由から proxy-wasm で JWT 検証を go 言語で実装するのは困難だった。
- tinygo で [利用可能な package に制約がある](https://tinygo.org/docs/reference/lang-support/stdlib/)
- proxy-wasm における [http アクセス](https://github.com/proxy-wasm/spec/tree/master/abi-versions/vNEXT#proxy_dispatch_http_call)の制約

具体的には、一般的な JWT 検証フローは下記のようになるが、4. までしか実現できない。そのため、JWT が不正に書き換えられているかのチェックが実施できないことになる。
1. HTTP Header から Bearer トークンを取得する
2. Bearer トークンを base64 デコードする
3. JWT ヘッダーのアルゴリズム(alg)に問題ないか検証する
4. JWT ペイロードの有効期限(exp)が切れてないか検証する
5. JWT ペイロードの issuer(iss) をもとに JWKS url にアクセスし、 JWT の署名検証を行う

以下はその調査内容の詳細である。

### tinygo で利用可能な package の制約
主に下記3つの package がサポートされていないため、既存の JWT 検証 OSS をほぼ利用できない。また、特に crypto/rsa が使えないことで RSA 署名された JWT 検証がかなり困難となる。 
```
- crypto/rsa
- encoding/json
- net/http
```

encoding/json が使えない理由は reflect package が利用できないことが原因なので、ほかの reflect を使わない json encode/decode する OSS を利用すれば JWT の json decode 自体は問題なく実施できる。
net/http は利用できないが、 HTTP アクセスはそもそも proxy-wasm で定義されたインターフェースを使う必要があるので、 tinygo でサポートされたとしても proxy-wasm では使えない。ただ、これを利用できないことによって活用できる OSS は制限されてしまいそう。
  
### proxy-wasm における http アクセスの制約
proxy-wasm では HTTP アクセスのために [DispatchHttpCall](https://pkg.go.dev/github.com/tetratelabs/proxy-wasm-go-sdk/proxywasm#DispatchHttpCall) を通して呼び出す必要がある。

この際、 HTTP 呼び出しを行ってその response を受け取って処理を行う callback を渡す必要があるが、この callback 処理が非同期で行われ、upstream へのアクセスを待ってくれない。
そのため、 HTTP GET を行った結果を元に何らか処理をし、それによって upstream への呼び出しをする/しないを切り替えることはできない。

## 調査手順
下記環境で proxy-wasm の実装・デプロイを行う。
- proxy-wasm の実装
  - wasme: 0.0.33
  - proxy-wasm-go-sdk v0.1.1
- proxy-wasm のデプロイ
  - Ubuntu 20.04
  - Docker 20.10.18
  - minikube v1.28.0
  - Istio 1.16.1

また、JWT 検証実装の調査手順としては下記になる。
- proxy-wasm の実装
  - wasme ツールのインストールと初期化
  - JWT 検証処理の実装
- proxy-wasm のデプロイ
  - istio 環境の準備
  - 実装したものを OCI として push
  - proxy-wasm を istio WasmPlugin を通してデプロイ
  - curl で JWT を設定してアクセスし、その結果・ログを確認

最終的な実装されたコードの全体像は[こちら](https://github.com/hiroyoshii/proxy-wasm-go-custom-jwt-fliter)に置いた。

### wasme ツールのインストールと初期化
[wasme のインストール手順通り](https://docs.solo.io/web-assembly-hub/latest/installation/)、下記を実施する。
```shell
curl -sL https://run.solo.io/wasme/install | sh
export PATH=$HOME/.wasme/bin:$PATH
```

`wasme init` で下記を選択すると、 go 言語で proxy-wasm を実装するための初期テンプレートが生成される。
```
# wasme init custom_jwt_filter
✔ tinygo
✔ istio:1.7.x, gloo:1.6.x, istio:1.8.x, istio:1.9.x
```
 
### JWT 検証処理の実装
JWT 検証処理を実装するには、生成された main.go の中にある `OnHttpRequestHeaders` 関数に処理を追加する。
Header は `[][2]string` 型で取得できる。また、key はすべて小文字で格納されているので、下記のように `authorization` Header から Bearer トークンを取得する処理を挟む。
`types.Action` は2種類存在しており、 upsteam に処理を送りたいなら `types.ActionContinue` を、処理を中断して response を返したいなら `types.ActionPause` を設定する。

```golang
func (ctx *httpHeaders) OnHttpRequestHeaders(numHeaders int, endOfStream bool) types.Action {
hs, err := proxywasm.GetHttpRequestHeaders()
if err != nil {
    proxywasm.LogCriticalf("failed to get request headers: %v", err)
}
proxywasm.LogDebugf("on request header: %v", hs)

for _, h := range hs {
    // jwt header access
    if h[0] == "authorization" {
        token := strings.TrimPrefix(h[1], "Bearer ")
        // ここに JWT 検証処理を実装
    }
}
return types.ActionPause
}
```

#### JWT の中身を json を decode して検証する
https://github.com/buger/jsonparser のような OSS を使うと tinygo でも json を処理できる。他にもあると思うが、今回の実装ではこちらを採用した。

例えば JWT ヘッダーのアルゴリズム(alg)の値を取得したい場合は下記のような記述になる。
```golang
parts := strings.Split(token, ".")
header, err := base64.RawURLEncoding.DecodeString(parts[0])
if err != nil {
    panic(err)
}
alg, err := jsonparser.GetString(header, "alg")
if err != nil {
    panic(err)
}
```

#### JWKS URL にアクセスする
<a name="access_jwks_url"></a>
※ 検証結果で記載した通り実際には機能しないが、トライした実装を記載する。
`DispatchHttpCall` には envoy cluster 名しか渡せないので、 URL path は Header に設定する。
JWT は GCP ServiceAccount から生成したものを想定しているため、 JWKS URL は `https://www.googleapis.com/service_accounts/v1/metadata/jwk/{issuer}` となる。
本来 callback にはこの JWKS URL にアクセスした結果を処理して署名検証するコードを記述することになるが、 tinygo はまだ crypto/rsa をサポートしていない。上記 JWT は `alg=RS256` なので現状は実装できず、 `wasme build` が通らなくなる。

```golang
func callJwksUrl(host, iss string, callback callback) error {
    reqHeaders := [][2]string{
        {":authority", "www.googleapis.com"},
        {":method", "GET"},
        {":path", "/service_accounts/v1/metadata/jwk/" + iss},
    }
    if _, err := proxywasm.DispatchHttpCall(host, reqHeaders, "", nil, 5000, callback); err != nil {
        return err
    }
}
 ```
 
### istio 環境の構築
下記は minikube 環境が設定済みの前提で手順を記載する。

[istio 構築チュートリアル](https://istio.io/latest/docs/setup/getting-started/)のとおりに istio をインストールし、 
```
curl -L https://istio.io/downloadIstio | sh -
cd istio-1.16.1
istioctl install --set profile=demo -y
```

bookinfo のサンプルアプリをデプロイする。
```
minikube kubectl -- label namespace bookinfo istio-injection=enabled
minikube kubectl -- apply -n bookinfo -f https://raw.githubusercontent.com/istio/istio/release-1.16/samples/bookinfo/platform/kube/bookinfo.yaml
minikube kubectl -- apply -n bookinfo -f https://raw.githubusercontent.com/istio/istio/release-1.16/samples/bookinfo/networking/bookinfo-gateway.yaml
``` 
  
### 実装したものを OCI として push
istio の WasmPlugin を追加するためにはいくつか方法があるが、ここでは oci image を作成してそれを実行する方法を採用する。
そのために、[webassemblyhub](https://webassemblyhub.io/repositories/) に実装した proxy-wasm の image を push する。まずユーザ登録を行い、 `wasme login` しておく。  

下記コマンドを proxy-wasm を実装した `main.go` があるディレクトリで実行し、ビルド・tag 打ち・push する。
```shell
wasme build tinygo ./ -t webassemblyhub.io/hiroyoshii/sample:dev
wasme tag webassemblyhub.io/hiroyoshii/sample:dev webassemblyhub.io/hiroyoshii/sample:v1.0
wasme push webassemblyhub.io/hiroyoshii/sample:v1.0
```

### proxy-wasm を istio WasmPlugin を通してデプロイ
あとは WasmPlugin リソースをデプロイするだけだが、そのために下記2つの設定が必要なのでそちらの手順も記載する。
- 外部のエンドポイント(`www.googleapis.com`)を呼び出せる envoy cluster の追加
- Webassemblyhub から image を取得するための Secret 追加

#### 外部のエンドポイントを呼び出せる envoy cluster の追加
[JWKS URL にアクセスする](#access_jwks_url) にある通り、 `DispathHttpCall` で指定するホスト名は envoy における cluster 名である。
istio 環境で外部ホストを追加するには下記のような ServiceEntry を追加すれば良い。
```yaml
apiVersion: networking.istio.io/v1alpha3
kind: ServiceEntry
metadata:
name: external-svc-https
spec:
hosts:
- www.googleapis.com
location: MESH_EXTERNAL
ports:
- number: 443
    name: https
    protocol: TLS
resolution: DNS
```

これで `outbound|443||www.googleapis.com` という cluster 名が envoy proxy に追加され、 proxy-wasm から  `www.googleapis.com`へアクセス可能になる。
  
#### Webassemblyhub から image を取得するための Secret 追加
oci image を pull できるように、下記コマンドで secret 登録する。
user name と password は `wasme login` したものと同様のもので良い。
```shell
minikube kubectl -- create secret docker-registry webassemblyhub-secret --docker-server=webassemblyhub-secret --docker-username=<user name> --docker-password=<password> -n istio-system
```

#### WasmPlugin のデプロイ
下記設定で istio-ingressgateway に proxy-wasm を適用できる。  
```shell
cat << EOF | kubectl apply -f -
  apiVersion: extensions.istio.io/v1alpha1
  kind: WasmPlugin
  metadata:
    name: custom-wasm-filter
    namespace: istio-system
  spec:
    selector:
      matchLabels:
        istio: ingressgateway
    url: oci://webassemblyhub.io/hiroyoshii/sample:v1.0
    imagePullPolicy: IfNotPresent
    imagePullSecret: webassemblyhub-secret
    phase: AUTHN
    pluginConfig:
      openid_server: authn
      openid_realm: ingress
  EOF
```
  
istio ingressgateway の log を確認し、下記のようなログが出ていればOK。
```shell
$ minikube kubectl -- logs -n istio-system -l app=istio-ingressgateway -f
...
info    wasm    fetching image hiroyoshii/sample from registry webassemblyhub.io with tag v1.0
```

#### 疎通&ログ確認
これで istio ingressgateway に JWT 検証を行う proxy-wasm が適用されたため、 curl 等で JWT 設定された HTTP 呼び出しを行えばその動作を確認できる。

今回は proxy-wasm のデバッグログの確認も行いたいため、 istio ingressgateway に下記設定変更を行った。
`kubectl edit deployment` を実行し、下記 `args` 内にある `proxyLogLevel` を `debug` に設定しなおすと、ログが istio-ingressgateway に表示されるようになる。
```yaml
    containers:
    - args:
      - proxy
      - router
      - ...   
```

curl でのアクセスは istio のチュートリアル手順と同様のエンドポイントに対して、下記のように Bearer トークンとして JWT を設定して呼び出しを行い、 JWT 検証ができているか確認している。
JWT の生成は [GCP ServiceAccount の JWT 生成エンドポイント](https://cloud.google.com/iam/docs/reference/credentials/rest/v1/projects.serviceAccounts/signJwt) を叩いて実施した。
```shell
curl -H "Authorization: Bearer <JWT>" -s "http://${GATEWAY_URL}/productpage"
```

#### 詰まったポイント
調査手順としては以上だが、今回詰まったポイントとその解消方法をメモしておく。

proxy-wasm とは直接関係ない部分であるが、　前述のとおり、`DispatchHttpCall` に設定するホスト名には envoy の cluster 名を指定する必要がある。これは下記コマンドで envoy config の dump を取得することでアクセスしたい先の `cluster_name` のフィールドから正確な名前を取得できる。
```
kubectl exec <istio-ingressgateway pod name> -c istio-proxy -n istio-system -- curl localhost:15000/config_dump
```

自身で envoy yaml 設定を記述しているなら簡単にわかるが、 istio の ServiceEntry を通して登録された cluster 名が何かがわからなかったため時間がかかってしまったポイントだった。

# さいごに
元々やりたかった proxy-wasm による JWT 検証が実装しきれなかったのは残念だが、実装～デプロイまでの手順を調査して色々と学びになった。
やはり手慣れた言語で envoy の拡張を実装・テストできるのは魅力だし、 `wasme` ツールを使えば開発はかなり楽ができると思う。
今後の proxy-wasm と tinygo の発展が楽しみ。