web-serial-api-ja

Serial API 手引き書

このドキュメントは、 Serial API の説明です。これは、Webページがシリアルデバイスと通信できるようにするために提案された仕様です。

目的

ユーザーは、特に教育、趣味、産業分野では、周辺機器をコンピューターに接続し、制御にカスタムソフトウェアを必要とします。 たとえば、ロボット工学は、学校でコンピュータプログラミングや電子工学を教えるためによく使用されます。 これには、コードをロボットにアップロードしたり、リモートで制御したりできるソフトウェアが必要です。 産業または愛好家の設定では、ミル、レーザーカッター、3Dプリンターなどの機器は、接続されたコンピューターで実行されているプログラムによって制御されます。 これらのデバイスは、多くの場合、シリアル接続を介して小さなマイクロコントローラーによって制御されます。

この制御ソフトが Web 技術を用いて構築されている例は数多くあります。例えば

場合によっては、これらの Web サイトは、ユーザーが手動でインストールしたネイティブエージェントアプリケーションを介してデバイスと通信します。 他の方法としては、アプリケーションは、Electronなどのフレームワークを介してパッケージ化されたネイティブアプリケーションで提供されます。 またその他のケースでは、ユーザーは、コンパイルされたアプリケーションを USB フラッシュドライブを介してデバイスにコピーするなど、更に追加の手順を実行する必要があります。

これらすべての場合において、サイトとそれが制御しているデバイスとの間に直接通信を提供することにより、ユーザーエクスペリエンスが向上します。 ネイティブコンポーネントのインストールが必要なサイトの場合、これにより、タスクを実行するためにサイト作成者に付与される強力な機能の範囲が制限されるため、ユーザーのセキュリティとプライバシーも向上します。

なぜWebBluetoothまたはWebUSBではないのですか?

WebBluetooth および WebUSB API は、Bluetooth LowEnergyおよびUSB周辺機器への低レベルのアクセスを提供します。 シリアルインターフェースを備えたほとんどのデバイスが Bluetooth または USB であるなら、なぜ別の API が必要なのでしょうか?

潜在性に関する API

サイトがシリアルデバイスに接続する前に、アクセスを要求する必要があります。サイトがすべての潜在的なデバイスのサブセットとの通信をサポートしている場合は、USB ベンダー ID のような特定のプロパティに一致するデバイスに選択可能なデバイスのセットを制限するフィルタを提供することができます。

const filter = {
  usbVendorId: 0x2341 // Arduino SA
};

try {
  const port = await navigator.serial.requestPort({filters: [filter]});
  // Continue connecting to |port|.
} catch (e) {
  // Permission to access a device was denied implicitly or explicitly by the user.
}

SerialPort インスタンスにアクセスする事で、サイトはポートへの接続を開くことができます。 open() のほとんどのパラメータはオプションですが、適切なデフォルトがないためボーレートは必要になります。 開発者のあなたは、デバイスが通信する際に期待する速度を知っている必要があります。

await port.open({ baudRate: /* pick your baud rate */ });

この時点で、readable 属性と writable 属性には、接続されたデバイスとの間でデータを送受信するために使用できる ReadableStreamWritableStream が設定されます。

この例では、 Hayes コマンドセット に似たプロトコルを実装するデバイスを想定しています。 コマンドは ASCII でエンコードされているため、 TextEncoderTextDecoder を使用して SerialPort のストリームで使用される Uint8Array を文字列との間で変換します。

const encoder = new TextEncoder();
const writer = port.writable.getWriter();
writer.write(encoder.encode("AT"));

const decoder = new TextDecoder();
const reader = port.readable.getReader();
const { value, done } = await reader.read();
console.log(decoder.decode(value));
// Expected output: OK

ポートを閉じる前に、読み取り可能および書き込み可能なストリームのロックを解除する必要があります。

writer.releaseLock();
reader.releaseLock();
await port.close();

ストリームから単一のチャンクを読み取るのではなく、次のようにループを使用して継続的に読み取るコードがよく使われます。

const reader = port.readable.getReader();
while (true) {
  const { value, done } = await reader.read();
  if (done) {
    // |reader| has been canceled.
    break;
  }
  // Do something with |value|...
}
reader.releaseLock();

この場合、 port.readable はストリームがエラーに遭遇するまでロックが解除されませんので、ポートを閉じるにはどうすればいいのでしょうか? readercancel() を呼び出すと、 read() で返された Promise{ value: undefined, done: true } で即座に解決されます。これにより、上記のコードがループから抜け出してストリームのロックを解除し、ポートを閉じることができるようになります。

