📝

google/webcryptoのjs_interop対応記

2024/03/09に公開

Flutterで暗号化や複合処理を行う場合、google/webcryptoが候補にあがります。Googleの非公式ライブラリですが、よくよくメンテナンスされている良いパッケージです。

https://pub.dev/packages/webcrypto

cryptoがDartのみで書かれたパッケージであることに対して、webcryptoはffiが使える環境ではBoringSSLを、使えないWebではCrypto APIを利用するパッケージです。甲乙の付け難いところではありますが、Flutter Webにてより良いパフォーマンスを示すことがあります。

先日リリース(24年3月6日)にリリースされたバージョン0.5.6から、dart:js_interopを利用した実装に更新されました。主に筆者がPRを出して実装を進めたので、変更の紹介と、得られた知見を紹介します。

Web Crypto APIの型定義

package:webの実装を隅々まで確認した方がいれば、「Web Crypto APIの型定義は既に存在するのでは?」と思われるかもしれません。次のクラスですね。

https://github.com/dart-lang/web/blob/v0.5.1/lib/src/dom/webcryptoapi.dart

しかし次のPRのように、型定義を独自にextension typeを行うこととしました。

https://github.com/google/webcrypto.dart/pull/86

この理由は、大きく分けると次の2つです。

JSAnyに行き着いてしまう問題

Dartでコードを書く嬉しさのひとつに、静的な型付けがあります。以前の記事で紹介したように、dart:js_interopの良い点は、JavaScript APIへのアクセス時に型付けがなされることです。
しかし、動的な型付けが行われるJavaScript APIは、そのまま変換するとあらゆる型を受け入れるためにJSAnyが多用されてしまいます。以下のコードは、package:webのwebcryptoapiのimportKeyの定義です。

https://github.com/dart-lang/web/blob/v0.5.1/lib/src/dom/webcryptoapi.dart#L252-L258

AlgorithmIdentifiertypedef AlgorithmIdentifier = JSAny;と定義されており、実質的にはJSAnyとなります。
これと比較すると、package:webcryptoのimportKeyが(JSAnyを使っていても)型付けを頑張っていることがわかると思います。

https://github.com/google/webcrypto.dart/blob/0.5.6/lib/src/crypto_subtle.dart#L132-L150

importKeyのややこしさは、バイト列としてKeyを受け取ることも、JWK(JSON Web Key)を受け取ることもできる点にあります。この2つは構造が異なるため、1つのAPIとして表現しようとすると、JSAnyを使うしかありません。
とはいえ、これらはDartからJavaScript APIにオブジェクトを渡すのが主なケースであり、JSAnyで定義してもデメリットはそこまでありません。問題はexportKeyの方です。

https://github.com/dart-lang/web/blob/v0.5.1/lib/src/dom/webcryptoapi.dart#L278-L281

https://github.com/google/webcrypto.dart/blob/0.5.6/lib/src/crypto_subtle.dart#L152-L164

exportKeyも、バイト列とJWKの2つの形式でデータを受け取ることができます。しかし、これらの戻り値をJSAnyとして定義してしまうと、実行時の型の判定を行わなければなりません。package:webではWeb Crypto APIへのアクセスを主な目的としていないので仕方がないのですが、この実装ではextension typeの良さがJavaScript APIへのアクセスのみになってしまっています。
DartのライブラリとしてWeb Crypto APIへのアクセスを行うのであれば、戻り値まで型付けを行う方が好ましいと思われます。

nullundefinedを区別してtoJSできない問題

実際にpackage:webのwebcryptoapiでWeb Crypto APIへのアクセスを試してみたことはないのですが、おそらく、いくつかのリクエストは失敗すると思われます。
というのも、現在の書き方ではundefinedなプロパティを表現できないためです。

JavaScriptの値からDartの値に変換する際には、JavaScriptのnullundefinedはDartのnullに変換されます。

https://dart.dev/interop/js-interop/js-types#null-vs-undefined

JS has both a null and an undefined value. This is in contrast with Dart, which only has null. In order to make JS values more ergonomic to use, if an interop member were to return either JS null or undefined, the compiler maps these values to Dart null.

一方で、Dartでnullを設定した場合には、JavaScriptではnullとなります。undefinedを指定することはできません。
ちょっとわかりにくいので、例を示します。例えば、次のようなクラス定義をしたとします。

