うなすけとあれこれ

2021年12月10日

OSSへの要望をカジュアルに提案することについて

itamae wiki changelog

アンサーブログ

これは Ruby についてカジュアルに議論できる場が欲しい - 虚無庵 というブログのアンサー的なものです。

上記ブログ記事で、甚六さんが

そもそもで”本当にカジュアルに話題を振って良いのか?”どうか分かっていない

とおっしゃられてますが、僕は良いと思います。そう思う理由を以下に続けます。

書いている人間の立場

僕はItamaeというRuby gemのメンテナ1です。Itamaeは構成管理ツールであり、同様のものはChefやAnsibleが挙げられます。

2018年からメンテナの一員となり、issue/pull requestへの対応、新しいバージョンのリリースやSlackでのコミュニケーションをやっていってます。

Itamaeのメンテナになりました | うなすけとあれこれ

もちろんRubyとは関わっているメンバーやコードベースの規模が大きく異りますが、Rubyと同様にOSSであり、僕はそのメンテナなのでこのテーマについてメンテナ側の立場で発言する資格はあると思っています。

要するに、まず結論

OSSのメンテナが「やりたい」と思っていることがあり、それについて外部から「やってほしい」や「手伝います」という声が上がってくれば優先度が上がる、というだけのことなんじゃないかと思います。

もちろん、「やってほしい」という要望は、そのOSS project固有のやりかたに則って上げる必要があるでしょう。これについてはsongmuさんが以前こんなことをおっしゃっていました。

OSSも人間の活動だからプロジェクト毎に個性がある。
なのでOSSのお作法、みたいなものに捉われないで、それぞれのプロジェクトとどの様に関わればうまくいくのかを、個別に向き合っていく必要がある。
世知辛いかも知れませんが、僕達の苦手な人付き合いと同じなんですよね。

— songmu (@songmu) November 16, 2021

そのプロジェクトのやりかたに則してさえいれば、あとは度胸、やるだけなんじゃないかなと。この場合は、まずRuby-dev office hourで話題に挙げる、でしょうか。

なぜそう思うのか、実際の事例

そもそも皆さんはItamaeをご存知でしょうか。最近はもっぱらコンテナ型仮想化技術が人気になったのもあり、インスタンスのprovisioningという行為を行うことは減ってきているのではないかと思います。しかし不要になったわけではありません。最低限Dockerが動くmachine imageを作ったり、自分の使うPCの環境構築のためなどで構成管理ツールは現役です。

現在、構成管理ツールとして主流なのはAnshibleかと思いますが、Itamaeも便利です。特にRubyに慣れた人にとっては使いやすい2でしょう。

しかし知名度やユーザーはAnsibleと比較すると今一つ、というかとても少ないのではないかと言わざるをえません。理由の一つとして、「ドキュメントが少ない」のではないかという仮説を僕は持っています。そうでなくても、Itamaeのドキュメントは公式GitHub reopositoryにwikiがあるくらいで、メンテナが言うのもなんですが、積極的にメンテナンスされているとは言い難いです。GitHub wikiなので、Pull Requestを受け付けることもできません。

https://github.com/itamae-kitchen/itamae/wiki

まとまった記事としても、作者のryotaraiさんによるGihyoでの連載があるくらいで、これも2015年に書かれたものですし、日本語の資料しかありません。内容が陳腐化したということはありませんが、公式でもっとリソースを用意できたらなというのはずっと考えていました。

Itamaeが構成管理を仕込みます! ~新進気鋭の国産・構成管理ツール~:連載|gihyo.jp … 技術評論社

そうやってやりたい気持ちは抱えているだけで、他のやりたいことに手一杯で全然手を動かすことができていませんでした。そこに、海外の方から声がかかりました。「GitHub wikiにこのコードサンプルを追加してほしい」「英語ドキュメントを充実させたい、その手伝いがしたいが、何かできることはないですか?」と。

言うなれば、僕が抱えていた課題感は、これまでは僕しか課題に思っていないものでした。しかし利用者からの要望があるということで、それに取り組む価値があるということがわかったのです。

今回甚六さんが課題に思っているのはRubyのパーサーについての保守性のことです。

個人的に Ruby parser についてまとめてみた - 虚無庵

この問題についても同じようなことが言えるのではないかと思っています。何とかしなきゃいけないとは思っているけど、本当に困っている人が居るかどうかわからない。なので優先度を上げる理由がない。手を動かしてくれる人がいるのであれば……

メンテナ側としての意見

ユーザーからの意見や要望というのは、基本的にはありがたいものです。機能要望、bug fixなどをどのように連絡してほしいかというのは、projectのやりかたに従っているのであれば怒られるものではないはずですし、project側はやりかたを明記しておくべきでしょう。

Blog post: オープンソースのガバナンスhttps://t.co/kncUgwvRPR

— Kohsuke Kawaguchi (@kohsukekawa) November 18, 2021

また、以下の記事に言及して過度に忖度、萎縮されているように感じました。

「issueを立てるな!」 - Qiita

この記事はタイトルこそ「issueを立てるな」であり、OSSはissueひとつで崩壊しかねない、儚いものだと述べています。ただ、この記事で問題としているのは、正当な手順を踏まないissueの作成であり、このコンテキストにおいてはあてはまらないと考えます。ちゃんとメンテナの方々の労力を思いやることができ、projectのやりかたを踏まえて要望を挙げるのであれば、何も萎縮することはないと思います。

ただ、何度も同じ話題が出てきては立ち消えてしまうのであれば、それは別の問題がありそう3なので一旦立ち止まるといいのかもしれません。

今回の話であれば、まずRuby-dev office hourで話題に挙げるというのは全然アリなんじゃないかと思います。中の人でもなんでもないですが、一度参加した感じだとそういう話題が忌避されることはないでしょう。

また、卜部さんのSlackタバコ部屋問題については、そこで決めるから問題になるのであって、何が問題なのかふわふわした状態でticketを切るより、一旦closedな場でもいいので、議論となる点をまとめてから公開の場で話題に挙げる、というのは全然問題ないと考えます。

コントリビューターとしての意見

Rubyほどの大きなプロジェクトに対して意見を提案するのは気が引けるというのはとてもよくわかります。とはいえ、これはもうやるかやらないかなので、「やるぞ」という気持ちを作るしかないと思います。

口頭で Ruby のことを話さなければならないので自分にはハードルが高い

についてですが、hackmdに事前に内容を書いておけること、以前の記事がとてもよくまとまっていることから、話す内容、想定質問については準備しておいて臨めばいいのではないかと思います。研究発表の場でもないので、考えが甘いことで詰められることもないでしょうし。

英語については、僕も同意見です。自信は全くないです。しかし、OSSをやっていく上で必用な英語のスキルというのは、読みと書きの比重がとても大きいです。つまりこの2つが最低限なんとかなればいいので、あとは機械翻訳に頼ればいいと思います。ただ、機械翻訳の結果の英文がおかしなことになっていないかを判断するための最低限の英語力は必用だと思うので、それは勉強しかないでしょう。僕はDuolingoをやっています。OSS活動の際に書く英文ですが、「これDuolingoでやった文型だな」と感じることはそれなりにあります。

まとめ

今のRubyコミュニティで、Ruby-dev office hour及びruby-jp slack以上にカジュアルに議論できる場はなく4、今後もできないと思います。なので、あとは、がんばってください!!

(ところで例えばPEGに置き換えるとして、PEGでRubyの文法って表現できるんですか?)


  1. 作者ではないです。作者はryotaraiさんで、メンテナは僕の他にsue445さんがいらっしゃいます。 

  2. 実は僕はAnsibleを使ったことがないので何とも言えない 

  3. 問題に思っている人は居るが、差し迫った状況ではない、危機感が薄いなど……? 

  4. 他にもRubyKaigiがありますが、「カジュアル」なのはリアル開催の廊下が近いと思っていて、現在のtakeoutのスタイルだとちょっと難しいかもしれないですね 

2021年12月10日
2021年11月30日

DeepLのデスクトップアプリをRustとPreactとTailwind CSSでつくった

app screenshot

これはなに

DeepLのAPI keyを使って翻訳を行う、DeepLが公式に提供しているデスクトップアプリのようなものの個人開発版です。

UI部分にPreactとTailwind CSS (Tailwind UI)、アプリケーションの土台やAPIとの通信部分にはRust (Tauri)を使っています。

名前は、DeepLのアプリなので、 ^d.*p.*l.*$ にマッチする英単語から適当に選んで “deplore” としました。

動機

英語は英語のまま理解できるのがもちろん一番いいのですが、長すぎる英文の概要だけでもサッとつかみたい場合などは機械翻訳は非常に役立ちます。

近年、機械翻訳ではDeepLの精度がとても素晴らしく、僕もPro planを契約して日常的に使っていました。しかし、DeepLの個人向けライセンスは、一度に1端末からしかアクセスできないという制限があります。

個人向けのライセンスでは、1名のみDeepL Proをご利用いただけます。この1名は、複数のデバイスからDeepL Proにアクセスできますが、一度に1つのデバイスからのみアクセスできます。