await reader.cancel();
await port.close();

シリアルポートは、バッファオーバーフロー、フレーミングエラー、パリティエラーなどの条件で、致命的ではない読み込みエラーを生成することがあります。これらのエラーは read() メソッドの例外としてスローされ、 ReadableStream がエラーになる原因となります。エラーが致命的でない場合は、 port.readable はエラーの直後に新しい ReadableStream に置き換えられます。上の例を拡張してこれらのエラーを処理するために、別のループを追加しました。

while (port.readable) {
  const reader = port.readable.getReader();
  while (true) {
    let value, done;
    try {
      ({ value, done } = await reader.read());
    } catch (error) {
      // Handle |error|...
      break;
    }
    if (done) {
      // |reader| has been canceled.
      break;
    }
    // Do something with |value|...
  }
  reader.releaseLock();
}

USBデバイスが取り外されるなどの致命的なエラーが発生した場合、 port.readablenull に設定されます。

先ほどの例に戻りますが、常に ASCII テキストを生成するデバイスの場合、 transformStream を使用することで encode()decode() の明示的な呼び出しを削除することができます。この例では、 writerTextEncoderStream から、readerTextDecoderStream から作成します。 pipeTo() メソッドは、これらの変換をポートに接続するために使用されます。

const encoder = new TextEncoderStream();
const writableStreamClosed = encoder.readable.pipeTo(port.writable);
const writer = encoder.writable.getWriter();
writer.write("AT");

const decoder = new TextDecoderStream();
const readableStreamClosed = port.readable.pipeTo(decoder.writable);
const reader = decoder.readable.getReader();
const { value, done } = await reader.read();
console.log(value);
// Expected output: OK

トランスフォームストリームを経由してパイプを行う場合、ポートを閉じることはより複雑になります。 readerwriter を閉じると、エラーがトランスフォームストリームを通って下層にあるポートに伝搬してしまいます。しかし、この伝搬はすぐには起こりません。 port.readableport.writable がアンロックされたことを検出するために、新しい writableStreamClosedreadableStreamClosed のプロミスが必要になります。 reader をキャンセルするとストリームが中断されるので、結果として発生するエラーをキャッチして無視しなければなりません。

writer.close();
await writableStreamClosed;
reader.cancel();
await readableStreamClosed.catch(reason => {});
await port.close();

シリアルポートには、デバイス検出とフロー制御のための追加信号が多数含まれており、これらは明示的に問い合わせて設定することができます。例えば、 Arduino のようないくつかのデバイスは、 Data Terminal Ready (DTR) 信号がトグルされるとプログラミングモードになります。

await port.setSignal({ dataTerminalReady: false });
await new Promise(resolve => setTimeout(200, resolve));
await port.setSignal({ dataTerminalReady: true });

シリアルポートが USB デバイスによって供給されている場合、そのデバイスはシステムに接続されたり切断されたりします。サイトがポートへのアクセス許可を得ると、これらのイベントを受信し、現在アクセスしている接続デバイスのセットを照会することができます。

// Check to see what ports are available when the page loads.
document.addEventListener('DOMContentLoaded', async () => {
  let ports = await navigator.serial.getPorts();
  // Populate the UI with options for the user to select or automatically
  // connect to devices.
});

navigator.serial.addEventListener('connect', e => {
  // Add |e.target| to the UI or automatically connect.
});

navigator.serial.addEventListener('disconnect', e => {
  // Remove |e.target| from the UI. If the device was open the disconnection can
  // also be observed as a stream error.
});

注:Chrome 89より前のバージョンでは、 connect および disconnect イベントでは、 navigator.serial でカスタム SerialConnectionEvent オブジェクトが発行され、影響を受ける SerialPort インターフェイスが port 属性として使用可能でした。Chrome 89 以降では、一般的な Event オブジェクトが SerialPort インターフェイス自体で発行されます。 イベントリスナーは、これらのイベントが SerialPort インターフェイスから Serial インターフェイスにバブリングされるため、 navigator.serial にイベントリスナーを登録したままでも、イベントリスナーを登録することができます。以前のバージョンとの互換性を保つために、 “e.port || e.target” という式を使用して port 属性(存在する場合)または target 属性(存在しない場合)のいずれかを取得できます。

セキュリティに関する考察