extension type Foo._(JSObject _) extends JSObject {
  external factory Foo({
    String? bar,
    String? baz,
  });
}

このクラスを次のようにインスタンスにすると、

final foo = Foo(
  bar: 'bar',
);

次のようなJavaScriptオブジェクトが生成されます。

{
  bar: 'bar',
  baz: null,
}

ここで注意が必要なのが、baznullであることです。Web Crypto APIのように「さまざまなパターンを受け取る」APIを扱う場合、nullであっても、keyの定義があることが問題になることがあります。
つまり、undefinedをDartから設定できないということは、自前で不要なkeyを含まないJSONを生成する必要がある、ということになります。

https://github.com/dart-lang/sdk/issues/49353#issuecomment-1714767638

この問題は認識されているものの、Dart 3.3の時点ではアノテーションなどは提供されていません。このため、「一度Mapを作った上で、.jsify()を使ってJSONに変換する」という手法が必要になります。

https://github.com/google/webcrypto.dart/blob/0.5.6/lib/src/crypto_subtle.dart#L569-L638

DOMException

JavaScript APIにはDOMExceptionという例外があります。この例外は、APIの呼び出し時に意図しないパラメータを渡した場合、例えば先述のimportKeyに不適切なnullパラメーターを渡した場合などに発生します。

https://developer.mozilla.org/en-US/docs/Web/API/DOMException

dart:js_interopを介してJavaScript APIを利用している最中に例外が発生すると、処理の中でJSObjectthrowされることになります。
簡単に動作を確認しておくと、try-tatchでは任意のクラスをキャッチすることができます。

void main() {
  try {
    throw const ObjectException();
  } on ObjectException catch (e) {
    print(e.toString());
  } on Exception catch (e) {
    print(e.toString());
  }
}

class ObjectException {
  const ObjectException();

  
  String toString() => 'This is ObjectException!!!';
}

このためDOMExceptionをキャッチするのであれば、JSObjectを継承したクラスを作成する必要があります。この時、extension typeを使うことで、簡単にDOMExceptionを定義することができます。

https://github.com/google/webcrypto.dart/blob/0.5.6/lib/src/crypto_subtle.dart#L214-L218

https://github.com/google/webcrypto.dart/blob/0.5.6/lib/src/impl_js/impl_js.utils.dart#L106-L118

この動きは、一見直感に反する(Exceptionをimplementsしていないクラスをキャッチするなど)があるのですが、慣れてくると良くできた設計だと感じます。
もしもJavaScriptのライブラリをDartから呼び出す際、例外をチェックする必要がある場合には、参考にしてもらえればと。

Browswer APIのテスト

Web Crypto APIはブラウザのAPIであるため、テストはブラウザごとに実行する必要があります。筆者も今回実装するまで知らなかったのですが、testパッケージには、実行するブラウザを指定するオプションがあります。

https://pub.dev/packages/test#restricting-tests-to-certain-platforms

「このファイルは、Webブラウザでのみ実行する」テストの場合には、@TestOn('browser')を使います。もしもChromeでのみ実行する場合には@TestOn('chrome')を、Firefoxでのみ実行する場合には@TestOn('firefox')を指定する感じです。
ライブラリのドキュメントを読むと、nodeposixなども指定できるようです。使うかどうかはわかりませんが、興味深いですね。

このオプションは、testgroupの引数にも指定できます。
ChromeとFirefoxでは成功するものの、Safariでは失敗するテストがある場合には、次のような記述をすることでテストを行うことができます。

https://github.com/google/webcrypto.dart/blob/0.5.6/test/crypto_subtle_test.dart#L221-L291

もしかすると、Tagsを使うことで、より柔軟にテストをすることができるかもしれません。dart_test.yamlによって、テスト行うプラットフォームごとにタイムアウト時間を調整できるなどもあるようなので、うまく動かない場合には試してみるといいかもしれません。一例として、24年3月現在ではmacOS上でFirefox 121以降のテストが動かないのですが、dart_test.yamlで正しいパスを指定することで動作させることができます。

https://github.com/dart-lang/test/pull/2195#issuecomment-1980063039

GitHubで編集を提案

Discussion