個人向けプランとチーム向けプランの違い – DeepLヘルプセンター

チーム向けのプランにすると料金は2名からとなってしまい、価格が倍となってしまいます。これはつらいです。

複数端末でDeepLを使っているとこの制限にとても頻繁にひっかかり、フラストレーションが溜まります。一時期、ログインセッションが切れる度にツイートしていました。

from:yu_suke1994 deepl session - Twitter検索 / Twitter

さすがにしんどくなったので、API planを契約して同じようなことができるデスクトップアプリを作ることにしました。

Why tauri, what’s tauri

アプリケーションの作成にはTauriを使うことにしました。

Build smaller, faster, and more secure desktop applications with a web frontend | Tauri Studio

Tauriは、Rust製のデスクトップアプリケーションフレームワークで、UIの部分にはWeb技術を使用することができます。

当初はElectronでサッと作れればいいかな?と思いましたが、以下のような理由からTauri(Rust)を採用することにしました。

Tauriは、OSのwebviewを使用するため、Blinkを同梱するElectronよりバンドルサイズを削減することができます。

もっともその分、WebKitやBlinkでの挙動の違いを考慮する必用はありますが、EdgeがBlinkベースになったのでそこまで大変ということもないと判断しました。

あと、Rustを書いてみたかった1、という理由もあります。

その他技術選定

フロントエンド部分については、Preactを選択しました。Tauriのフットプリントの軽さを活かしたかったのと、Preactで困ることがないだろうという理由です。

https://preactjs.com/

状態管理に関しては、新しめであり、使ってみたかったという理由でRecoilを選択しました。

https://recoiljs.org/

デザインに関しては、Tailwind CSS、と言うよりTailwind UIを選択(購入)しました。Tailwind CSSを使ってみたかったのと、出来合いのコンポーネントの質が非常に良いからです。

また、bundlingに関してはその速さが界隈で話題なのもあり、Viteを選択しました。

https://vitejs.dev/

ちなみにこれらは、 yarn create tauri-app から create-vite → preact-ts という選択をすることで簡単に導入することができました。

苦労部分

とにかくRustの並行処理、所有権のあたりが壊滅的にわかりませんでした。rust-analyzerとGitHub Copilotにおんぶにだっこ状態でコードを書いていました。"The Rust Programming Language" には現在主流となっている(?)Futureについての入門的記載が見当らず、またTauriのdocumentやRustのdocumentの土地勘がなく、reqwestを用いた非同期なAPI requestを行うまでにとても苦労しました。

開発に、大体1ヶ月くらいはかかりました。Rustの記事をとにかく読み漁っていました。

コントリビューション歓迎

RustもReactも習熟しているとは言い難いので、ある程度の経験がある方から見るとなんでこんなことになってるんだ!!なコードだらけかと思います。またエラー処理や、APIのレスポンス待ちにspinnerを出したりなど足りてない機能が沢山あります。皆さんのコントリビューションをお待ちしています!!!!!


  1. Railsを主戦場としている自分が今後学ぶべき技術について(随筆) | うなすけとあれこれ 

2021年11月30日
2021年10月31日

BrainuxとKubeEdgeを組み合わせることはできるのか Part 1

はい

はじめに

これは 2021 年 10 月 31 日に開催された Brain Hackers Meetup #1 での発表内容を再構成したものです。

僕のスキルと Brain Hackers コミュニティ

Brain Hackers は、SHARP の電子辞書 “Brain” シリーズの改造や、ソフトウェアの開発に興味がある人々のためのコミュニティです。 https://github.com/brain-hackers/README

とあるように、ハードウェアの改造がメインの目的となるコミュニティです。僕も参加しているのですが、僕はあまりその方面の知識がありません。というので、僕にできることで何か面白そうなことができないかというのを考えていました。

Brainux は IoT デバイスになれるのか?

ある日、「Brainux で IoT っぽいことってできないのかな」と思ったことが始まりでした。日々追い掛けている Kubernetes 関係の情報で、 KubeEdge というものがあったなということも思い出し、これと Brainux を組み合わせられないかというのを思いつきました。

KubeEdge について

KubeEdge の詳細な説明はしませんが、とにかく Edge device を Kubernetes cluster に参加させることができるもののようです。これを Brainux に導入できたら何か面白いことができるのではないかと思いました。

KubeEdge を setup する

KubeEdge をセットアップするための方法は、 https://kubeedge.io/en/docs/setup/keadm/ に記載があります。これをやっていきます。

さて、手順に “Setup Cloud Side (KubeEdge Master Node)” という項目があります。ここからわかるように、KubeEdge の master node となるインスタンスで何か作業を行う必要があり、となると Master node に入って作業をるする必要がある、すなわち Master node を操作できるようになっていなければなりません。

よって、GKE や EKS といった Managed Kubernetes service には導入できず、自分で Kubernetes cluster を構築する必要がありそうです。

kubeadm で Kubernetes cluster を構築する

早速 kubeadm を使用して、GCP 上に Kubernetes cluster を構築していきます。

GCP 3 nodes

一点注意が必要なのは、最新の KubeEdge の Release1 は、現時点で素直に kubeadm を apt から install して入る Kubernetes の version 1.22 系ではうまく動きません。そのため、 sudo apt install -y kubelet=1.21.1-00 kubeadm=1.21.1-00 kubectl=1.21.1-00 などで 1.21 系をインストール、構築する必要があります。

さて、これで Kubernetes cluster を構築することができました(ということにします。詳細については触れません)。

keadm init を実行する

https://kubeedge.io/en/docs/setup/keadm/#setup-cloud-side-kubeedge-master-node

さて、 Kubernetes cluster ができあがったので、 keadm を実行して KubeEdge を構築します。

keadm init done

ここまでで Cloud Side での setup が完了しました。

Brainux 上で keadm join を実行する

ここから、 Edge Side での setup を行っていきます。適当に arm 向けのバイナリを実行すると、以下のように “Illegal instruction” というエラーになってしまいます。

illegal instruction

これは、いわゆる現代において何も考えずに Go で Arm 向けのバイナリをビルドすると、それは ARMv7 向けのバイナリとしてビルドされます。ですが、現時点で僕が Braniux を動かしている PW-SH2 は ARMv5 であり、つまり動きません。

https://github.com/golang/go/wiki/GoArm

KubeEdge をビルドする

なので、手元で git clone してビルドしようと思ったのですが…… clone 中に電池が切れてしまい、その後起動しなくなってしまいました。

私のBrainuxの起動シーケンスはここで止まっています #dicthack pic.twitter.com/ArWMJjNlQX

— うなすけ (@yu_suke1994) October 31, 2021

Part 2 について

未定です。


  1. master branch では動くっぽい 

2021年10月31日
2021年10月24日

Kaigi on Rails 2021 の運営スタッフをしました

OBS

Kaigi on Rails 2021 おつかれさまでした

Kaigi on Rails 2021 に参加していただいた皆様、素晴らしい発表をしてくださった発表者の皆様、そして Proposal を提出してくださった皆様、協賛していただいたスポンサーの皆様、本当にありがとうございます。今年も Kaigi on Rails を開催することができました。

まだまだ残っている作業は多いのですが、ひとまず本編終了ということで振り返りを書こうと思います。

担当範囲

大体昨年と同じような範囲を担当しました。

Kaigi on Rails STAY HOME Edition 配信の裏側

配信については昨年と少し変わって、OBS から配信するマシンと Zoom に入場して映像・音声を拾うマシンを完全に別にしました。こうすることによって Slack など他アプリの音がまちがって入ることのない体制を組むことができました。

昨年から cfp-app は用意したかったのですが、様々な事情1により導入は今年からとなってしまいました。また、cfp-app はやっぱり便利だけど、これまたとある事情1により今年は使いこなすことができませんでした。来年に向けてやっていく気持ちがあります。

オンラインカンファレンスと “手応え”

終わってみれば Doorkeeper の参加者登録は 700 人超というすごい数字になりました。YouTube の配信も同時視聴者数が 200 人超と、とても多くの方々に参加していただけたと思います。

しかし今回、準備から開催終了まで、スタッフは一切リアルで集合することはありませんでした。

また、実際に会場を抑えて開催するイベントにおいて、数百人が集まるというのは、それを実際に “見る” ことができます。

ですが、Kaigi on Rails 2021 の本編運営においては、言ってしまえばパソコンの前でポチポチしているだけ2で、家から一歩も出ずに終わってしまいました。

これは技術が発展したからだという良い面ももちろんありますが、どうにもこれまでのスタッフ業と比較すると「やった感」があまりないというか、本当にみんな来てくれて、楽しんでくれたんだろうか、そういうイベントをやれていたんだろうかという気持ちが残ります。

なので、皆さんの Twitter での投稿や reBako、SpatialChat 、感想ブログでの反応をありがたく頂いています。

Next

準備期間中は先輩イベントである RubyKaigi の取り組みからの着想であったり、他にも様々なアイデアが出てきましたが、どうにも開催までの期間では他にやることも多く中々手が出せていませんでした。

