Latin-1と0x85のなぞ
概要
Proton Calendarは、Protonが提供するプライバシーファーストのカレンダーサービスです。Proton Calendarを使うことで、スケジュールをエンドツーエンド暗号化で保護しつつ、他の人をカレンダーに招待したり、カレンダーをURLで共有したりすることができます。
Proton CalendarからURLでカレンダーを共有する場合は、iCalendar形式でスケジュールをダウンロードできます。iCalendarは改行区切りのプレーンテキストでスケジュールを記述する形式であり、改行文字としてCRLFを使わなければなりません。そのため、それ以外の改行文字がある場合はCRLFに変換する必要があります。
この記事では、iCalendarの特徴や改行文字から見た文字コードについて説明した上で、Proton CalendarのURL共有機能からダウンロードできるiCalendar形式のファイルが壊れてしまうというバグについて紹介します。なお、このバグは既に修正されているので現在は再現しません。巻末に添付した当時のiCalendarファイルを比較することで、どのようにバグが起きていたのかを体験できます。
Proton Calendarとは
Protonは、電子メールやカレンダー、クラウドストレージやVPN接続を提供するプライバシーファーストの統合サービスです。
当初はProtonMailという名前で電子メールのみを提供するサービスだったこともあり、ごく最近まで protonmail.com
というドメインで運用されていました。その後、カレンダーやクラウドストレージなどの新たなサービスを追加していくうちに、プライバシー保護のエコシステムを目指すという方向性にシフトしていきます。そして、2022年5月にはサービス全体をProtonというブランドにまとめ上げ、サイトデザインやロゴを大きく刷新しました[1]。同時に、メインのドメインもmailを含まない proton.me
に置き換えられています。
Proton Calendar はProtonが提供するカレンダーサービスです。Google カレンダーのような一般的なカレンダーサービスとは異なり、登録された予定がエンドツーエンド暗号化で保護されているのが大きな特徴です。
単に予定を悪意ある運営者から保護したいだけなら、データをローカルにのみ保存するカレンダーアプリを使えばいいかもしれません。しかし、自宅のデスクトップPCで登録した予定を外で確認したい場合には不便ですし、突然SSDがクラッシュして大事な予定が失われてしまうかもしれません。Proton Calendarなら、運営者にプライバシーを晒さずに予定を同期・保管できます。
さらに、Proton Calendarには安全性を保ちつつカレンダーを共有する機能があります[2]。共有レベルは「予定全体(Full)」または「予定の有無のみ(Limited)」から選択でき、専用のURLを介してiCalendar形式のファイルを共有できます。専用のURLとは、次のようなものです:
https://calendar.proton.me/api/calendar/v1/url/E1KR01K_mSkB4xNfWKcWxPdBI-4XgM4_9L1lor6u_M4b3W3SnYTbDOER4DxkIoqdNC-XyXS90bN2i5LQ_8si-Q==/calendar.ics?CacheKey=azCo4bm52XVbcRaKJshNNQ%3D%3D&PassphraseKey=lVBEVmWy0xKQ8c6rXPkjMNnQkeCuVJ2bgTl-M9ny9DI%3D
- カレンダーIDのようなもの:
E1KR01K_mSkB4xNfWKcWxPdBI-4XgM4_9L1lor6u_M4b3W3SnYTbDOER4DxkIoqdNC-XyXS90bN2i5LQ_8si-Q==
- CacheKey:
azCo4bm52XVbcRaKJshNNQ==
- PassphraseKey:
lVBEVmWy0xKQ8c6rXPkjMNnQkeCuVJ2bgTl-M9ny9DI=
このURLから、僕がProton Calendarに登録しているカレンダーの1つを閲覧できます。前にURLにおけるフラグメントと鍵の受け渡しでお伝えしたとおり、URLを通じて鍵を渡す場合はフラグメントを使用すべきですが、今回はファイルを直接ダウンロードする必要があるので使えません。フラグメントで鍵を受け渡す場合は、JavaScriptによる復号処理が必要となるからです。
もちろん、ゼロアクセス暗号化のようなアーキテクチャが正常に運用されていれば大きな問題はないでしょう。
iCalendarとは
さて、先ほど示したURLからiCalendar形式のファイルをダウンロードすると、以下のようなテキストを得られます。
BEGIN:VCALENDAR
PRODID:-//Proton AG//ProtonCalendar 1.0.0//EN
VERSION:2.0
BEGIN:VTIMEZONE
TZID:UTC
...
END:VTIMEZONE
BEGIN:VEVENT
UID:fPDE6TvVtlU-ZKW0agLtxHMTudmJ@proton.me
DTSTAMP:20210819T234541Z
SUMMARY:ホシノ
DTSTART;VALUE=DATE:20210102
DTEND;VALUE=DATE:20210103
SEQUENCE:0
RRULE:FREQ=YEARLY
STATUS:CONFIRMED
END:VEVENT
BEGIN:VEVENT
...
BEGIN:VEVENT
UID:lVrwGDF9KYqoY2kUbVFtfYjQMKJ7@proton.me
DTSTAMP:20211201T152307Z
SUMMARY:ナツ
DTSTART;VALUE=DATE:20201204
DTEND;VALUE=DATE:20201205
SEQUENCE:1
RRULE:FREQ=YEARLY
STATUS:CONFIRMED
END:VEVENT
END:VCALENDAR
iCalendar とは、RFC 5455で定義されたスケジュールの標準フォーマットです。 XXX:YYY
形式のレコードが改行(CRLF: \x0d\x0a
)区切りで記録されたプレーンテキストで、UTF-8がデフォルトのエンコーディングと規定されています。1行あたり最大75オクテットの制限があるため、改行直後にスペースまたはタブを入れることで前の行から継続することが可能です。
さて、改行文字としてCRLFが名指しされているのはなかなか面倒ですね。LF単体やCR単体で改行するのが一般的なシステムを使っている場合、よく注意しないと簡単にCRLF以外の改行文字が混入してしまいます。しかも、改行文字を種類ごとに可視化できるエディタを使ったり、バイナリ形式で比較しなければ間違いに気付くのは難しいでしょう。
また、改行文字として取り扱われるのはLF( \x0a
)やCR( \x0d
)だけではありません。Unicodeなら行区切り文字(LS: U+2028
)と段落区切り文字(PS: U+2029
)が使用できます[3]。これらの文字はLFやCRより使われる機会が少ないものの、iCalendar形式で記載する際はCRLFで(値としての改行なら \\n
で)置換する必要があります。
さらに、大まかにLatin-1と呼ばれるコードページISO-8859-1では、 \x85
がNext Line(NEL)と呼ばれる改行文字にマッピングされています。これはUnicodeにも引き継がれており、 U+0085
が同様にNELを表します。
ISO-8859-1とは
ISO/IEC 8859-1は、7ビットのASCIIコードを8ビット以上に拡張する独自仕様(いわゆる拡張ASCII)の乱立を受けてISOが開発した8ビットの文字集合です。ISO/IEC 8859-1では、C0領域と呼ばれる 0x00
~ 0x1f
および 0x7f
と、C1領域と呼ばれる 0x80
~ 0x9f
には文字を定義しておらず、全部で191種類の英数字と記号を利用できます。
このISO/IEC 8859-1の未使用領域65種全てに制御文字を割り当てたのが ISO-8859-1 です。Windowsでは、C1領域のマッピングが異なるWindows-1252というコードページが定義されました。かつてはこれらのコードページを取り違えて解釈してしまうせいで、C1領域の記号を制御文字として扱ってしまうことも多かったようです。
前述の通り、ISO-8859-1における \x85
は改行文字として扱われるNELという制御文字です。つまり、iCalendar形式で記載する場合はCRLF(ISO-8859-1でも \x0d\x0a
)または \\n
に変換しなければなりません。ISO-8859-1は8ビットの文字集合なので、単に \x85
を \x0d\x0a
に置き換えるだけで事足ります。
一方、UnicodeにおけるNEL( U+0085
)はUTF-8で \xc2\x85
とエンコードされるので、 \x85
を全てCRLFに置換してしまうと壊れてしまいます。 U+....
はUnicode上のコードポイントであり、UTF-8やUTF-16にエンコードして得られる実際のバイト列とは異なるものだからです。
Proton Calendarで起こっていたこと
Proton Calendarには、UTF-8で \x85
を含む文字があるとiCalendar形式のファイルが壊れてしまうというバグがありました。過去形なのは、6月1日にProtonサポートへ問い合わせてから、6月10日にはこのバグが既に修正されていたためです。
僕が認識できたバグの内容は以下の通りです。
- 初めて(十分に長い間を空けて)共有カレンダーのURLにアクセスした場合は、スケジュールがそのまま記録された完全なiCalendar形式のファイルが返ってくる。
- 複数回(十分に長い間を空けず)共有カレンダーのURLにアクセスした場合は、UTF-8のシーケンスを無視して
\x85
がCRLFに変換された状態の壊れたカレンダーが返ってくる。
ここで「UTF-8のシーケンスを無視して \x85
がCRLFに変換される」とは、以下のようなケースです。
-
光速感情をUTF-8でエンコードします。
-
\xe5\x85\x89
\xe9\x80\x9f
\xe6\x84\x9f
\xe6\x83\x85
-
-
光
と情
が\x85
を含んでいるため、CRLFに変換されます。-
\xe5\x0d\x0a\x89
\xe9\x80\x9f
\xe6\x84\x9f
\xe6\x83\x0d\x0a
-
- このバイト列をUTF-8でデコードすると、余計な改行文字が挿入されてシーケンスが壊れてしまいます。
�\r\n�速感�\r\n
おそらく、カレンダーの情報をキャッシュに出し入れするタイミングで何らかの不適切な置換処理が走っており、2回目以降は置換によって壊れたカレンダーが返されるのでしょう。バグ修正後に詳細について尋ねてみたところ、次のような回答が得られました。
The issue was connected with a regex we execute on our backend side to force the line ending to be \r\n when returning the ICS.
Protonサポートより
すると、カレンダー所有者のクライアントから受け取った暗号化済みのカレンダーをPassphraseKeyで復号するという単純な処理を想像していましたが、実際はキャッシュに置いたり復号済みのデータにちょっとした処理を行っているみたいですね。さらに質問してみたところ、Proton Calendar上のデータをiCalendar形式に変換してから、暗号化した状態でキャッシュに持っているそうです。おそらく、書き込む前か読み出した後に改行を置換するのでしょう。
The iCal data is generated from the ProtonCalendar events by decrypting all events using the query parameters from the link. Those query parameters are not stored backend side and only used during the API request processing. Once the data is generated, we do cache some encrypted components of the iCal data (using the CacheKey).
Protonサポートより
Proton Calendarのセキュリティモデルの概要については、The Proton Calendar security modelで読むことができます。共有カレンダーの性質と要件を満たすために、利便性を向上しつつプライバシーを保つアーキテクチャを採用しているようです。
なお、問い合わせのために使ったテスト用のカレンダーには、日本語では伝わりにくいと思って ⅐⅑⅒⅓⅔⅕⅖⅗⅘⅙⅚⅛⅜⅝⅞
という文字列を含む予定を登録しました。これらはUTF-8で \x85
をふんだんに含む文字で、しかも英語圏でも比較的識別しやすそうです。
現在は既にバグが修正されているため、当時の状況を再現したい場合は以下のファイルをお使いください。
まとめ
- プライバシーファーストのカレンダーサービスであるProton Calendarを使うと、鍵情報を付与したURLを通してiCalendar形式でスケジュールを共有できます。
- iCalendarは改行区切りのレコードでスケジュールを記述する形式で、改行文字をCRLFに統一しなければいけません。
- Proton CalendarがURLでカレンダーを共有する際に改行文字を統一するプロセスにバグがあり、UTF-8のシーケンスを無視した不適切な改行が挿入されていました。
転載元の「Latin-1と0x85のなぞ」はCC-BY 4.0(https://creativecommons.org/licenses/by/4.0/)でライセンスされているため、この記事についても同じライセンスが適用されます。
Discussion