以前片手でのキーボード入力を支援するソフトウェアについてというタイトルで、怪我などで手首などを負傷してしまったときに使える入力方法の一つとしてHalf QWERTY(とそれをソフトウェアとして実装したxhk
)を紹介しました。
ある程度はxhk
を使うことでなんとかなるのですが、それでも打ちにくいキーというのは存在します。例えばキーを複数組み合わせて押さないといけない場合です。
*1
片手にこだわらずに使えるものをという観点で探したところ、プログラム可能なフットスイッチというものがあるのをみつけました。
ベースになっているのはPCsensorのモデルのようです。
複数のモデルがあり、別のモデルであるRI-FP3BKはLinuxでの実績がすでにあるようでした。
RI-FP3MGそのものの言及はありませんが、フットスイッチそのものに設定が保持されるタイプなので、設定自体はLinuxからできなくてもいいかということで試してみました。
フットスイッチをPCに接続してlsusb
の結果を確認すると、次のIDで認識されていました。
% lsusb
(省略)
Bus 002 Device 002: ID 0c45:7404 Microdia
標準ではWindowsの設定アプリが付属しているのでそちらを使えばいいのですが、認識されたIDをもとに検索すると、次のリポジトリを見つけました。
対応しているIDのリストに0c45:7404
があったので、Linuxでも使えそうです。
footswitch
はREADME.mdに記載の次の手順でビルドできました。
% sudo apt install libhidapi-dev
% git clone https://github.com/rgerganov/footswitch.git
% cd footswitch
% make
ビルドが完了すると、footswitch
とscythe
の2つのバイナリができます。0c45:7404
に対応しているのはfootswitch
なのでそちらを使います。
-r
オプションを指定するとフットスイッチの現在の設定値を確認できます。
% sudo ./footwswitch -r
[switch 1]: a
[switch 2]: b
[switch 3]: c
出荷直後は、a
、b
、c
がそれぞれのスイッチに割りあてられていました。
スイッチ自体は3つありますが、次の2つの用途に使ってみることにしました。
上記の2つの設定を行うには、次のコマンドを実行します。
% sudo ./footswitch -1 -m shift -k space -3 -m ctrl -k t
-1 -m shift -k space
の部分が「スイッチ1が押されたら、モディファイアキーとしてShiftキーを、通常のキーとしてスペースを押した状態にする」ための指定です。
-3 -m ctrl -k t
の部分が「スイッチ3が押されたら、モディファイアキーとしてCtrlキーを、通常のキーとしてtを押した状態にする」ための指定です。
なお、上記のようにスイッチ2の設定を一緒に指定しない場合には、スイッチ2の設定がクリアされるので注意が必要です。(特定のスイッチのみ指定して設定を上書きみたいなことはできない)
footswitchはキー入力だけでなく、マウスカーソルの移動もエミュレートできるようです。(そちらは試していない)
README.mdを参照する限り、かなり細かな設定ができそうに思いますが、試した限りでは次の場合には期待通りには動作しません。
CtrlとCapsLockキーを入れ換えている場合には、-m ctrl -k t
が機能せず、tだけ打鍵したかのように振る舞います。
この問題は解決していませんが、キー割り当てをCtrlにこだわらなければ回避策があります。単純なやりかたですが、Ctrlの代わりに別のキーとの組み合わせにするなどです。
幸い、Ctrl+Tを使いたかったのはtmuxのプレフィクスとしてだったので、次のようにしてAlt+Tをfootswitchで指定することにしました。(ターミナルで作業する範囲においては、他のキー割り当てと衝突しにくい)
% sudo ./footswitch -1 -m shift -k space -3 -m alt -k t
今回は、キーボード入力の補助手段としてのフットスイッチを紹介しました。
もし、xhk
などを駆使していても追いつかない状況になったら、選択肢の一つとして試してみるのもよいかもしれません。
*1 組み合わせても問題ないキーの割り当てを工夫するのも一つの手ですが、どう割りあてるかが悩ましいです。既定の割り当てと衝突したりするためです。
Groongaを開発している須藤です。
GroongaにはRDBMS(MySQLやMariaDBやPostgreSQLなど)と同じようにテーブルとカラムがあり、それらに構造化したデータを保存します。しかし、用途が違う(Groongaは検索がメインでRDBMSは検索だけでなくデータの管理も大事)のでスキーマの設計方法はRDBMSでの設計方法と違う部分があります。そのため、RDBMSのスキーマの設計に慣れている人でもGroongaのスキーマをうまく設計できないことがあります。
この記事では少し複雑なデータをGroongaで検索するためのスキーマの設計方法を説明します。
なお、ここで使っている設計を実装したGroongaコマンドはgroonga/groonga-schema-design-exampleにあります。実際に試してみると理解が深まるはずなのでぜひ試してみてください。
まず、検索対象のデータを説明します。
検索対象は次の2つです。
それぞれのデータがもつ情報は同じものもあれば違うものもあります。
たとえば、「タイトル」は「論文」にも「書籍」にもあります。
しかし、「雑誌」は「論文」だけにあり、「書籍」にはありません。(論文が収録されている雑誌の情報です。)
また、次のような親子関係があります。「論文」はいくつも階層になった親があります。書籍は複数の親を持ちます。少し複雑なデータですね!
出版元
出版社
親カテゴリー
シリーズ
この少し複雑なデータに対して次のような検索をするためのスキーマを設計します。
Groongaでは検索対象を1つのテーブルに集めることが重要です。すごく重要です。本当に重要です。
今回のケースでは「論文」と「書籍」が検索対象なので、それらを同じテーブルに格納します。今回の設計では2つ合わせて「文献」と扱うことにし、Literature
テーブルを作成します。
Literature
テーブルの定義は次の通りです。
table_create Literature TABLE_HASH_KEY ShortText
主キーに設定する値は「論文」と「書籍」全体で一意にする必要があることに注意してください。「論文」ではISSNとなにかを使って、「書籍」ではISBNを使うと一意にできる気がします。ここでは、どうにかして一意にできる前提で設計を進めます。
Literature
でTABLE_HASH_KEY
を使っているのは、今回のケースでは主キーで完全一致検索できれば十分だからです。
「論文」と「書籍」を同じテーブルに入れるため、区別するための情報も格納します。この設計ではtype
カラムを追加し、そこに"paper"
(「論文」)または"book"
(「書籍」)を格納することにします。
type
の型はShortText
でもよいのですが、検索効率および空間効率を考慮してTypes
型にします。Types
はこの後にすぐ定義しますが、ただのテーブルです。カラムの型にテーブルを指定すると実際のデータ("paper"
と"book"
)はTypes
テーブルの主キーに入ります。カラムにはTypes
テーブルの該当レコードのID(1
と2
とか)が入ります。各カラムに入るデータは単なる数値なので比較も高速(検索効率がよい)ですし、サイズも小さい(空間効率がよい)です。
column_create Literature type COLUMN_SCALAR Types
Types
は次のように定義したテーブルです。主キーには"paper"
または"book"
を設定します。主キーは完全一致だけできれば十分なのでTABLE_HASH_KEY
にしています。
# "paper"または"book"
table_create Types TABLE_HASH_KEY ShortText
Literature
テーブルにレコードを追加したときにこのテーブルにも自動でレコードが追加されるので明示的に管理する必要はありません。type
カラムに"paper"
を格納しようとすれば自動でTypes
テーブルに主キーが"paper"
のレコードが追加されます。すでにレコードがあればそのレコードを使います。つまり、type
カラムの型にShortText
を使ったときと同じように使えます。
型にテーブルを指定する方法はGroongaではよく使う方法です。用途はいろいろありますが、この使い方はRDBMSでいうenum
型のようなものを実現するための使い方です。enum
型のように値を制限することはできませんが。。。他の用途は後ででてきます。
Literature
に「論文」と「書籍」の情報をすべて格納します。中には「論文」にしかない情報あるいは「書籍」にしかない情報も存在します。存在しない情報は該当カラムに値を設定しません。
たとえば、「子カテゴリー」情報は「書籍」にしか存在しないので「論文」用のレコードを格納するときは「子カテゴリー」情報のカラムに値を設定しません。
GroongaにはNULL
はないので、値を設定しなかったカラムの値はその型の初期値になっています。たとえば、ShortText
なら空文字列ですし、Int32
なら0
です。
今回の設計ではLiterature
テーブルには次のカラムを用意します。
type
(Types
): 種類(「論文」("paper"
)か「書籍」("book"
))title
(ShortText
): タイトルauthors
(Authors
): 著者(複数)volume
(Volumes
): 号(「論文」のみ)book_publisher
(BookPublishers
): 出版社(「書籍」のみ)child_category
(ChildCategories
): 子カテゴリー(「書籍」のみ)series
(Series
): シリーズ(「書籍」のみ)title
とauthors
は全文検索のためのカラムです。
検索項目を増やす場合は単にカラムを増やしてインデックスを追加するだけです。追加方法はauthors
を例にして後述します。全文検索用のスキーマ設計の方法もあわせて説明します。
以下のカラムはドリルダウンのためのカラムです。
volume
book_publisher
child_category
series
これらの情報で親子関係を表現します。親の親がある場合でもGroongaでは各レコードは直接の親だけを格納していれば十分です。各レコードに親の情報だけでなく、親の親の情報も格納する必要はありません。これは正規化した状態のままでよいということです。正規化した状態のままで扱えるため情報の管理が楽です。たとえば、「雑誌」の名前を変更する時は雑誌テーブルの該当レコードを変更するだけでよく、「雑誌」情報を持っているすべてのレコードを変更する必要はないということです。
ドリルダウン用のスキーマ設計は後述します。
以上が設計の概要です。ポイントは次の通りです。
著者情報を例に検索項目を追加する方法を示します。
著者は複数存在するので次のようにCOLUMN_VECTOR
で定義します。
column_create Literature authors COLUMN_VECTOR Authors
型はAuthors
テーブルにしていますがShortText
にしてもよいです。テーブルを使っている理由はtype
カラムのときと同じで検索効率および空間効率がよいからです。著者でドリルダウンするなら(今回は説明しません)テーブルにするべきです。計算効率が全然違います。
今回の設計では著者名を主キーにします。
table_create Authors TABLE_HASH_KEY ShortText
同姓同名の著者を別人として扱いたい場合は著者IDを振ってname
カラムを追加します。今回の説明ではそこは本質ではないので単に著者名を主キーにしています。
著者名で完全一致検索する場合は次のようにすれば効率よく検索できます。
select \
--table Literature \
--query 'authors:@山田太郎'
著者名で全文検索する場合は追加のインデックスが必要です。
まず、Authors._key
で全文検索するためのインデックスが必要です。
table_create Terms TABLE_PAT_KEY ShortText \
--default_tokenizer TokenNgram \
--normalizer NormalizerNFKC100
column_create Terms authors_key \
COLUMN_INDEX|WITH_POSITION Authors _key
Terms
テーブルは他の全文検索用インデックスでも共有可能です。共有するとトークン(全文検索用にテキストを分割したもの)の情報を共有でき、DB全体の空間効率がよくなります。
Terms
テーブルではTokenNgram
とNormalizerNFKC100
を使っています。他にも指定できるものはありますが、これらがバランスがよいので、まずはこれから始めるのがよいです。必要なら後で調整するとよいです。
Terms.authors_key
は全文検索用のインデックスなのでWITH_POSITION
を指定しています。
これで、著者名で全文検索して該当著者を検索できるようになります。しかし、その著者から該当「論文」を見つけることはまだできません。追加で次のインデックスが必要です。
column_create Authors literature_authors \
COLUMN_INDEX Literature authors
このインデックスはどの著者がどの「論文」の著者かを高速に検索するためのインデックスです。このインデックスも作ることで「著者名で全文検索して著者を見つけ、さらに、その著者がどの論文の著者かを検索する」を実現できます。
検索クエリーは次のようになります。完全一致検索のときとの違いはauthors:@
に._key
が加わってauthors._key:@
となっているところです。
select \
--table Literature \
--query 'authors._key:@山田'
各インデックスカラムの役割を図示すると次の通りです。
authors
は複数の著者が存在するためCOLUMN_VECTOR
を使っています。また、重複した情報が多くなるため型にテーブルを利用しました。そのため、少し複雑になっています。
title
のように単純な情報の場合は次のようにするだけで十分です。
column_create Literature title COLUMN_SCALAR ShortText
column_create Terms literature_title \
COLUMN_INDEX|WITH_POSITION Literature title
title
とauthors
を両方検索対象にするには次のようにします。
select \
--table Literature \
--match_columns 'title || authors._key' \
--query 'キーワード'
「論文」(type
が"paper"
)だけを検索する場合は次のように--filter
で条件を追加します。select
では--query
と--filter
で条件を指定できますが、--query
はユーザーからの入力をそのまま入れる用のオプションで--filter
はシステムでより詳細な条件を指定する用のオプションです。
select \
--table Literature \
--match_columns 'title || authors._key' \
--query 'キーワード' \
--filter 'type == "paper"'
参考:select
検索対象のデータには2段以上の親子関係のドリルダウンがありますが、まずは1段の親子関係のドリルダウンの実現方法について説明します。
例として次の親子関係のドリルダウンの実現方法について説明します。
効率的なドリルダウンを実現するためにテーブルを型にしたカラムを作成します。(enum
型っぽい使い方とは別の型にテーブルを使う使い方。)
今回の設計では「号」用にVolumes
テーブルを作成します。
table_create Volumes TABLE_HASH_KEY ShortText
「論文」はLiterature
テーブルなので、Literature
テーブルにvolume
カラムを作成します。型はVolumes
テーブルです。
column_create Literature volume COLUMN_SCALAR Volumes
これでvolume
カラムで効率的にドリルダウンできます。次のようにすれば、「号」でドリルダウンし、その「号」には何件の「論文」があるかを検索できます。
select \
--table Literature \
--drilldowns[volumes].keys 'volume' \
--drilldowns[volumes].output_columns '_key,_nsubrecs'
図示すると次の通りです。
次の親子関係も同様に実現できます。
続いて2段以上の親子関係のドリルダウンの実現方法について説明します。
まずは、次の2段のケースについて説明します。
その後、次の3段のケースについて説明します。
2段の場合もテーブルを型にしたカラムを作成するのは同じです。
今回の設計では「雑誌」用にMagazines
テーブルを作成します。
table_create Magazines TABLE_HASH_KEY ShortText
「号」が所属する「雑誌」を格納するカラムをVolumes
テーブルに追加します。
column_create Volumes magazine COLUMN_SCALAR Magazines
これで「号」から「雑誌」をたどることができます。
「号」と「雑誌」でドリルダウンするには次のようにします。ポイントは、.table
でvolumes
を指定しているところと、calc_target
・calc_types
です。
select \
--table Literature \
--drilldowns[volumes].keys 'volume' \
--drilldowns[volumes].output_columns '_key,_nsubrecs' \
--drilldowns[magazines].table 'volumes' \
--drilldowns[magazines].keys 'magazine' \
--drilldowns[magazines].calc_target '_nsubrecs' \
--drilldowns[magazines].calc_types 'SUM' \
--drilldowns[magazines].output_columns '_key,_sum'
--drilldowns[${LABEL}]
は高度なドリルダウンのためのパラメーターです。
このselect
では以下の2つのドリルダウンを実行します。
--drilldowns[volumes]
: 「号」でドリルダウン--drilldowns[magazines]
: 「雑誌」でドリルダウン--drilldowns[magazines].table
で他のドリルダウンの結果を指定できます。指定するとドリルダウン結果をさらにドリルダウンできます。今回のように親子関係がある場合は子のドリルダウン結果から親のドリルダウン結果を計算します。
ただ、普通にドリルダウンすると、カウントした件数は「論文」の件数ではなく、「号」の件数になります。孫(「論文」)でドリルダウンしているのではなく、子(「号」)でドリルダウンしているからです。孫(「論文」)の件数をカウントするには子(「号」)でカウントした件数をさらにカウントする。その設定が次のパラメーターです。
--drilldowns[magazines].calc_target '_nsubrecs'
--drilldowns[magazines].calc_types 'SUM'
_nsubrecs
には子(「号」)でカウントした孫(「論文」)の件数が入っています。それのSUM
(総計)を計算するので孫の件数になります。出力する時は_nsubrecs
ではなく_sum
で参照します。
--drilldowns[magazines].output_columns '_key,_sum'
図示すると次の通りです。
3段になった次のケースも同様です。
まず、出版元を効率よくドリルダウンするためにPaperPublishers
テーブルを作ります。
table_create PaperPublishers TABLE_HASH_KEY ShortText
Magazines
テーブル(「雑誌」)に出版元を格納するカラムを追加します。
column_create Magazines publisher COLUMN_SCALAR PaperPublishers
これで「雑誌」から「出版元」をたどることができます。
「号」と「雑誌」と「出版元」でドリルダウンするには次のようにします。ポイントは、「出版元」のドリルダウンのcalc_target
で_nsubrecs
ではなく_sum
を使っているところです。「出版元」のドリルダウンで「論文」の件数をカウントするには「雑誌」のドリルダウンでカウント済みの「論文」の件数の総計を計算します。そのカウント済みの「論文」の件数が_nsubrecs
ではなく_sum
にあるので_sum
を使います。
select \
--table Literature \
--drilldowns[volumes].keys 'volume' \
--drilldowns[volumes].output_columns '_key,_nsubrecs' \
--drilldowns[magazines].table 'volumes' \
--drilldowns[magazines].keys 'magazine' \
--drilldowns[magazines].calc_target '_nsubrecs' \
--drilldowns[magazines].calc_types 'SUM' \
--drilldowns[magazines].output_columns '_key,_sum' \
--drilldowns[paper_publishers].table 'magazines' \
--drilldowns[paper_publishers].keys 'publisher' \
--drilldowns[paper_publishers].calc_target '_sum' \
--drilldowns[paper_publishers].calc_types 'SUM' \
--drilldowns[paper_publishers].output_columns '_key,_sum'
図示すると次の通りです。
次のケースも同様に実現できる。
親子階層の情報を使って子のレコードを検索する方法を説明します。
ここでは、対象の「出版元」内の「雑誌」の一覧を返すケースを例にして説明します。
まず、対象の「出版元」を絞り込む必要があります。ここでは「出版元」の名前(主キーに入っています)を全文検索して絞り込むとします。
全文検索用のインデックスのテーブルはAuthors._key
用に作ったTerms
テーブルを流用します。
column_create Terms paper_publishers_key \
COLUMN_INDEX|WITH_POSITION PaperPublishers _key
これで「出版元」の名前で全文検索できます。しかし、authors
のときと同じで、「出版元」は絞り込めますが、絞り込んだ「出版元」を元に「雑誌」を絞り込むことはできません。「雑誌」も絞り込めるようにするには追加で次のインデックスが必要です。
column_create PaperPublishers magazines_publisher \
COLUMN_INDEX Magazines publisher
このインデックスは「出版元」をキーにどの「雑誌」がその「出版元」を参照しているかを高速に検索するためのインデックスです。このインデックスがあることで、絞り込んだ「出版元」を元に「雑誌」を絞り込めます。
次のようなクエリーで「出版元」の名前で全文検索し、絞り込んだ「出版元」が発行している「雑誌」を出力できます。
select \
--table Magazines \
--match_columns 'publisher._key' \
--query 'おもしろ雑誌' \
--output_columns '_key, publisher._key'
他の親子関係のケースも同様に実現できます。
Groongaで以下の機能を効率的に実現するためのスキーマ設計方法について説明しました。
今回の設計を実装したGroongaコマンドはgroonga/groonga-schema-design-exampleにあります。実際に試してみると理解が深まるはずなのでぜひ試してみてください。
クリアコードではGroongaのスキーマ設計もサポートしています。Groongaをもっと活用したい方はお問い合わせください。
クリアコードでは Gecko(Firefox)を組み込み機器向けに移植する取り組みを行っています。
この対応にあたっては OSSystems/meta-browser というYoctoレイヤをフォークして作業を行っていますが、対応が落ちついたバージョンについてはアップストリーム(OSSystems/meta-browser)に成果をフィードバックしています。例えば52ESRへのメジャーバージョンアップの対応はクリアコードの成果が元となっています。
さて、60ESRへのメジャーバージョンアップ対応についてもフィードバックをしていたのですが、最後まで自分たちでは解決できなかった問題が一つありました。今回は、OSSにはこんなフィードバックの仕方もあるのだという事例として紹介したいと思います。
52ESRから60ESRへの更新にあたって一番大きな問題となったのが、Firefox本体にRustで書かれたコードが導入された点です。通常、Firefoxをビルドする際にはrustupというコマンドでツールチェーンを導入します。クリアコードで60ESRのYoctoへのポーティングを開始した当初も、Rustのツールチェーンについてはrustupで導入したものを使用していました。
しかし、Yoctoでは一貫したクロスビルド環境を提供するために、ツールチェーン類も全てYoctoでビルドするのが基本です。このため、RustのツールチェーンについてもYoctoでビルドした物を使う方がよりYoctoらしいと言えます。調べてみたところ、既にmeta-rustというYoctoレイヤが存在していることが分かりましたので、これを利用することとしました。
meta-rustへの対応作業を進めていく中で、Firefoxのビルドシステムがターゲット環境用のlibstd-rsを発見してくれないという問題に遭遇しました。しかし、我々はRustのビルドの仕組みには不慣れであったため、それがどこの問題であるかをはっきりと切り分けることができていませんでした。一方で、meta-rustでのビルドの際に、libstd-rsのみビルド済みのものを導入することで、ひとまずこの問題を突破できることが分かりました。
その他発生していた問題については解決することができ、Firefoxのビルドが全て通るところまでは到達しました。
上記の回避策が正しい修正方法ではないということは分かっていたのですが、ではどこでどう修正をするのが正しいのかという点については、少し調査をしてみた限りでは判断がつかない状態でした。時間をかければいずれは解明できるのは間違いないのですが、我々には他にも取り組まなければいけない問題が山積していたため、その時点では、これ以上時間をかけることはできませんでした。
ひとまずOSSystems/meta-browserに対しては「こういう問題があってまだ調査中だから、マージするのはもうちょっと待ってね」という形でpull requestを投げておきました。
ところがしばらくすると、 Yocto/Open EmbeddedプロジェクトのKhem Raj氏が、OSSystems/meta-browserのメンテナであるOtavio Salvador氏に働きかけ、上記のlibstd-rsの問題が未解決であるにも関わらず、このpull requestをmasterにマージしてしまいました。上記の回避策はmeta-rustにはマージされていませんので、そのままではビルドを通すことすらできません。私の目から見るとちょっとした暴挙にも見えたのですが、状況についてはKhem Raj氏にも間違いなく伝わっているので、何か算段があるのだろうということでしばらく経過を見守ることにしました。
あとから分かったのですが、ちょうどYoctoの次のリリース向けの開発が始まるタイミングであったため、とりあえずmasterにマージしてCIに組み込んでしまってからの方が問題に取り組みやすいという事情があったようです。しばらく時間はかかりましたが、無事Khem Raj氏から問題を解決するpull requestが投げられました。
我々の方ではmeta-rustとmeta-browserのどちらで対処すべき問題なのかすら切り分けることができていませんでしたが、meta-browserで対処すべき問題だったようです。
今回の件については、以下のような懸念から、ともすればフィードバックを躊躇してしまいがちな事例ではないかと思います。
前者については、確かにプロジェクトによってはそのようなpull requestが歓迎されないこともあるでしょう。とはいえ、今回の事例についてはそこそこ工数がかかる60ESRポーティング作業を粗方済ませており、その成果が有益と感じる開発者も多いはずです。また、後者については「聞くのは一時の恥、聞かぬは一生の恥」の良い事例ではないかと思います。結果的にはアップストリームの開発者の協力を得て問題を解決することもできて、成果をより多くの人に使ってもらえる状態にできたので、中途半端でもフィードバックをしておいて良かったなと感じています。
皆さんも「この程度のものは誰の役にも立たないだろう」「この程度のものを世に出すのは恥ずかしい」などと一人で勝手に思い込まないで、手元にある成果を積極的に公開してみてはいかがでしょうか?ひょっとすると、それが世界のどこかで悩んでいる開発者の問題を解決して、感謝してもらえるかもしれませんよ。
※注記:本文末尾の「公開鍵暗号ではなく共通鍵暗号を使う理由」の説明について、2019年1月30日午前0時から21時までの間の初出時に内容の誤りがありました。また、2019年1月30日午前0時から2月5日20時頃までの間において、本文中での AES-CTR
による暗号化処理が、 nonce
を適切に指定していないために脆弱な状態となっていました。お詫びして訂正致します。初出時の内容のみをご覧になっていた方は、お手数ですが訂正後の説明を改めてご参照下さい。
クリアコードで主にMozilla製品のサポート業務に従事している、結城です。
FirefoxやThunderbirdがSSL/TLSで通信する際は、通信内容は自動的に暗号化されます。その一方で、Cookieやローカルストレージ、IndexedDBなどに保存されるデータは、平文のままでユーザーの環境に保存される事が多く、必要な場合はアプリ側で内容を暗号化しなくてはなりません。こういった場面でJavaScriptから使える暗号化・復号のための汎用APIが、Web Crypto APIです。
ただ、Web Crypto APIでデータを暗号化するにはある程度の知識が必要になります。「encrypt()
という関数に文字列と鍵を渡したらいい感じに暗号化されて、decrypt()
という関数に暗号と鍵を渡したらいい感じに復号される」というような単純な話ではないため、全く前提知識がないと、そう気軽には使えません。
この記事では、Web Crypto APIを用いたローカルデータの暗号化の基本的な手順をご紹介します。
Web Crypto APIでは様々な暗号方式を使えますが、それぞれ適切な用途が決まっています。一般的な「データの暗号化と復号」という場面では、以下のいずれかの暗号方式を使うことになります。
AES-CTR
AES-CBC
AES-GCM
RSA-OAEP
AES や RSA というのが具体的な暗号アルゴリズムの名前で、CTR、CBC、GCM というのは暗号モードの名前です。諸々の理由から、アプリ内部でローカルに保存するデータを暗号化するという場面では、AES-CTR
AES-GCM
が最適と言えるでしょう(詳細は後で述べます)。
JavaScriptで書かれた実装において、暗号化したいデータは普通は文字列や配列、オブジェクトといった形式を取っていることでしょう。しかしながら、Web Crypto APIでこれらのプリミティブな値を直接暗号化する事はできません。Web Crypto APIでは基本的に、データを ArrayBuffer
やTyped Arrayと呼ばれる、よりバイナリ形式に近いデータで取り扱います。そのため、これらのデータ型の概念をきちんと理解しておく必要があります。Blob, ArrayBuffer, Uint8Array, DataURI の変換という記事では、これらのデータ型の関係を図を交えて分かりやすく説明してくれていますので、Web Crypto APIを触る前にぜひ読んでおきましょう。
また、Web Crypto APIの多くの機能は非同期処理になっており、処理結果がPromiseとして得られる場合が多いです。Promiseとは、簡単に言えば以下のような物です。
promise.then(function(value) { ... })
のように、then
メソッドにコールバック関数を渡してその中で受け取る必要がある。then
メソッドのコールバック関数が呼ばれる、というような事もある。async function() { ... }
という風に async
キーワードを付けて定義された関数は、非同期の関数になる。
return
した値は、呼び出した側からは常にPromiseとして受け取る事になる(関数の戻り値が常にPromiseになる)。let value = await promise;
のように書くと、その場でPromiseの中の値を取り出せる(then
メソッドを使わなくてもよくなる)。以上の事を踏まえ、実際にデータを暗号化してみることにしましょう。
なお、Web Crypto APIの入出力はPromiseになっている事が多いので、以降のサンプルはすべて以下のような非同期関数の中で実行するものとします。
(async function() {
// ここで実行
})();
Web Crypto APIでの暗号化・復号では、Web Crypto APIで生成された CryptoKey
クラスのインスタンスを鍵に使います。ここでは以下の2パターンの鍵の作り方を紹介します。
パスワードで保護されたOffice文書やPDFを取り扱う時のように、暗号化や復号の時にユーザーによるパスワードの入力を求める形にしたい場合には、入力されたパスワードを CryptoKey
クラスのインスタンスに変換する操作を行います。Webブラウザで開発者用のコンソールを開いて、以下のスクリプトを実行してみると、実際に得られた AES-GCM
用の鍵がコンソールに出力されます。
// パスワードとして使う文字列。(ユーザーの入力を受け付ける)
let password = prompt('パスワードを入力して下さい'); // 例:'開けゴマ'
// 文字列をTyped Arrayに変換する。
let passwordUint8Array = (new TextEncoder()).encode(password);
// パスワードのハッシュ値を計算する。
let digest = await crypto.subtle.digest(
// ハッシュ値の計算に用いるアルゴリズム。
{ name: 'SHA-256' },
passwordUint8Array
);
// 生パスワードからのハッシュ値から、salt付きでハッシュ化するための素材を得る
let keyMaterial = await crypto.subtle.importKey(
'raw',
digest,
{ name: 'PBKDF2' },
// 鍵のエクスポートを許可するかどうかの指定。falseでエクスポートを禁止する。
false,
// 鍵の用途。ここでは、「鍵の変換に使う」と指定している。
['deriveKey']
);
// 乱数でsaltを作成する。
let salt = crypto.getRandomValues(new Uint8Array(16));
// 素材にsaltを付与して、最終的なWeb Crypto API用の鍵に変換する。
let secretKey = await crypto.subtle.deriveKey(
{
name: 'PBKDF2',
salt,
iterations: 100000, // ストレッチングの回数。
hash: 'SHA-256'
},
keyMaterial,
// アルゴリズム。
{ name: 'AES-GCM', length: 256 },
// 鍵のエクスポートを禁止する。
false,
// 鍵の用途は、「データの暗号化と復号に使う」と指定。
['encrypt', 'decrypt']
);
console.log(secretKey);
// => CryptoKey { type: "secret", extractable: false, algorithm: {…}, usages: (2) […] }
パスワードから鍵への変換に際して、より安全にするためにsaltとストレッチングを使っている事に注意して下さい*1。
この使い方においては、CryptoKey
クラスのインスタンスとして得られた鍵はメモリ上に一時的に保持しておくだけでよく、ログアウト時やアプリの終了時にはそのまま破棄する事になります。次回訪問時・ログイン時・アプリ起動時には、再びパスワードの入力を求め、その都度この方法で鍵へと変換します。
// saltの保存。
localStorage.setItem('passwordSalt',
JSON.stringify(Array.from(salt)));
// saltの復元。
let salt = localStorage.getItem('passwordSalt');
salt = Uint8Array.from(JSON.parse(salt));
ところで、上記の例では「平文のパスワード→SHA-256でハッシュ化→AES-GCMの鍵としてインポート」という経路で変換を行っていますが、「パスワードのハッシュ化にsaltは必要じゃないの?」と疑問に思う方もいらっしゃるかと思います。saltが必要かどうかは、ハッシュ化したパスワードを外部や第三者に読み取られる危険性を考慮しないといけないかどうかに依存します。ハッシュ化したパスワードや鍵を一旦ストレージに保存するのであればハッシュ化は必要ですが、この例のようにメモリ上でごく短時間保持されるだけの場合は必要ない、というのが筆者の考えです。ハッシュ化時にsaltを使うという事自体は以下のようにして実現できますが、ストレッチングにそれなりの時間がかかりますので、本当に必要という事が言える場合にのみ使う事をお薦めします。
「何らかの方法で鍵を安全な場所(暗号化したデータとは別の保存先)に保管できる」という前提がある場合には、crypto.subtle.generateKey()
を使って鍵を生成し、ユーザーにパスワードの入力を求めずに暗黙的に暗号化・復号を行うという事もできます。この場合、鍵は以下のように生成します。
let secretKey = await crypto.subtle.generateKey(
// アルゴリズムと鍵長。ここでは最長の256bitを指定している。
{ name: 'AES-GCM', length: 256 },
// 鍵のエクスポートを許可するかどうか。trueでエクスポートを許可する。
true,
// 鍵の用途。
['encrypt', 'decrypt']
);
console.log(secretKey);
// => CryptoKey { type: 'secret', extractable: true, algorithm: Object, usages: Array[2] }
生成された鍵は CryptoKey
クラスのインスタンスとなっており、そのままでは永続化(保存)できませんが、以下のようにすると、JSON Web Key形式(通常のオブジェクト)として鍵をエクスポートできます。
// JSON Web Key(JWK)形式でのエクスポート。
let exportedSecretKey = await crypto.subtle.exportKey('jwk', secretKey);
console.log(exportedSecretKey);
// => Object { alg: "A256GCM", ext: true, k: "DAvha1Scb8jTqk1KUTQlMRdffegdam0AylWRbQTOOfc", key_ops: (2) […], kty: "oct" }
このようにして得られたJWK形式の鍵は、以下の手順で再び CryptoKey
のインスタンスに戻す事ができます。
// JWK形式からのインポート。
let importedSecretKey = await crypto.subtle.importKey(
'jwk',
exportedSecretKey,
// 鍵を生成した際の暗号アルゴリズム。
{ name: 'AES-GCM' },
// 再エクスポートを許可するかどうかの指定。falseでエクスポートを禁止する。
false,
// 鍵の用途。
['encrypt', 'decrypt']
);
console.log(importedSecretKey);
// => CryptoKey { type: 'secret', extractable: true, algorithm: Object, usages: Array[2] }
実際には、初回訪問時・初回起動時などのタイミングで生成した鍵をエクスポートして安全な場所に補完しておき、次回訪問時・次回起動時などのタイミングでそれを読み取ってインポートし、メモリ上に鍵を復元する、といった要領で使う事になります。
鍵の準備ができたらいよいよ本題の暗号化ですが、その前に、「暗号化する対象のデータ」を「暗号化できるデータ形式」に変換するという操作が必要です。
Web Crypto APIの暗号化処理は、データの入出力を ArrayBuffer
やTyped Arrayの形式のみで行う仕様になっているため、どんなデータを暗号化するにせよ、何らかの方法でデータをこれらの形式に変換しなくてはなりません。文字列は 前出の例の中で行っていたように、TextEncoder
を使って以下のようにTyped Arrayに変換できます。
// データをTyped Arrayに変換。
let inputData = (new TextEncoder()).encode('暗号化したい文字列');
console.log(inputData);
// => Uint8Array(27) [ 230, 154, 151, 229, 143, 183, 229, 140, 150, 227, … ]
オブジェクト形式のデータであれば、この直前に「JSON.stringify()
で文字列に変換する」という操作を加えればよいでしょう。
入力データが用意できたら、次は初期ベクトルの準備です。
初期ベクトルとは、AES-CBC
および AES-GCM
においてデータを同じ鍵で暗号化する際に、暗号文から内容の推測を困難にするために付与する値です*2。パスワードのハッシュ化に用いるsaltのようなもの、と言えば分かりやすいでしょうか。本来は一意な値である必要がありますが、ここでは話を単純にするために、とりあえず乱数を使う事にします。実際に使う時は、きちんとした初期ベクトルを生成する手順の解説も併せて参照して下さい。
// 初期ベクトルとして、8bit * 16桁 = 128bit分の領域を確保し、乱数で埋める。
let iv = crypto.getRandomValues(new Uint8Array(16));
入力データと初期ベクトルが用意できたら、ようやく暗号化です。これは以下の要領で行います。
let encryptedArrayBuffer = await crypto.subtle.encrypt(
// 暗号アルゴリズムの指定とパラメータ。
{ name: 'AES-GCM',
iv },
// 事前に用意しておいた鍵。
secretKey,
// ArrayBufferまたはTyped Arrayに変換した入力データ。
inputData
);
console.log(encryptedArrayBuffer);
// => ArrayBuffer { byteLength: 27 }
暗号化後のデータは、非同期に ArrayBuffer
形式で返されます。ここでは27バイトの長さがあるという事だけが分かっているため、このような表示になっています。
データを暗号化できたら、今度はこれをIndexedDBや localStorage
などに格納可能な形式に変換します。例えば文字列への変換であれば、以下の手順で行えます。
let encryptedBytes = Array.from(new Uint8Array(encryptedArrayBuffer), char => String.fromCharCode(char)).join('');
console.log(encryptedBytes);
// => �����`Ù�¥ë�`û-Þm#þ'�¾��[�·�
ここでは ArrayBuffer
形式で27バイトの長さのデータとして得られた物を、8bitずつに切り分けて Unit8Array
に変換し、さらにその1バイトずつを文字コードと見なして文字に変換しています。
このような文字列はBinary Stringと呼ばれ、コンソールなどに出力しても文字化けした結果になる事がほとんどのため、データを持ち回る過程で破損してしまわないよう、取り扱いには注意が必要です。安全のためには、以下のようにしてBase64エンコード済みの文字列に変換して、文字化けなどが起こりにくい安全な状態で取り回すのがお薦めです。
let encryptedBase64String = btoa(encryptedBytes);
console.log(encryptedBase64String);
// => YPgdHZgguUeHpt9FcYy2IaZTfbTNswbfn93e
AES-GCM
で暗号化したデータ*3の復号には、暗号化時に使用した初期ベクトルが必要となります*4。以下のようにして、暗号化したデータとセットで保存しておきます。
localStorage.setItem('encryptedData',
JSON.stringify({
data: encryptedBase64String,
iv: Array.from(iv)
}));
次は、暗号化済みのデータから元のデータを取り出してみましょう。
Web Crypto APIの復号処理も、暗号化処理と同様、データの入出力を ArrayBuffer
やTyped Arrayの形式のみで行う仕様になっています。先の例と逆の操作を行い、まずは暗号化されたデータを ArrayBuffer
またはTyped Array形式に変換します。
let encryptedData = JSON.parse(localStorage.getItem('encryptedData'));
let encryptedBase64String = encryptedData.data;
// 通常のArrayとして保存しておいた初期ベクトルをUint8Arrayに戻す
let iv = Uint8Array.from(encryptedData.iv);
// Base64エンコードされた文字列→Binary String
let encryptedBytes = atob(encryptedBase64String);
// Binary String→Typed Array
let encryptedData = Uint8Array.from(encryptedBytes.split(''), char => char.charCodeAt(0));
データの準備ができたら、いよいよ復号です。これは以下の手順で行います。
let decryptedArrayBuffer = await crypto.subtle.decrypt(
// 暗号アルゴリズムの指定とパラメータ。暗号化時と同じ内容を指定する。
{ name: 'AES-GCM',
iv },
// 暗号化の際に使用した物と同じ鍵。
secretKey,
// ArrayBufferまたはTyped Arrayに変換した暗号化済みデータ。
encryptedData
);
console.log(decryptedArrayBuffer);
// => ArrayBuffer { byteLength: 27 }
復号の時には、暗号化時と同じパラメータ、同じ鍵が必要である点に注意して下さい。何らかのトラブルで鍵や初期ベクトルを喪失してしまうと、元のデータは永遠に取り出せなくなってしまいます。
復号されたデータは ArrayBuffer
形式になっています。これを通常の文字列へ変換するには以下のように操作します。
let decryptedString = (new TextDecoder()).decode(new Uint8Array(decryptedArrayBuffer));
console.log(decryptedString);
// => '暗号化したい文字列'
無事に、暗号化する前の平文であった「暗号化したい文字列」という文字列を取り出す事ができました。
AES-CTR
AES-GCM
を使うのか?冒頭で、Web Crypto APIでデータの暗号化と復号に使えるアルゴリズムは4つあると述べましたが、本記事ではその中で何故 AES-CTR
AES-GCM
を選択しているのかについて、疑問に思う人もいるでしょう。
暗号アルゴリズムには、大別して「共通鍵暗号」と「公開鍵暗号」の2種類があります。共通鍵暗号は暗号化と復号に同じ鍵を使う方式、公開鍵暗号は暗号化と復号で異なる鍵を使う方式です。公開鍵暗号は「暗号化したデータと、それを復号する鍵の両方を、信頼できない通信経路で送り、受信側で復号する」「暗号化と復号を別々の所で(別々の人が)行う」という場面に適しています。逆に言うと、「アプリ内部でローカルに保存するデータを暗号化する」という場面のように、データや鍵が信頼できない通信経路を通る事がないのであれば暗号化も復号も同じ所で行うのであれば、公開鍵暗号(RSA-OAEP
)ではなく共通鍵暗号(AES-*
系)で充分に安全だと言えます。
また、AES-*
系の中で AES-CTR
を選択する理由は、他の2つが「初期ベクトル」という、鍵とは別の使い捨ての情報を組み合わせて使う(「鍵」と「初期ベクトル」の両方が揃って初めて暗号を復号できる)方式だからです。
AES-CBC
と AES-GCM
は、1回1回の暗号化・復号ごとに初期ベクトルを変えることで安全性を高める前提の方式です。ネットワークを通じてサーバーとクライアントの間でデータをやりとりするという場面では、暗号化して送られたデータはすぐに受け手の側で復号されます。このようなケースでは、同じ暗号データを何度も復号するという事は起こり得ないため、初期ベクトルは使い捨てにできます。
一方、アプリケーションのローカルデータを暗号化するという場面では、暗号化して保存されたデータを何度も復号する必要があります。データの復号には暗号化した時と同じ初期ベクトルが必要になるため、必然的に、初期ベクトルは使い捨てにできず、何度も使い回す事になります。本当は怖いAES-GCMの話 - ぼちぼち日記などの解説記事で詳しく述べられていますが、初期ベクトルを使い回した場合これらの暗号アルゴリズムは非常に脆弱な物となり、「情報の漏洩を防ぐ」という目的を達成できなくなってしまいます。
以上の理由から、このような場面では初期ベクトルを用いない方式である AES-CTR
が最適だと言える訳です。
AES-*
系の中で AES-GCM
を選択した理由は、他の2つには以下のようなデメリットがあるからです。
AES-CTR
: カウンタの取り扱いが初期ベクトルに比べて面倒。AES-CBC
: 暗号化の並列処理ができない=暗号化処理に時間がかかる可能性がある。Webアプリなどでパスワード認証を実装する際には、パスワードをそのまま保存するのはなく、非可逆的に変換した結果であるハッシュ値を保存する事が一般に推奨されています。この時には、ハッシュ値から元のパスワードを推測しにくくするために、パスワードに余計なデータを加えてからハッシュ化するのが一般的です。この追加するデータを「塩をまぶす」ことに例えてsaltと呼びます。
これと同様にAESにおいても、データには必ずsaltのような一意なデータを付与してから暗号化するようになっています。これは仕様上秘密である必要はなく、一意でさえあればいいという事になっています。AES-CBC
および AES-GCM
では、そのようなsalt相当のデータを「初期ベクトル」と呼んでいます。
一方、AES-CTR
ではsaltにあたるデータを「カウンタ」の一部として与えます。カウンタは最大で16byte=128bitの長さを指定できますが、そのうち一部をnonce(一意な数字)、残りを0からカウントアップする文字通りのカウンタとして使う事になっています。例えば128bit中の64bitをnonce、残り64bitをカウントアップ部にする、といった要領です。……という具合に長々説明している事自体に顕れていますが、「一意な値を1つ用意するだけでいい」という初期ベクトルに比べて、カウンタは取り扱いが若干ややこしいという事自体が AES-CTR
のデメリットと言えます。
では、AES-CTR
以外なら AES-CBC
でも AES-GCM
でもどちらでも良いのでは? という話になりそうですが、AES-CBC
には処理を並列化できないという原理上の制限があります。AES-CTR
や AES-GCM
にはそのような制限が無く、上手く実装されたソフトウェアであれば、マルチスレッドを活用した並列処理で高速ができます*5。
以上の理由から、この3つの選択肢の中では総合的に見て AES-GCM
が最もメリットが大きいと筆者は考えています。
なお、本文中の例のように乱数を使って初期ベクトルを生成するのは、本来は望ましい事ではありません。実際の実装にそのまま組み込む前には、よりセキュアなきちんとした初期ベクトルを生成する手順の解説も併せてご参照下さい。
鍵を自動生成して暗黙的に暗号化・復号を行うという動作をさせたい場合、鍵は暗号化されたデータとは別の安全な場所に置く必要があります。生の鍵を暗号化されたデータと同じ場所に保存してしまっては、暗号化の意味がありません
鍵の置き場所としては、例えば以下のような例が考えられます。
こういった方法で鍵を保存しておくやり方と、都度パスワードの入力を求めるやり方のどちらが安全かについては、専門家の間でも意見が分かれているようです。鍵をデバイス上に保存しておくやり方には、当然ながら、デバイスそのものが盗難された場合にデータを保護できなくなるというリスクがあります。一方で、パスワード入力を頻繁に求める方法は、パスワードの入力の機会が増えるため、キーロガーやショルダーハッキングといった攻撃に遭うリスクが増大します。パスワードマネージャとマスターパスワードを使う方法は、両者の弱点を補うものと言えます。
以上、Web Crypto APIを使ってJavaScriptでローカルデータを暗号化する手順をご紹介しました。個人情報のようにクリティカルな情報を取り扱うWebアプリや拡張機能を開発する場合に、参考にしてみて下さい。
*1 saltというとハッシュ化した後のパスワードの流出に備えての物のように思えますが、ここでは暗号化されたデータが流出した時の保護を目的としています。ハッシュ化した後のパスワードを保存していない場合、ハッシュ化されたパスワードの流出については考えなくても良い(JavaScriptのコードから機械語への変換のされ方を制御したり、メモリ上の情報を再配置したり、といった低レベルの対策を取れないJavaScriptという言語の特性上、サイドチャネル攻撃で平文のパスワードやハッシュ化したパスワードを読み取られる事を警戒する事はアプリケーション実装者の側では不可能で、JavaScriptの実行エンジンの実装側が責任を持つことになります。また、平文のパスワードが流出しないようXSSの対策は充分に取られていることを前提とします)のですが、saltを使用していないと、攻撃者は生パスワードからのハッシュ値の計算を省略できるため、暗号化済みデータに対する攻撃がより成功しやすくなります。saltを付与すると、攻撃者はそのような最適化を行えなくなり、結果的に、暗号化されたデータのみが流出した時の攻撃に対する耐性が高まります。
*2 `AES-CBC`、`AES-GCM`だけでなく、`AES-CTR` においても暗号化時のパラメータの1つである「カウンタ」の一部としてこれに相当する情報を指定する必要があります。
*3 `AES-CBC` も同様。
*4 よって、実質的には初期ベクトルも「暗号を解く鍵」の一部と言えるかもしれません。
*5 実際にブラウザのWeb Crypto APIの実装がそのように最適化されているかどうかは、また別の話になります。