来年というか次回の Kaigi on Rails でどれだけお手伝いできるかはわかりませんが、しばらく(Kaigi on Rails の運営業が)ゆっくりできる時間に細々と手を動かしていけたらいいなと思っています。

そして、もしかしたら来年の開催ではリアル会場で開催できるのか、できないのか……という情勢になってきています。そうなったとき、どこまでオンライン重点で準備をしていくべきか3というのが全く見えていません。難しいですね。


この記事は The Mark 65 によって書かれました。


  1. 飲みに行くぞ!!!! 

  2. もちろんですがお気楽ということはありません。OBS の操作ひとひとつで手が震えています。 

  3. 例えばコロナウイルスの感染脅威が十分に低下し、これまでのようなオフラインイベントの開催にあたっての制限が現実的なものに落ち着いたとして、そもそも会場はどうするかという問題があり、なので来年も引き続きフルオンラインイベントとして開催し、再来年にようやくリアルで、となる可能性は十分にある、というかそのほうが高いんじゃないだろうか……等 

2021年10月24日
2021年09月16日

RubyKaigi Takeout 2021 で発表しました

スライド表紙

RubyKaigi Takeout 2021 の 3 日目に、「Ruby, Ractor, QUIC」 という題で個人的に取り組んでいることについて話しました。

Ruby, Ractor, QUIC (スライドへのリンク)


発表内容については、事前に台本を作成し 25 分間という枠に収まることを確認してからスライドを作成するという流れで組み立てました。それもあってとても聞きやすかったというお声を頂き、苦労が報われた感じがありました。ありがとうございます。

きっかけ

開発のきっかけを、発表内では「QUIC の勉強をしている時に ko1 さんの Tweet を見かけた」と言いましたが、実はもう少しバックストーリーがあります1

発表に出していない部分の経緯として、そもそもは Black 社内で Web 技術について話していたのがきっかけとなります。2021 年初頭くらいから一部メンバーの間で WebRTC を自前実装する流れ 2 が社内にあり、だったら自分も何かしてみたい、そういえば WebTransport というものがあるけど、土台となる技術について何も知らないな、じゃあ QUIC について調べてみるか、という流れで、 QPACK や QUIC の draft を読んだり、curl の HTTP/3 対応版を build したり、Initial packet を手で parse したりしはじめた、というのが始まりです3

これは 3 月

image.png (27.1 kB)

これは 4 月

image.png (39.5 kB)

これは 5 月

image.png (450.9 kB)

そうこうしている過程で QUIC のことを調べているうちに、奥さんと笹田さんの tweet を見、Ractor でやってみるのはよさそうだ、ということも考えるようになりました。

その後、身内で「Ruby で QUIC をやっている」ということを言ったところ応援されたのもあり、しばらくの間、正しく Initial packet を parse することもできないままにちまちまと開発を進めていましたが、角谷さんの tweet に触発され、エイヤで RubyKaigi に Proposal を出したところ accept されたので焦りはじめた、という経緯がありました。

採択されて以降、特に timetable が公開されるまでの間にはなんとか成果を出そうとがんばって書いたのが以下の 2 つの記事になります。

Proposal 原文

Details

Ractor, introduced in Ruby 3, has made it possible to easily write parallel processing in Ruby. However, at this point, there are still no examples of its use on a certain scale, and it is difficult to estimate whether it is practical or not, and whether there are bottlenecks or not.

QUIC, which will be standardized in May 2021, is a protocol for communication-based on UDP, as opposed to the TCP communication used in HTTP to date. It is generally implemented in userspace, which makes development easier.

For my technical interest, I am trying to implement the QUIC protocol on Ruby 3 by using Ractor. By doing so, I believe I can contribute to the performance evaluation and improvement of Ractor itself by creating a program that uses Ractor of a certain size.

Pitch

I am involved in the development of a cloud gaming service called OOParts. This service uses WebRTC to send video from the server to the user’s web browser. As a successor to WebRTC, a protocol called WebTransport has been proposed by W3C and IETF. This is a technology that is attracting a lot of attention in the field of cloud gaming. WebTransport is being developed with the primary goal of being built on top of HTTP/3, and HTTP/3 will be implemented based on QUIC. I have been trying to implement the QUIC protocol stack in Ruby for learning purposes, and have partially succeeded in parse the initial packet at this stage. https://gist.github.com/unasuke/322d505566de087f58a6b47902812630

The reason I started this effort in the first place was because I saw these messages between Kazuho Oku san, who is working on the QUIC specification, and Sasada-san, who is developing Ractor https://twitter.com/_ko1/status/1282963500583628800

Since then, I have started learning QUIC, and have written several pull requests and blog posts in the process.

At this point, I haven’t gotten to the point of using Ractor to communicate, but I can talk about some of the challenges of implementing QUIC in Ruby.

この proposal 、及び当日の発表原稿はあらや君に review してもらいました。毎度直前のお願いになってしまって申し訳ないです。次回以降の HTTP/Tokyo には是非参加させていただきます。

副産物 : udpbench

既存のツールはなかったのだろうか、という tweet がありました。UDP を使ってベンチマークを行うツールとして、iperf3 というものがあります。しかしこれがベンチマークの対象としているのは帯域であり、iperf3 間での通信がどのくらいの速度で行えるのかを計測するツールで、今やりたい「サーバーがどのくらいの UDP パケットを処理できるのか」という目的にはマッチしないものでした。

また、そもそも UDP が送りつけっぱなしプロトコルということもあり、「送ったら、返ってくる」という性質のものでもないために、 TCP のようにちゃんと想定した response を返してくれるのか、ということを検証できる既存のものが見当りませんでした4

udpbench は本当に突貫工事で、例えば packet が返ってこなかった場合は無限に待ち続けてしまうなど、いわゆるタイムアウト機構がありません。テストもありません。Pull request お待ちしております……使う人いるの?

感想

「こういうものができました、見てください」ではなく、「こういうことをやろうとしています、今の課題はこうです」という内容の発表だったので、出来上がっているものがほぼ無い状態で発表するというものすごいプレッシャーがありました。Proposal が通った以上、それが求められていると運営側が判断したから話す価値はあるはずなのですが、他の登壇している方々が成果を持ってやってくる様子を見ていると、やはり無力感があります。

そして RubyKaigi が終わった今はまた一変して「こういうことをやっています」というのを全世界の Rubyist に対して宣言したという状況になります。

人は宣言すると実行する

— うなすけ (@yu_suke1994) July 22, 2015

資料作成のために手を動かしている間は、自分に対してずっと「あとはやるだけ」「でも、やるんだよ」と言い聞かせていました。これからも、やるだけです。

Rabbit のこと

少し前に書いた スライド置き場のポリシー にも書きましたが、しばらく Rabbit からは離れていました。しかし RubyKaigi に登壇となったら Rabbit 使うしかないだろう!という気持ちになって久々に使いました。スライドのテーマについて参考にさせていただき、また ruby-jp slack でトラブルシューティングに付き合っていただいた @hasumikin さんにはとても感謝しています。

Rabbit については思うところがあり、やはり凝ったことをし出すと大変になってしまうあたり、HTML と CSS でスライドを作るようにしたほうが自分のスタイルに合っているんじゃないかということです。これまでは Markdown、今回は RD で書きましたが、これらは文書構造の定義には向いていても外観の装飾には向いていないのでどうしてもがんばってやりくりしないといけなくなります。図は結局 Illustrator でがんばりましたし。

あと、素材に関しては keynote.app の template しかなかったのも手間取りました。RubyKaigi team に Rabbit の theme を書いてくれとは言いませんが、 Mediakit みたく素材を配布してもらえるとありがたかった5です。Web ページの情報から取得できるだろうというのはそれはそうですが……

それはそれとして、もっと Rabbit ユーザーが増えてくれると嬉しいですね。

発表後から Fukuoka.rb までのこと

まず宿題とした OpenSSL::Cipher の Ractor 対応を行いました。

OpenSSL::Cipher をRactor内で使えるように試してみたら、これで一旦 new はできたけどテストが何もありません。 (Make OpenSSL::Cipher ractor-safe, but is it right…?)

Make OpenSSL::Cipher Ractor-safe https://t.co/GfpfgEWWMo #rubykaigi

— うなすけ (@yu_suke1994) September 11, 2021

加えて、既に Ractor-safe とされていた OpenSSL::BN について、Ractor を使用したテストケースを追加して pull request を作成しました。不完全なのでまだまだ修正が必要ですが。

発表では「できる」と言ったこと、例えば packet をパースする部分について、実際は実装できていなかったので実装を行いました。

https://github.com/unasuke/raiha/compare/b0eadd3…0886149

パーサーは、今は Initial Packet が来る前提のコードになっているのをまずは修正しないと、と考えています。

他には細かいですが、Rabbit でドキュメントの誤りについて、MFA を有効にしているとスライドの公開ができないことについての pull request を作成しました。

謝辞