このAPIは、 Web Bluetooth および WebUSB API と同様のセキュリティリスクをもたらすため、これらの教訓をここに適用できます。 主な脅威は次のとおりです:

基本的な緩和策は、一度にひとつのデバイスのみへのアクセスを許可するパーミッションモデルです。 requestPort() の呼び出しによって表示されるプロンプトに対して、ユーザは特定のデバイスを選択するために能動的に手順を踏まなければなりません。これにより、接続されたデバイスに対するドライブバイ攻撃を防ぐことができます。実装では、ページがデバイスと通信中であることをユーザに視覚的に示し、いつでもその許可を取り消すことができるようにすることもできます。

またユーザーエージェントは、ネットワーク経由の攻撃者による悪意のあるコードの注入を防ぐために、ページがセキュアな発信元から提供されることを要求しなければなりません。コードの安全な配信はそのコードが信頼できることを示すものではありませんが、サイトのオリジンに基づいて行われる他のセキュリティ上の判断が意味を持つことを保証するための最低限の要件です。また、ユーザーエージェントは、Feature Policyを通じて埋め込みページから明示的に許可されていない限り、クロスオリジンの iframe が API を使用することを防止しなければなりません。これは、信頼されたサイト自体が侵害されない限り、ほとんどの悪意のあるコードインジェクション攻撃を緩和します。

残る懸念は、悪意のあるサイトからデバイスへのアクセスを許可するようにユーザーを誘導するフィッシング攻撃を介して、接続されたデバイスを悪用することです。これらの攻撃は、デバイスが常時接続されているホストコンピュータへの信頼を悪用し、デバイスの本来の機能を悪用したり、デバイスに悪意のあるファームウェアをインストールしてホストコンピュータを攻撃したりするために使用されます。ページからデバイスに送られるデータの意味はユーザーエージェントには不透明なので、このタイプの攻撃を完全に防ぐメカニズムはありません。特定のタイプのデータが送信されるのをブロックしようとする試みは、それでもなおそのタイプのデータを自分のデバイスに送信したいデバイスメーカー側の回避策によって満たされるでしょう。

ユーザーエージェントは、潜在的なフィッシング攻撃を軽減するために追加で設定を実装することができます。

この最終的な軽減策は、この API に適用する事がより困難です。これにはいくつかの理由があります。第一に “exploitable” とは何を意味するのかを定義するのが難しいことです。例えば、この API によって Arduino ボードにファームウェアをアップロードするページが可能になります。これらのデバイスは教育市場やホビー市場では一般的であるため、実際にはこの API の主要なユースケースとなっています。これらのボードはファームウェアの署名検証を実装していないため、簡単に悪意のあるデバイスに変えられてしまう可能性があります。これらはブロックされるべきでしょうか? いいえ、 Arduino ユーザーはこのリスクを受け入れなければなりません。

また USB や Bluetooth デバイスとは異なり、 DB-25 、 DE-9 、 RJ-45 コネクタを介してホストに直接接続され、ハンドシェイクも行われない場合や、汎用の USB シリアルや Bluetooth シリアルのアダプタを介してホストに接続されている場合もあるため、シリアルデバイスの正体を知ることは困難です。

プライバシーに関する考察

シリアルデバイスには 2 種類の機密情報が含まれています。

  1. デバイスが USB または Bluetooth デバイスの場合、シリアル番号や MAC アドレスの他にベンダー ID やプロダクト ID ( メーカーやモデルを識別するもの ) などの識別子があります。
  2. シリアルポートを介して送信されるコマンドによって更に識別子が利用可能であるかも知れません。またデバイスは、プライベートなものとみなされているかいないかに関わらず他の個人情報を保存する場合もあります。

悪意のあるファームウェアでデバイスがプログラムされるのを防ぐという “セキュリティに関する考察” のセクションで述べたのと同じ理由から、一度アクセスを許可されたページがこの情報にアクセスするのを防ぐことは非現実的です。その代わりに、パーミッションモデルは、ページが最初の場所でどのデバイスにアクセスできるかを正確に制御することができます。ページは選択可能なデバイスを積極的に列挙することはできません。これはファイル選択の UI に似ています。サイトはファイルシステムに任意にアクセスすることはできません。ファイルが選択されると、サイトは完全なファイルにアクセスすることができます。また、ユーザーエージェントは、あるページがこれらのパーミッションを使用しているときに、何らかのインジケータを使ってリアルタイムでユーザーに通知することができます。