まずは一緒に Initial Packet を目と手でパースするのを手伝ってくれた Black のメンバーに、次にこの活動について背中を押してくれ、またわからないところを親切に教えてくれ、さらにレビューもしてくれたあらやくんをはじめとする kosen10s のみんなに改めて感謝します。

次にオンラインでのイベント体験として素晴しいシステムを用意してくれ、Proposal の後押しならびに採択による開発のブーストとなってくれた RubyKaigi team の皆さん、ありがとうございました。

これから

「開発しています」とは言ったものの、しばらくガッツリ手を動かすことはないと思います。理由としては、高専 DJ 部、Kaigi on Rails 、その後に (Proposal が通った場合は) Cloud Native Days Tokyo 2021 が控えているためです。隙間時間を見つけてちまちま手を動かしていくつもりです。

また、 Ractor 自体に手を入れていくということになると C 言語などの知識が必要になる6と思っていて、 C 言語は学生時代に書いたきり、かつ高度なことはやらなかったので改めて C 言語の学習をしようとも思っています。具体的には次の本を買って読もうと思いますが、他におすすめの本があれば教えてください。

また実装、資料作成中に思ったこととして、るりまに Ractor と OpenSSL についてのドキュメントが不足しているというものがあり、それもなんとか手を動かしていきたいです。


  1. 「表向きの理由」とも言える 

  2. がんばって WebRTC for The Curious を訳していたら voluntas さんに先を越されたという裏話もある 

  3. このあたり時系列がふわふわしている 

  4. 今調べたら https://github.com/bluhm/udpbench というものがありました、今見つけました。 

  5. じゃあ Kaigi on Rails team では提供してんのかいな!と言われるとそこまで手が回っていないのでスミマセン、という…… 

  6. Fukuoka.rb でうづらさんにアドバイスいただいた、String に C 拡張で each_bit のようなメソッドを生やすというのを手始めにやってみたい 

2021年09月16日
2021年08月22日

続・QUICのInitial packetをRubyで受けとる (curl編)

QUIC と Ruby

前回の記事では、Appendix にある例示の packet をデコードするところまでやりました。この記事ではその続きとして、冒頭で受けとった curl からの packet をデコードして中身を見ることにします。

ですがこの記事を書くまでの間に、おりさのさんが parse に成功したものを gist に公開してくれていました!

QUIC の Initial packet を Ruby で受けとる | うなすけとあれこれ https://t.co/UBAjY5g88z
これのプログラムをいじってcurlから受け取ったパケットをparseできるようにしていた.https://t.co/PqS7VEIFkh
BinData::Stringが書き換えられなくて変な部分がある

— おりさの (@orisano) August 12, 2021

この記事では、おりさのさんの script を見つつ、もう少し先まで進んで CRYPTO frame の中身をちゃんと見るところまでやりたいと思います。

おりさのさんの script と僕のものとの違い

これがおりさのさんの gist です。僕のものよりも関数に切り出されていたりとなかなか奇麗になコードに整形されています。

https://gist.github.com/orisano/0efc65f96b81ca7c174fedd3431de611

packet の保護を外すのも、 QUICInitialPacket のインスタンスメソッドになっていたりと、オブジェクト指向らしいコードになっています。本当にありがたいですね。

また、ちゃんと UDPSocket を作成しており、実行するだけで packet を受けとれるようになっています。

ふたたび、curl からの Initial Packet を受けとる

それではこれを実行して、前回の記事のように Initial Packet を受けとり中身を表示させてみましょう。

$ ruby orisano_quic.rb
{:header_form=>1,
 :long_packet_type=>0,
 :packet_number_length=>0,
 :version=>1,
 :destination_connection_id_length=>16,
 :destination_connection_id=>
  "\xFC\xCCWh\xEE\xAE\xF1\x90R\xA2\xF6\xA5\xA2\xB2\x9C\x8D",
 :source_connection_id_length=>20,
 :source_connection_id=>
  "\xD7\xA6\x9B\x9Bhq\xD7\xC3\x04\x8A*\xB3\xA5\x13\x8D[\xACcV[",
 :token_length=>0,
 :token=>"",
 :len=>288}
{:frame_type=>6,
 :offset=>0,
 :len=>267,
 :data=>
  "\x01\x00\x01\a\x03\x03\x18,Ct_\x85h79\x10y\x86\x8A\x05d\x92\xA3a\xAE\x9D\xF2\xF6\xF9\x02\xE8\xC9\x93)\"\xE4\x86\e\x00\x00\x06\x13\x01\x13\x02\x13\x03\x01\x00\x00\xD8\x00\x00\x00\x0E\x00\f\x00\x00\t127.0.0.1\x00\n" +
  "\x00\b\x00\x06\x00\x1D\x00\x17\x00\x18\x00\x10\x00\x17\x00\x15\x02h3\x05h3-29\x05h3-28\x05h3-27\x00\r\x00\x14\x00\x12\x04\x03\b\x04\x04\x01\x05\x03\b\x05\x05\x01\b\x06\x06\x01\x02\x01\x003\x00&\x00$\x00\x1D\x00 \x83rx\x1ELvw\xFE\x9D\xC3[k\\=\x80\vw\x17N\xB2\xBFL\xA4g\xA4\xBE\x87\xE1\x1F\xF0\xD6w\x00-\x00\x02\x01\x01\x00+\x00\x03\x02\x03\x04\x009\x00L\x01\x04\x80\x00\xEA`\x03\x04\x80\x00\xFF\xF7\x04\x04\x80\x10\x00\x00\x05\x04\x80\x10\x00\x00\x06\x04\x80\x10\x00\x00\a\x04\x80\x10\x00\x00\b\x04\x80\x04\x00\x00\t\x04\x80\x04\x00\x00\n" +
  "\x01\x03\v\x01\x19\x0F\x14\xD7\xA6\x9B\x9Bhq\xD7\xC3\x04\x8A*\xB3\xA5\x13\x8D[\xACcV["}

と、このようになりました。この記事では、この CRYPTO frame の中身を見ていきます。

CRYPTO frame を decode する

RFC 9000 の CRYPTO frame の定義 では、 cryptographic handshake messages を transmit するとあります。また、RFC 9001 の 4.1.3. Sending and Receiving Handshake Messages には、 QUIC CRYPTO frames only carry TLS handshake messages. とあります。もう少し読み進めて RFC 9001 - 4.3. ClientHello Size には、ClientHello が送られるとあります。

ということで TLS 1.3 の定義、 RFC 8446 での Handshake 及び ClientHello の定義を見てみましょう。

enum {
    client_hello(1),
    server_hello(2),
    new_session_ticket(4),
    end_of_early_data(5),
    encrypted_extensions(8),
    certificate(11),
    certificate_request(13),
    certificate_verify(15),
    finished(20),
    key_update(24),
    message_hash(254),
    (255)
} HandshakeType;

struct {
    HandshakeType msg_type;    /* handshake type */
    uint24 length;             /* remaining bytes in message */
    select (Handshake.msg_type) {
        case client_hello:          ClientHello;
        case server_hello:          ServerHello;
        case end_of_early_data:     EndOfEarlyData;
        case encrypted_extensions:  EncryptedExtensions;
        case certificate_request:   CertificateRequest;
        case certificate:           Certificate;
        case certificate_verify:    CertificateVerify;
        case finished:              Finished;
        case new_session_ticket:    NewSessionTicket;
        case key_update:            KeyUpdate;
    };
} Handshake;

uint16 ProtocolVersion;
opaque Random[32];

uint8 CipherSuite[2];    /* Cryptographic suite selector */

struct {
    ProtocolVersion legacy_version = 0x0303;    /* TLS v1.2 */
    Random random;
    opaque legacy_session_id<0..32>;
    CipherSuite cipher_suites<2..2^16-2>;
    opaque legacy_compression_methods<1..2^8-1>;
    Extension extensions<8..2^16-1>;
} ClientHello;

https://www.rfc-editor.org/rfc/rfc8446.html#section-4

ちょっと長いように見えますが、ClientHello のみに注目すればそこまでではありません。ここで、 legacy_session_idcipher_suiteslegacy_compression_methodsextensions それぞれの長さがわからないように見えますが、 fileld<floor..ceiling> という表記があった場合、 まず ceiling を格納できる大きさの領域が先頭に存在しており、その領域で後続するデータの長さを表現するようになっています。 legacy_session_id<0..32> の場合では、32 までの数値を格納できるよう、まず先頭で 1 バイト使用して1後続の長さを表現します。その後、その数だけのバイト長を読み、フィールドの値とします。

opaque でない field それぞれの中身を見ていきます。

CipherSuite の中身はなんでしょうか。 uint8 が 2 つ連続した形式になっており、値によって使用される AEAD のアルゴリズムと HKDF でのハッシュ長が決まります。

https://datatracker.ietf.org/doc/html/rfc8446#appendix-B.4 に実際の値があります。

Extension の中身はなんでしょうか。ちょっと長いので enum の定義は省略しますが、以下のような構造になっています。

struct {
    ExtensionType extension_type;
    opaque extension_data<0..2^16-1>;
} Extension;

https://datatracker.ietf.org/doc/html/rfc8446#section-4.2

ExtensionType に拡張の種類、 extension_data には拡張で使用する情報が入ります。

これで読めるようになったので、 CRYPTO frame の中身を decode してみましょう。

RFC の enum を定義し、 curl から Initial packet を受けとって decode するのが以下のコードになります。

https://gist.github.com/unasuke/2fcf8e97a80c59bf943bf9b3d4fac964

これまた長いので、gist への URL となります。また、実はおりさのさんが ClientHello の decode をするコードを書いてくれていた(!)ので、先ほどの gist は pretty print を行い、さらに見やすくしたものとなります。

実行すると、以下のように ClientHello の中身が読めるようになります!

デコードされたTLS handshakeの様子

TLS ClientHello の中に、 QUIC のためのパラメーターが含まれているのも読み取れますね。

ここで ClientHello の中身が何を意味しているかについては、ラムダノートさんから出ている「プロフェッショナル SSL/TLS」の 2 章に詳しいのでぜひ読んでみてください。

参考文献


  1. 実のところ 32 は 6 bit ですが、 RFC 8446 - 3.3. Numbers に記載されるように基本的な数値型は an unsigned byte で表現されるのでこの場合は 1 byte です。 

2021年08月22日
2021年08月11日

QUIC の Initial packet を Ruby で受けとる

QUIC と Ruby

QUIC とは

QUIC は、今年 5 月に RFC 9000 や他いくつかの RFC によって標準化された、次世代のインターネットにおける通信プロトコルです。HTTP/3 では、この QUIC を下位層として使うことになっており、今後のより高速なインターネット通信において QUIC の占める役割は非常に大きなものとなるでしょう。

QUIC is now RFC 9000 | Fastly

この記事では、QUIC による通信が始まる第一歩であるところの、Initial packet を Ruby で受けとってみることにします。

はじめに

この記事内では、いくつかの外部の記事を参照しています。それらは QUIC の、ある時点での draft を参考に書いてあるものもありますが、この記事では RFC となった QUIC version 1 に対しての内容となります。

記事内の誤り、誤字脱字等は気軽に twitter @yu_suke1994 にリプライしていただけると嬉しいです。

UDP Packet を受けとる

QUIC では TCP ではなく UDP を使って通信します。つまり、Ruby で QUIC の Packet を受けとるために作成するのは UDP Socket になります。

以下のように Socket を作成し1、そこに対して QUIC の Initial packet を送信してやります。

require 'socket'

socket = UDPSocket.new
socket.bind("0.0.0.0", 8080)

begin
  raw_packet = socket.recvfrom_nonblock(2000)
rescue IO::WaitReadable
  retry
end

pp raw_packet[0]

ここで用意したポートに対して、どのように QUIC の Packet を送信すればいいでしょうか。ここでは、僕がビルドしている HTTP/3 に対応している curl を使用することにします2。docker-compose.yml の内容は割愛します。

https://github.com/unasuke/curl-http3

Initial packet を parse する

さて、Initial packet を受信することができたので、中身を見てみます。

irb(main):001:0> raw_packet[0].unpack("H*")
=> ["c80000000110b61d55525ce5050363d471738ff245271476637acd05d5...

人間が読むようなものではありませんね。そこで、これを parse します。

QUIC の Packet の構造はこのようになっており、header と frame を持ちます。header には short header と long header の 2 種類があり、Initial packet は long header を持ちます。Initial packet は以下のような構造をしています。

Initial Packet {
  Header Form (1) = 1,
  Fixed Bit (1) = 1,
  Long Packet Type (2) = 0,
  Reserved Bits (2),
  Packet Number Length (2),
  Version (32),
  Destination Connection ID Length (8),
  Destination Connection ID (0..160),
  Source Connection ID Length (8),
  Source Connection ID (0..160),
  Token Length (i),
  Token (..),
  Length (i),
  Packet Number (8..32),
  Packet Payload (8..),
}

https://www.rfc-editor.org/rfc/rfc9000.html#name-initial-packet

この定義に従い packet を parse するのですが、ここで bindata という便利な gem を使います。使い方の説明は割愛しますが、以下のようなコードを書くことで今受け取った Initial packet を parse することができます。

ここで気をつけないといけないのが、上記構造でフィールドの長さが (i) となっているもの (Token length と Length) です。このフィールドは可変長で、 “Variable-Length Integer Encoding” という形式で表現されています。この形式は、まず先頭 2 ビット (two most significant bits) を読み、その値によって後続のバイト数が決まるようになっています3

require 'bindata'

def tms(bit)
  case bit
  when 0 then 6
  when 1 then 14
  when 2 then 30
  when 3 then 62
  end
end

class QUICInitialPacket < BinData::Record
  endian :big
  bit1 :header_form, asserted_value: 1
  bit1 :fixed_bit, asserted_value: 1
  bit2 :long_packet_type, asserted_value: 0
  bit2 :reserved_bit
  bit2 :packet_number_length
  bit32 :version
  bit8 :destination_connection_id_length
  bit :destination_connection_id, nbits:  lambda { destination_connection_id_length * 8 }
  bit8 :source_connection_id_length
  bit :source_connection_id, nbits: lambda{ source_connection_id_length * 8 }

  # Variable-Length Integer Encoding for token
  bit2 :token_two_most_significant_bits
  bit :token_length, nbits: lambda { tms(token_two_most_significant_bits) }
  string :token, read_length: lambda { token_length }

  # Variable-Length Integer Encoding for length
  bit2 :length_two_most_significant_bits
  bit :length_length, nbits: lambda { tms(length_two_most_significant_bits) }

  bit :packet_number, nbits: lambda { (packet_number_length + 1) * 8 }
  string :payload, read_length: lambda { length_length - (packet_number_length + 1) }
end

parsed_packet = QUICInitialPacket.read(raw_packet[0])

https://www.rfc-editor.org/rfc/rfc9000.html#name-variable-length-integer-enc

payload に frame が格納されます。 Initial packet の payload には何が入っているのでしょうか。Initial packet の payload には、CRYPTO frame というものが含まれているはず4です。CRYPTO frame は以下のような構造をしています。

CRYPTO Frame {
  Type (i) = 0x06,
  Offset (i),
  Length (i),
  Crypto Data (..),
}

https://www.rfc-editor.org/rfc/rfc9000.html#name-crypto-frames

それでは、先ほど受けとった packet の Packet Payload は、先頭に 0x06 が含まれているはず5ですね。見てみましょう。

irb(main):001:0> parsed_packet
=>
{:header_form=>1,
 :fixed_bit=>1,
 :long_packet_type=>0,
 :reserved_bit=>2,
 :packet_number_length=>0,
 :version=>1,
 :destination_connection_id_length=>16,
 :destination_connection_id=>242071802372027324022003629770543351079,
 :source_connection_id_length=>20,
 :source_connection_id=>675879382196389319306730800640250706941161587532,
 :token_two_most_significant_bits=>0,
 :token_length=>0,
 :token=>0,
 :length_two_most_significant_bits=>1,
 :length_length=>288,
 :payload=>
  "QuqI\xE6\xB10......

含まれていませんね。これはどうしてでしょうか。

現代のインターネットでは、通信は暗号化されてやりとりするのが一般的です。もちろん QUIC も暗号化した情報をやりとりするのが基本です。なので、この時経路上を流れていくパケットは暗号化されている6ため、まずは復号する必要があります。

Initial packet を復号する (packet の保護を解く)

さて復号のためには、どのように暗号化されているのかを知る必要があります。

QUIC におけるパケットの暗号化、それも Initial packet に対して行われる保護については、RFC 9001 - 5.2. Initial Secrets にその詳細があります。日本語では、flano_yuki さんの記事 「QUIC の暗号化と鍵の導出について」kazu-yamamoto さんの記事 「QUIC 開発日記 その 1 参戦」 がわかりやすいです。

QUIC では、まず header を利用して payload を暗号化してから、その暗号化された内容を利用して header を保護します。そこで、復号のためには、まず payload から mask を得て header の保護を解除し、その後得られた header の平文を用いて payload を復号します。

この過程において行われていることは、前述の flano_yuki さんの記事内にて引用されているスライド の p24 及び p27 の図がとてもわかりやすいです。

ここからは実際に受けとった packet の情報をもとに作業をしていくのではなく、検証が容易になるようRFC 9001 に記載されている付録 A の値を用いて暗号化と復号をしてみます。

RFC 9001 - Appendix A.2. でやってみる

まずは平文の packet を暗号化してみます。これが Appendix A.2. にある CRYPTO frame の平文です。

060040f1010000ed0303ebf8fa56f12939b9584a3896472ec40bb863cfd3e868
04fe3a47f06a2b69484c00000413011302010000c000000010000e00000b6578
616d706c652e636f6dff01000100000a00080006001d00170018001000070005
04616c706e000500050100000000003300260024001d00209370b2c9caa47fba
baf4559fedba753de171fa71f50f1ce15d43e994ec74d748002b000302030400
0d0010000e0403050306030203080408050806002d00020101001c0002400100
3900320408ffffffffffffffff05048000ffff07048000ffff08011001048000
75300901100f088394c8f03e51570806048000ffff

これを暗号化していくのですが、このままでは 245 bytes であり、header を合わせても 1200 bytes には届きません。なので PADDING frame を付与して header を除く payload 全体を 1162 bytes まで増やします7

payload =
  "060040f1010000ed0303ebf8fa56f12939b9584a3896472ec40bb863cfd3e868" +
  "04fe3a47f06a2b69484c00000413011302010000c000000010000e00000b6578" +
  "616d706c652e636f6dff01000100000a00080006001d00170018001000070005" +
  "04616c706e000500050100000000003300260024001d00209370b2c9caa47fba" +
  "baf4559fedba753de171fa71f50f1ce15d43e994ec74d748002b000302030400" +
  "0d0010000e0403050306030203080408050806002d00020101001c0002400100" +
  "3900320408ffffffffffffffff05048000ffff07048000ffff08011001048000" +
  "75300901100f088394c8f03e51570806048000ffff" +
  ("00" * 917) # PADDING frame

これを暗号化します。どのように行うのかは RFC 9001 - 5. Packet Protection に定義があり、Initial packet については AEAD_AES_128_GCM を用いて暗号化します。この AEAD については RFC 5116 に定義されており、暗号化に必要なパラメーターは以下の 4 つです8

これらの入力から、 C (ciphertext) が得られます。それぞれの入力を見ていきます。

K、 secret key ですが、RFC 5869 に定義されている鍵導出関数、HKDF を使用して導出します。ここで、まず初期 secret の導出のため、 HKDF-Extract の入力に QUIC の場合は 0x38762cf7f55934b34d179ae6a4c80cadccbb7f0a と Destination Connection ID (この Appendix では 0x8394c8f03e515708)9 を使用します。

この HDKF ですが、一般に HKDF-Extract の後に HKDF-Expand を使用して最終的な鍵を得ます。そのため、Ruby の OpenSSL gem にも、この過程を一度に行うための OpenSSL::KDF.hkdf という関数10があります。しかし、QUIC (および TLS 1.3)ではこれをそのまま使うことができません。理由は、RFC 5869 での定義の他に、RFC 8446 (TLS 1.3) で定義されている HKDF-Expand-Label(Secret, Label, Context, Length) という関数11が必要なためです。

ありがたいことに、Ruby による TLS 1.3 実装 thekuwayama/tttls1.3HKDF-Expand-Label の実装があるので、これを使って鍵を導出します。

require 'openssl'
require 'tttls1.3/key_schedule'

def hkdf_extract(salt, ikm)
  ::OpenSSL::HMAC.digest('SHA256', salt, ikm)
end

initial_salt = ["38762cf7f55934b34d179ae6a4c80cadccbb7f0a"].pack("H*")
destination_connection_id = ["8394c8f03e515708"].pack("H*")

initial_secret = hkdf_extract(initial_salt, destination_connection_id)
# initial_secret.unpack1("H*") => "7db5df06e7a69e432496adedb00851923595221596ae2ae9fb8115c1e9ed0a44"

client_initial_secret = TTTLS13::KeySchedule.hkdf_expand_label(initial_secret, 'client in', '', 32, 'SHA256')
# client_initial_secret.unpack1("H*") => "c00cf151ca5be075ed0ebfb5c80323c42d6b7db67881289af4008f1f6c357aea"

key = TTTLS13::KeySchedule.hkdf_expand_label(client_initial_secret, 'quic key', '', 16, 'SHA256')
# key.unpack1("H*") => "1f369613dd76d5467730efcbe3b1a22d"

iv = TTTLS13::KeySchedule.hkdf_expand_label(client_initial_secret, 'quic iv', '', 12, 'SHA256')
# iv.unpack1("H*") => "fa044b2f42a3fd3b46fb255c"

hp = TTTLS13::KeySchedule.hkdf_expand_label(client_initial_secret, 'quic hp', '', 16, 'SHA256')
# hp.unpack1("H*") => "9f50449e04a0e810283a1e9933adedd2"

このようにして、A.1 Keys と同様の鍵を得ることができました。

では暗号化していきます。RFC 9001 - 5.3. AEAD Usage によれば、AEAD に渡すパラメータは以下のように定義されます。

これらから、以下のようなコードで暗号化された payload を得ることができました。

payload =
  "060040f1010000ed0303ebf8fa56f12939b9584a3896472ec40bb863cfd3e868" +
  "04fe3a47f06a2b69484c00000413011302010000c000000010000e00000b6578" +
  "616d706c652e636f6dff01000100000a00080006001d00170018001000070005" +
  "04616c706e000500050100000000003300260024001d00209370b2c9caa47fba" +
  "baf4559fedba753de171fa71f50f1ce15d43e994ec74d748002b000302030400" +
  "0d0010000e0403050306030203080408050806002d00020101001c0002400100" +
  "3900320408ffffffffffffffff05048000ffff07048000ffff08011001048000" +
  "75300901100f088394c8f03e51570806048000ffff" +
  ("00" * 917)

encryptor = OpenSSL::Cipher.new("AES-128-GCM")
encryptor.encrypt
encryptor.key = key
nonce = (iv.unpack1("H*").to_i(16) ^ 2).to_s(16)
encryptor.iv = [nonce].pack("H*")
encryptor.auth_data = ["c300000001088394c8f03e5157080000449e00000002"].pack("H*")

protected_payload = ""
protected_payload << encryptor.update([payload].pack("H*"))
protected_payload << encryptor.final
protected_payload << encryptor.auth_tag

pp protected_payload.unpack1("H*")
# => d1b1c98dd7689fb8ec11
# d242b123dc9bd8bab936b47d92ec356c0bab7df5976d27cd449f63300099f399
# 1c260ec4c60d17b31f8429157bb35a1282a643a8d2262cad67500cadb8e7378c
# 8eb7539ec4d4905fed1bee1fc8aafba17c750e2c7ace01e6005f80fcb7df6212
# 30c83711b39343fa028cea7f7fb5ff89eac2308249a02252155e2347b63d58c5
# 457afd84d05dfffdb20392844ae812154682e9cf012f9021a6f0be17ddd0c208
# 4dce25ff9b06cde535d0f920a2db1bf362c23e596d11a4f5a6cf3948838a3aec
# 4e15daf8500a6ef69ec4e3feb6b1d98e610ac8b7ec3faf6ad760b7bad1db4ba3
# 485e8a94dc250ae3fdb41ed15fb6a8e5eba0fc3dd60bc8e30c5c4287e53805db
# 059ae0648db2f64264ed5e39be2e20d82df566da8dd5998ccabdae053060ae6c
# 7b4378e846d29f37ed7b4ea9ec5d82e7961b7f25a9323851f681d582363aa5f8
# 9937f5a67258bf63ad6f1a0b1d96dbd4faddfcefc5266ba6611722395c906556
# be52afe3f565636ad1b17d508b73d8743eeb524be22b3dcbc2c7468d54119c74
# 68449a13d8e3b95811a198f3491de3e7fe942b330407abf82a4ed7c1b311663a
# c69890f4157015853d91e923037c227a33cdd5ec281ca3f79c44546b9d90ca00
# f064c99e3dd97911d39fe9c5d0b23a229a234cb36186c4819e8b9c5927726632
# 291d6a418211cc2962e20fe47feb3edf330f2c603a9d48c0fcb5699dbfe58964
# 25c5bac4aee82e57a85aaf4e2513e4f05796b07ba2ee47d80506f8d2c25e50fd
# 14de71e6c418559302f939b0e1abd576f279c4b2e0feb85c1f28ff18f58891ff
# ef132eef2fa09346aee33c28eb130ff28f5b766953334113211996d20011a198
# e3fc433f9f2541010ae17c1bf202580f6047472fb36857fe843b19f5984009dd
# c324044e847a4f4a0ab34f719595de37252d6235365e9b84392b061085349d73
# 203a4a13e96f5432ec0fd4a1ee65accdd5e3904df54c1da510b0ff20dcc0c77f
# cb2c0e0eb605cb0504db87632cf3d8b4dae6e705769d1de354270123cb11450e
# fc60ac47683d7b8d0f811365565fd98c4c8eb936bcab8d069fc33bd801b03ade
# a2e1fbc5aa463d08ca19896d2bf59a071b851e6c239052172f296bfb5e724047
# 90a2181014f3b94a4e97d117b438130368cc39dbb2d198065ae3986547926cd2
# 162f40a29f0c3c8745c0f50fba3852e566d44575c29d39a03f0cda721984b6f4
# 40591f355e12d439ff150aab7613499dbd49adabc8676eef023b15b65bfc5ca0
# 6948109f23f350db82123535eb8a7433bdabcb909271a6ecbcb58b936a88cd4e
# 8f2e6ff5800175f113253d8fa9ca8885c2f552e657dc603f252e1a8e308f76f0
# be79e2fb8f5d5fbbe2e30ecadd220723c8c0aea8078cdfcb3868263ff8f09400
# 54da48781893a7e49ad5aff4af300cd804a6b6279ab3ff3afb64491c85194aab
# 760d58a606654f9f4400e8b38591356fbf6425aca26dc85244259ff2b19c41b9
# f96f3ca9ec1dde434da7d2d392b905ddf3d1f9af93d1af5950bd493f5aa731b4
# 056df31bd267b6b90a079831aaf579be0a39013137aac6d404f518cfd4684064
# 7e78bfe706ca4cf5e9c5453e9f7cfd2b8b4c8d169a44e55c88d4a9a7f9474241
# e221af44860018ab0856972e194cd934
#
# https://www.rfc-editor.org/rfc/rfc9001#section-a.2-7 の内容からheader部分を除いたものと同じ

次に、この暗号化された payload を sampling したものをもとに header を保護します。このあたりは、 https://tex2e.github.io/blog/crypto/quic-tls#ヘッダの暗号化 に、このようになっている理由の日本語による解説があります。

どの部分からどの部分までを sampling するかは、 RFC 9001 - 5.4.2. Header Protection Sample に計算式があるので、Initial packet の場合の式を以下に記載します。

pn_offset = 7 + len(destination_connection_id) + len(source_connection_id) + len(payload_length)
pn_offset += len(token_length) + len(token) # 特にInitial packetの場合
sample_offset = pn_offset + 4

# sample_length は、AES を使用する場合は 16 bytes
# https://www.rfc-editor.org/rfc/rfc9001#section-5.4.3-2
sample = packet[sample_offset..sample_offset+sample_length]

それでは計算していきましょう。Appendix では、header は c300000001088394c8f03e5157080000449e00000002 でした12。destinationconnectionid は 8394c8f03e515708 なので 8 bytes、 sourceconnectionid はないので 0 byte、 payloadlength は 449e なので 2 bytes、 token length は 0 なので 1 byte、 token 自体は無いので 0 byte となり、 `pnoffsetは 18 になります。よってsample_offset` は 22 bytes となります。

なので、packet の先頭から 22 bytes 進み、そこから 16 bytes を取得すると d1b1c98dd7689fb8ec11d242b123dc9b になります。

ここから mask を導出します。AES を使用する場合の mask の導出は RFC 9001 - 5.4.3. AES-Based Header Protection に定義されており、これを Ruby で行うのが以下のコードになります。

enc = OpenSSL::Cipher.new('aes-128-ecb')
enc.encrypt

# hp
enc.key = ["9f50449e04a0e810283a1e9933adedd2"].pack("H*")

sample = "d1b1c98dd7689fb8ec11d242b123dc9b"

mask = ""
mask << enc.update([sample].pack("H*"))
mask << enc.final
pp mask.unpack1("H*")
# => "437b9aec36be423400cdd115d9db3241aaf1187cd86d6db16d58ab3b443e339f"

それでは得られた mask を元に、header の保護をしてみましょう。

# https://www.rfc-editor.org/rfc/rfc9001#section-a.2-6
header = "c300000001088394c8f03e5157080000449e00000002"
mask = "437b9aec36be423400cdd115d9db3241aaf1187cd86d6db16d58ab3b443e339f"

# Appendixの例と範囲が異なるのは、文字列で保持しているため
# 2文字 => 1 byte
header[0..1] = (header[0..1].to_i(16) ^ (mask[0..1].to_i(16) & '0f'.to_i(16))).to_s(16)
header[36..43] = (header[36..43].to_i(16) ^ mask[2..9].to_i(16)).to_s(16)

pp header
# => "c000000001088394c8f03e5157080000449e7b9aec34"

このようにして得られた保護された header と、暗号化された payload を結合することで Appendix にある “resulting protected packet”13 と同様のものを得ることができました。

それではここまでの手順を逆に辿り、暗号化された packet の復号をやってみましょう。これは長いので、完全版を gist に置き、ここには抜粋したものを掲載します。

https://gist.github.com/unasuke/b30a4716248b1831bd428af3b7829ce7

require 'openssl'
require 'bindata'

class QUICInitialPacket < BinData::Record
  # ....
end

class QUICProtectedInitialPacket < BinData::Record
  # ....
end

class QUICCRYPTOFrame < BinData::Record
  endian :big
  bit8 :frame_type, asserted_value: 0x06
  bit2 :offset_two_most_significat_bits
  bit :offset, nbits: lambda { tms(offset_two_most_significat_bits) }
  bit2 :length_two_most_significant_bits
  bit :length_length, nbits: lambda { tms(length_two_most_significant_bits) }
  string :data, read_length: lambda { length_length }
end

raw_packet = [
  "c000000001088394c8f03e5157080000449e7b9aec34d1b1c98dd7689fb8ec11" +
  # .......
].pack("H*")

packet = QUICProtectedInitialPacket.read(raw_packet)

# ここからheaderの保護を解除するためのコード
pn_offset = 7 +
  packet.destination_connection_id_length +
  packet.source_connection_id_length +
  (tms(packet.length_two_most_significant_bits) + 2) / 8 +
  (tms(packet.token_two_most_significant_bits) + 2) / 8 +
  packet.token_length

sample_offset = pn_offset + 4

sample = raw_packet[sample_offset...sample_offset+16]

enc = OpenSSL::Cipher.new('aes-128-ecb')
enc.encrypt

enc.key = ["9f50449e04a0e810283a1e9933adedd2"].pack("H*") # hp
mask = ""
mask << enc.update(sample)
mask << enc.final

# https://www.rfc-editor.org/rfc/rfc9001#name-header-protection-applicati
# headerを保護するときとは逆の手順を踏んで保護を解除する
raw_packet[0] = [(raw_packet[0].unpack1('H*').to_i(16) ^ (mask[0].unpack1('H*').to_i(16) & 0x0f)).to_s(16)].pack("H*")

# https://www.rfc-editor.org/rfc/rfc9001#figure-6
pn_length = (raw_packet[0].unpack1('H*').to_i(16) & 0x03) + 1

packet_number =
  (raw_packet[pn_offset...pn_offset+pn_length].unpack1("H*").to_i(16) ^ mask[1...1+pn_length].unpack1("H*").to_i(16)).to_s(16)

# 先頭の0が消えてしまうので、パケット番号の長さに満たないぶんを zero fillする
raw_packet[pn_offset...pn_offset+pn_length] = [("0" * (pn_length * 2 - packet_number.length)) + packet_number].pack("H*")

# headerの保護が外れたpacket (payloadはまだ暗号)
packet = QUICInitialPacket.read(raw_packet)

# 復号のためheaderのみを取り出す
header_length = raw_packet.length - packet.payload.length

# payloadの復号
dec = OpenSSL::Cipher.new('aes-128-gcm')
dec.decrypt
dec.key = ["1f369613dd76d5467730efcbe3b1a22d"].pack("H*") # quic key
dec.iv = [("fa044b2f42a3fd3b46fb255c".to_i(16) ^ packet.packet_number).to_s(16)].pack("H*") # quic iv
dec.auth_data = raw_packet[0...(raw_packet.length - packet.payload.length)]
dec.auth_tag = packet.payload[packet.payload.length-16...packet.payload.length]

payload = ""
payload << dec.update(packet.payload[0...packet.payload.length-16])
payload << dec.final

# 復号したpayloadをCRYPTO frameとしてparse
pp QUICCRYPTOFrame.read(payload)
# => {:frame_type=>6,
# :offset_two_most_significat_bits=>0,
# :offset=>0,
# :length_two_most_significant_bits=>1,
# :length_length=>241,
# :data=>
#  "\x01\x00\x00\xED\x03\x03\xEB\xF8\xFAV\xF1)9\xB9XJ8\x96G.\xC4\v\xB8c\xCF\xD3\xE8h\x04\xFE:G\xF0j+iHL" +
#  "\x00\x00\x04\x13\x01\x13\x02\x01\x00\x00\xC0\x00\x00\x00\x10\x00\x0E\x00\x00\vexample.com\xFF\x01\x00\x01\x00\x00\n" +
#  "\x00\b\x00\x06\x00\x1D\x00\x17\x00\x18\x00\x10\x00\a\x00\x05\x04alpn\x00\x05\x00\x05\x01\x00\x00\x00\x00" +
#  ....

CRYPTO frame の中に何やらそれらしき文字列が出現していることから、payload の復号に成功したことがうかがえますね。

実際に curl から受けとった packet の parse もやっていきたいところですが、一旦この記事ではここまでとします。

さいごに

プロトコルの理解は、実際に手を動かしてみるのが一番ですね。

記事内の誤り、誤字脱字等は気軽に twitter @yu_suke1994 にリプライしていただけると嬉しいです。

記事をチェックしてくれた あらやくんおりさのくんとちくじさん に感謝します。

追記

参考文献


  1. 最低でも 1200 bytes は受信できるようにする必要があります。特に Initial packet については、必ず 1200 bytes 以上のサイズになるように PADDING などを行う必要があります。 https://www.rfc-editor.org/rfc/rfc9000.html#name-datagram-size 

  2. もっと手軽に実験するなら、Python の環境があるなら aioquic 、Rust の環境を用意するのが苦でないなら Neqo を使うのもいいと思います。なかなかビルド済バイナリを落としてくるだけで使用できる QUIC クライアントがないのが現状です。 

  3. 厳密には、この時点で parse を行うことはできません。後述する packet の保護を解除しないと正しい情報は得られません。 

  4. TLS handshake を行うため。 https://www.rfc-editor.org/rfc/rfc9001#name-carrying-tls-messages 

  5. 先頭にあるとは限りませんし、どのみちこの時点では暗号化されており 0x06 の存在を確認することはできません。 https://www.rfc-editor.org/rfc/rfc9000.html#section-17.2.2-8 

  6. 例外もあります。例えば Version Negotiagion パケットは保護されません。 https://www.rfc-editor.org/rfc/rfc9001#section-5-3.1 

  7. https://www.rfc-editor.org/rfc/rfc9001#section-a.2-1 

  8. https://www.rfc-editor.org/rfc/rfc5116.html#section-2.1 

  9. https://www.rfc-editor.org/rfc/rfc9001#section-appendix.a-1 

  10. https://docs.ruby-lang.org/en/3.0.0/OpenSSL/KDF.html#method-c-hkdf 

  11. https://www.rfc-editor.org/rfc/rfc8446.html#section-7.1 

  12. https://www.rfc-editor.org/rfc/rfc9001#section-a.2-4 

  13. https://www.rfc-editor.org/rfc/rfc9001#section-a.2-7 

2021年08月11日
2021年07月30日

スライド置き場のポリシー

JavaScriptがとんでもないことになっている様子

スライドをどこに置くか

スライドをスライド共有サービスに置くのをやめることにした - 私が歌川です を読み、自分の現時点でのスタンスを書いておこうかなの気持ちになったので書くものです。

めんどくさい人間だなという自覚はあります。とにかくスライドに出てくるURLをクリックしたいという気持ちが強く、それができないスライド共有サービスに自分のスライドは置きたくないです。

最適解は、歌川さんも書いているようなPDFを置いておくWebサイトを立てておくことになるでしょうが、ちょっと面倒だなあというので外部サービスに頼っていたいです。あと埋め込みどうすればいいんだろう。

2021年07月30日
2021年06月05日

Electronで作っているPocketリーダーアプリの近況報告

screenshot

ElectronでPocketリーダーアプリを作っています | うなすけとあれこれ

PocketのリーダーアプリをElectronで開発しています。 commit logを見返すと昨年11月頃から開発を進めており、bosyuでベータテスターとして何名かの方々に試してもらってからは、2週間くらいに1度の頻度リリースを行っており、現在 v0.16.0 になっています。

開発中のアプリのベータテスターを募集します!「あとで読む」の管理などにPocketを使っている方をお待ちしています~~~~

Pocketリーダーアプリのベータテスター募集! | うなすけ @yu_suke1994
https://t.co/EHMUPSyXft #bosyu

— うなすけ (@yu_suke1994) January 9, 2021

アプリ自体は、Pocketに保存している、あとで読むための記事をサクサクと読んでいくことにフォーカスしています。キーボードのみで記事リストを上下、アーカイブし、マウスで記事そのもののスクロールができるようになっています。具体的にどのようなものかは、以下の動画で大体掴めると思います。

さてbosyuがサービスをクローズするということになったので、このタイミングで進捗報告を兼ね、Discordに移行することにしました。以下から参加することができます。

https://discord.gg/5jjPx26x25

bosyuがやりとりが2週間途絶えるとやりとりができなくなる仕様ということもあり、それが締め切りの役割となってこれまでリリースを重ねてこれた部分があると思っています。それがDiscordに移行してその制限がなくなったときに、同じようなペースでリリースをできるかどうかはちょっとわかりません。

そしてゆくゆくは何らかの形で有料化するつもりでいます。とはいえ、試しに触ってもらってお金を払うに値するかどうか決められるようになっていて欲しく、そして一体何を課金要素とするのかについては何も考えられていません。

electron-jp

少し話がずれるのですが、electon-jpというSlackがあり、一時期は盛り上がっていたのですが、今ではただのRSS feedが流れるだけの場所になってしまっており、悲しみを感じています。ぽつぽつでもいいから、また人々の交流する場になってほしいと思っています。

2021年06月05日
2021年05月17日

HTTP/3が喋れるcurlを定期的にbuildする

TL;DR

inductorさんが上のような記事を公開されています。ここで公開されているDockerfileによってbuildされたimageがあると嬉しいので、GitHub Actionsで定期的にbuildしたものをGitHub Container Registry上にホストすることにしました。

https://github.com/users/unasuke/packages/container/package/curl-http3

結論としてはこれだけなのですが、docker imageのtagについてちょっと手間取ったのでそれについて書こうと思います。

Docker imageのtagをある程度自由に書きたい

このimageをbuidするにあたり、 curl-http3 というimageに対して以下のtagを付けたいと思っていました。

GitHub ActionsでDocker buildを行いたいとなった場合には、以下のDocker公式が提供しているactionを使用するのが定石でしょう。

https://github.com/marketplace/actions/build-and-push-docker-images

そして、 docker/build-push-action@v2 を利用して動的なtagを付けようと、このようなYAMLを書きました。

- run: date +%Y-%m-%d
  id: date # 結果を参照できるようにidをつけておく
- run: foobar
- name: Build and push
  uses: docker/build-push-action@v2
  with:
    context: quiche
    push: true
    tags:
      - ghcr.io/unasuke/curl-http3:quiche-${{ steps.date.outputs.result }} # ここで dateの結果を利用したい
      - ghcr.io/unasuke/curl-http3:quiche-latest
    cache-from: type=local,src=/tmp/.buildx-cache
    cache-to: type=local,dest=/tmp/.buildx-cache-new

${{ steps.date.outputs }} というのは、 idがdateとなっているstepの出力を展開させるための記法です。

https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions#jobsjob_idoutputs

しかしここで文字列展開はできないらしく、以下のエラーによってActionを実行することができませんでした。

The workflow is not valid. .github/workflows/build.yml (Line: 32, Col: 13): A sequence was not expected .github/workflows/build.yml (Line: 70, Col: 13): A sequence was not expected

(どちらにせよ、上に書いたようなYAMLの内容では dateコマンドの結果を再利用することはできません、 ::set-output などの記法を適切に使用する必要があります)

どうすればいいでしょうか?

docker/metadata-action を使う

docker/metadata-action を使うと、ここで行いたいことができるようになります1

https://github.com/marketplace/actions/docker-metadata-action

もう一度、どのようなタグをつけたいかをおさらいします。

ここで、quiche版のみに注目して考えてみます。まず、quiche-latest については固定値なので docker/build-push-action@v2 だけでも実現できますが、今問題になっているのは、上でも述べたように quiche-2021-05-01 などのビルドした時点での日付が入っているものです。

これは、docker/metadata-action では type-schedule を指定することで実現できます。

https://github.com/marketplace/actions/docker-metadata-action#typeschedule

- uses: docker/metadata-action@v3
  id: meta
  with:
    images: ghcr.io/unasuke/curl-http3
    tags: |
      type=schedule,pattern={{date 'YYYY-MM-DD'}},prefix=quiche-
      type=raw,value=quiche-latest

- name: Build and push
  uses: docker/build-push-action@v2
  with:
    context: .
    push: true
    tags: ${{ steps.meta.outputs.tags }}

具体的には、上のような指定を書くことで quiche-2021-05-01 のようなtagを付けたimageをbuildすることができます。注意すべきは、この type=schedule で指定したtagが付与されるのはScheduled eventsの場合だけなので、pushしたタイミングで実行されるactionではtagが付きません。

他にもcommit hashを指定することもできます。

最終的に、 quiche-latestquiche-<YYYY-MM-DD>quiche-<commithash> の3種類のtagを付けるために、以下のようなYAMLを記述しました。

- uses: docker/metadata-action@v3
  id: meta
  with:
    images: ghcr.io/unasuke/curl-http3
    tags: |
      type=schedule,pattern={{date 'YYYY-MM-DD'}},prefix=quiche-
      type=raw,value=quiche-latest
      type=sha,prefix=quiche-

- name: Build and push
  uses: docker/build-push-action@v2
  with:
    context: quiche
    push: true
    tags: ${{ steps.meta.outputs.tags }}

実際に動いているWorkflowの定義は https://github.com/unasuke/curl-http3/blob/master/.github/workflows/build.yml にあります。

まとめ

HTTP/3が喋れるcurlを定期的にbuildして unasuke/curl-http3 に置いてあります。

また、GitHub Actionsにおいてdocker imageのtagをある程度柔軟に指定したい場合、 docker/metadata-action が解決策になるかもしれません。


  1. もともとは crazy-max/ghaction-docker-meta@v2 でしたが、この記事を書いている間にdocker org公式でメンテナンスされるようになっていました。すごい! https://github.com/docker/metadata-action/pull/78 

2021年05月17日
新しい投稿
古い投稿