Plan 9とGo言語のブログ

主にPlan 9やGo言語の日々気づいたことを書きます。

FIDO U2Fセキュリティキーを使ってSSHする

今年の5月に、GitHubがFIDO U2Fセキュリティキーを利用したSSH接続に対応したので、手元にあったTitan Security Keyで試してみました。

Titan Security Keyを買ったときの話はこちら。

SSH鍵の生成

SSHでFIDO U2Fセキュリティキーを参照するためにはOpenSSH 8.2以上が必要です。Arch Linuxでは少なくともopenssllibfido2の2つが必要なのでpacmanでインストールします。

$ sudo pacman -S openssh libfido2
$ ssh -V
OpenSSH_8.6p1, OpenSSL 1.1.1k  25 Mar 2021

また、鍵ペアはecdsa-skまたはed25519-skで作っておく必要があります。セキュリティキーを接続した状態でssh-keygenを使って作成しましょう。YubiKeyなど、この時点でセキュリティキーへのタッチを要求される場合もあるそうですが、Titan Seciruty Keyではタッチは要求されず、パスフレーズを聞かれただけでした。

$ ssh-keygen -t ecdsa-sk -C user@example.org
Generating public/private ecdsa-sk key pair.
You may need to touch your authenticator to authorize key generation.
Enter file in which to save the key (/home/lufia/.ssh/id_ecdsa_sk): 
Enter passphrase (empty for no passphrase): 
Enter same passphrase again: 
Your identification has been saved in /home/lufia/.ssh/id_ecdsa_sk
Your public key has been saved in /home/lufia/.ssh/id_ecdsa_sk.pub
SHA256:h7O7eF62nwJCHxnnelOX0QUlufa144r/gjOZWBWhmnM lufia@linux-pc
(snip)

ここで、セキュリティキーを接続せずにssh-keygenを実行すると、

Key enrollment failed: invalid format

というエラーで終了します。

ecdsa-skまたはed25519-sk(以下めんどうなのでecdsa-skだけを表記します)では、他のものと同様にid_ecdsa_skid_ecdsa_sk.pubの2つファイルを生成しますが、他のものと異なり秘密鍵はセキュリティキーに保存*1されていて、id_ecdsa_skファイルには、セキュリティキーへの参照などの盗まれても安全な情報だけが含まれるようです。id_ecdsa_skのフォーマットはデフォルトではRFC 4716 - The Secure Shell (SSH) Public Key File Formatです。

id_ecdsa_skファイルに保存されている具体的な情報については面倒なので追ってませんが、秘密鍵をセキュリティキーに要求する部分のコードはsshconnect2.c:1232-1270の辺りなので、ここでsshkey_is_sksshkey_typeするために必要な情報が含まれているのでしょう。興味があればload_identity_filesshkey_load_private_type辺りを読んでみてください。

SSH鍵を使う

ecdsa-sk鍵を用意できたら、GitHubのSettings → SSH and GPG keysに公開鍵(id_ecdsa_sk.pub)を登録します。ここはよくある手順なので省略。

登録が終わったら、以下のコマンドで確認してみましょう。

$ ssh -T -i id_ecdsa_sk git@github.com
Enter passphrase for key 'id_ecdsa_sk': ***
Confirm user presence for key ECDSA-SK SHA256:h7O7eF62nwJCHxnnelOX0QUlufa144r/gjOZWBWhmnM
User presence confirmed
Hi lufia! You've successfully authenticated, but GitHub does not provide shell access.

実行例だと分かりづらいですが、

Confirm user presence for key ECDSA-SK SHA256:h7O7eF62nwJCHxnnelOX0QUlufa144r/gjOZWBWhmnM

のところで止まるので、セキュリティキーにタッチすると先に進みます。セキュリティキーが接続されていない場合は、

sign_and_send_pubkey: signing failed for ECDSA-SK "id_ecdsa_sk": invalid format

というエラーで接続に失敗します。

感想

最近は、GitHubへのアクセスではアクセストークンを使っているし、SSHでリモートログインする機会もだいぶ減りましたが、とはいえたまに使うことはあるので、ファイルを盗まれても安全で、TOTPほど面倒でもないのはいいですね。ただ、セキュリティキーが手元にないときは何もできないので忘れないようにしましょう。

セキュリティキー便利なので使っていきたいんですが、意外と対応しているサービスは少ないですね*2AWS CLIでもFIDO U2Fセキュリティキーがサポートされたら嬉しいのですが、コンソールログインでは使えるけどCLIでは使えず、MFAデバイスは1つだけしか登録できない制約があるので、現状ではTOTPするしかないのが悲しい*3

*1:Titan Security Keyは複数の鍵を管理する機能を持っていないと思いますが

*2:Mackerelも対応していないけどそこは見なかったことに...

*3:Mackerelも1つしか登録できないけどこれも忘れてください...

デスクトップ環境をKDE 5 Plasmaデスクトップに変更した

先月の記事ではGNOMEをインストールしましたが、KDE 5 Plasmaデスクトップの方が見た目も操作感も好みだったので変更しました。WebブラウザとターミナルとPlan 9ツールの利用が主な使い方なので、どれを使ってもそんなに変わらないのですが。

やったこと

まず必要なパッケージを追加します。Waylandバックエンドを使うのでplasma-wayland-sessionも入れておきます。plasma-metaはメタパッケージといって複数のパッケージがまとまったものです。

$ sudo pacman -S plasma-meta sddm plasma-wayland-session

KDEアプリケーションはこれだけ入れました。kde-applicationsはパッケージグループで、こちらも複数のパッケージをまとめたものです。

$ pacman -Qge kde-applications
kde-applications ark
kde-applications dolphin
kde-applications gwenview
kde-applications kcalc
kde-applications konsole
kde-applications ksystemlog
kde-applications okular

pacmanのメタパッケージとパッケージグループ

上でみたように、パッケージグループとメタパッケージはどちらも複数のパッケージをまとめるものですが、利用方法は全然別のものです。

パッケージグループ

  • pacman -gフラグでグループの絞り込みができる
  • パッケージグループをインストールする時に必要なパッケージを選択できる
  • 選択したパッケージは明示的インストールとして扱う
  • グループに新しい依存パッケージが追加されても追従しない

メタパッケージ

  • パッケージ名に-metaが付く(全てではないらしい)
  • メタパッケージをインストールする時はパッケージの選択はできない
  • メタパッケージを明示的インストールとして、個別のパッケージは依存パッケージとして扱う
  • メタパッケージに新しい依存が増えた場合、メタパッケージをアップデートするとインストールされる

SDDM

ログイン画面でSDDMを使うようにします。どのディスプレイマネージャを使うのかについては、/etc/systemd/system/display-manager.serviceシンボリックリンクになっていて、SDDMの場合は参照先が/usr/lib/systemd/system/sddm.serviceとなります。必ず1つしか有効にできないので、GDMは無効にします。

$ sudo systemctl disable gdm.service
$ sudo systemctl enable sddm.service

/etc/sddm.conf.d/uid.conf

SDDMはデフォルトにより、UIDが60000以上のユーザを扱いません。だけどもsystemd-homedでユーザを管理している場合、UIDは60000より大きな値が使われるので、SDDMにユーザが表示されずログインできません。なのでUIDの最大値を変更します。

[Users]
MaximumUid=60350

/etc/sddm.conf.d/hidpi.conf

12インチMacBookRetinaディスプレイなので、HiDPIオプションも有効にしておきます。

[Wayland]
EnableHiDPI=true

[X11]
EnableHiDPI=true

Firefox

WaylandバックエンドのPlasmaデスクトップでは、FirefoxはXWaylandで動作するようですが、HiDPIディスプレイで使うと全体的にぼやけたり滲んだりといった表示になります*1FirefoxはWaylandに対応しているので、環境変数で切り替えましょう。

~/.pam_environment

+MOZ_ENABLE_WAYLAND DEFAULT=1

ところで、このファイルはpam_envによるものみたいですね。マニュアルはpam_envにありました。

ショートカットキー

Plasmaデスクトップの場合でも、GTKベースアプリケーションはgsettings(dconf)の設定を参照します。Firefoxも同様なので、以下の設定は必要です。

$ gsettings set org.gnome.desktop.interface gtk-key-theme Emacs

IBus

GNOMEの場合は何もしなくてもIBusを使えていましたが、KDEでは自分で書いてあげる必要があるようです。~/.config/autostart以下に設定を作ります。

~/.config/autostart/ibus-daemon.desktop

[Desktop Entry]
Type=Application
Name=IBus Daemon
Exec=ibus-daemon -drx --panel=/usr/lib/kimpanel-ibus-panel

~/.config以下にはautostart-scriptsディレクトリもあります。autostartは拡張子.desktopなiniファイルを管理する場所で、autostart-scriptsシェルスクリプトなどを管理するために使うようです。

KDEシステム設定

基本的にはデフォルトのまま使って慣れる派ですが、どうしても無理なものをシステム設定で変更しました。変更した箇所だけ列挙。

  • キーボードの設定でCapsLockをControlに置き替え
    • キーボード→詳細→Ctrl position/Caps LockをCtrlとして扱う
  • トラックパッドをいつも通りに設定
    • タップしてクリック
    • タップしてドラッグ
    • Tap-and-drag lock
    • スクロールの方向を反転(自然なスクロール)

ショートカット→KRunner/KRunnerで、KRunner*2の開始をCtrl+Spaceでもできるようにしようかと思ったけど、これは慣れの問題なのでAlt+Spaceのまま使うことにしました。

気になりメモ

WaylandバックエンドでPlasmaデスクトップを使う場合、スリープからの復帰や外部ディスプレイ接続などで突然クラッシュすることがあります。Waylandでの不具合などは以下でまとめられています。

GNOME関連のパッケージをアンインストールしてもgsettings list-schemasで確認するとorg.gnome.*スキーマが残っています。これらのスキーマgsettings-desktop-schemasによって追加されたもので、このパッケージはphonon-qt5-gstreamergtk3などが依存しているようでした。

*1:どうして滲むのかはHiDPI support in Chromiumで説明されていました

*2:macOSにおけるSpotlightのようなもの

12インチMacBookにArch Linuxをインストールした

手元のデスクトップ環境をLinuxに切り替えました。2009年頃からmacOS(当時はMac OS X)を使っていたけど、QEMUFUSEを不自由なく使える方がPlan 9との相性が良いので、Linuxの方がいいかなと思ったのでした。

やったこと

MacBook10,1 (Retina, 12-inch, 2017)にArch Linuxをインストールしました。このハードウェアではネットワークなど一通り使えていますが、バージョンによっては使えないケースもあるようです。MacBook Proの対応状況はState of Linux on the MacBook Pro 2016 & 2017にまとまっていますが、MacBookのものは無いので、近いハードウェアから推測する必要があります。

バックアップを取得

事前にMacBookのバックアップを取得しましょう。Time Machineがいちばんお手軽ですし、Linuxにデータを渡すことも考えるならexFATなディスクにコピーしておくと便利です。

また、macOSインストーラを用意しておくと、困った場合に安心です。Mac App StoreからOSインストーラをダウンロードすると/Applicationsインストーラが作られるので、以下のコマンドを実行します。

% sudo /Applications/Install\ macOS\ Big\ Sur.app/Contents/Resources/createinstallmedia --volume /Volumes/Mobile

詳細は、公式ドキュメントのmacOS の起動可能なインストーラを作成する方法を参照してください。

Arch Linuxインストーラを準備

Arch Linuxのイメージをダウンロードから探して、USB インストールメディアの「BIOSUEFI ブータブル USB」に書かれている手順を実行します。

% diskutil unmountDisk /dev/diskX
% sudo dd if=archlinux-2021-04-01-x86_64.iso of=/dev/rdiskX bs=1M

余談ですがこのISOイメージは、ddコマンドを使ってそのままUSBメモリに書き込むだけで使えるように作られています。xorrisoとUEFIブート再び[その1]に詳細が書かれているけれどハック感があって面白いです。

インストール

上で作ったUSBメモリからインストーラを起動して、セットアップしていきます。事前に以下のドキュメントを読んでおくといいでしょう。

MacBook Proのインストール事例だけどこれも参考になりました。

インストーラを起動するとすぐにプロンプトが表示されるので必要なコマンドを入力していけばいいのですが、MacBookの場合はどうやら画面の下数行*1が見切れてしまっているようで、コマンド入力や結果の確認が困難です。なのでカーソルが画面の中央あたりまで到達したときは、次のコマンドを入力する前にCtrl+Lclearなどでカーソルを1行目に戻してから入力すると安全です。

まずインターネットへ接続するためWi-Fiの設定をします。ドキュメントはIwdです。この設定はインストーラにおけるネットワークの設定で、再起動すると消えてしまうので、後でもう一度、同じ設定が必要です。

# iwctl
[iwd]# device list
  wlan0      dc:a9:04:xx:xx:xx       on      phy0      station
[iwd]# station dc:a9:04:xx:xx:xx scan
[iwd]# station dc:a9:04:xx:xx:xx get-networks
[iwd]# station dc:a9:04:xx:xx:xx connect SSID
[iwd]# exit

時刻合わせです。これも再起動すると消えるので、後でもう一度設定します。

# timedatectl set-ntp true

準備ができたらパーティションを分割していきます。GPTで管理するので、fdiskではなくgdiskを使います。分割自体は好みでレイアウトすればいいのですが、最初のEFIパーティションはsystemd-bootで利用するのでそのまま残しておきます。手元では、Linux用のパーティションは特に分割などせず、1つだけ作りました。また、スワップファイルの方が便利そうなので、swapパーティションは作りませんでした。

# gdisk /dev/nvme0n1
...

パーティションの用意ができたら、インストール先となるパーティション/mntにマウントします。

# mkfs.ext4 /dev/nvme0n1p2
# mount /dev/nvme0n1p2 /mnt

そのまま必須パッケージを/mntにインストールします。

# pacstrap /mnt base linux linux-firmware

/etc/fstabを更新します。

# genfstab -U /mnt >>/mnt/etc/fstab

ブートローダはsystemd-bootを使うので、EFIパーティション/mnt/bootにマウントしてからarch-chrootします。

# mount /dev/nvme0n1p1 /mnt/boot
# arch-chroot /mnt

systemd-boot

ブートローダの選択肢はいくつかありますが、Arch Linuxのドキュメントに、MacBookではいちばん簡単な方法と書かれていたのでsystemd-bootを使いました。ドキュメントはsystemd-bootです。

ここでマイクロコードも一緒にロードしておきましょう。12インチMacBookIntelのCPUなので、パッケージはintel-ucodeです。

# pacman -S systemd-boot intel-ucode
# bootctl --path=/boot install

設定ファイルを2つ作ります。/boot/loader/entries/arch.confカーネル/となるパーティションを設定するものです。UUIDの値は、blkidコマンドで/dev/nvme0n1p2の値を調べました。

title Arch Linux
linux /vmlinuz-linux
initrd /intel-ucode.img
initrd /initramfs-linux.img
options root=UUID=ca7846e4-64bc-4086-ae73-524f5aeb546e rw

ブートしたいカーネルが複数あるなら、このファイルを必要なだけ作ります。次に、ローダ自体の設定です。これは/boot/loader/loader.confに書きます。

default arch
timeout 3
console-mode max
editor no

正しく設定ができていれば、bootctlコマンドで設定した内容を読めるはずです。

# bootctl list
Boot Loader Entries:
        title: Arch Linux (default)
           id: arch.conf
       source: /boot/loader/entries/arch.conf
        linux: /vmlinuz-linux
       initrd: /intel-ucode.img
               /initramfs-linux.img
      options: root=UUID=ca7846e4-64bc-4086-ae73-524f5aeb546e rw

(この時点では、インストーラのエントリがいくつかあるけど再起動すると消える)

ネットワークの設定

上で行ったネットワークの設定をもう一度実施します。こちらは再起動した後で使われるものです。インストーラと異なり、ブート後の環境にはまだiwdパッケージがインストールされていないので、ここで追加しておきます。

# pacman -S iwd

設定自体は上のものと同じですが、以下のコマンドを使うと1行で行なえます。

# iwctl station wlan0 connect <SSID>

SSIDごとに、設定は/var/lib/iwd/<SSID>.<enc>へ保存されます。あとは、ホスト名の設定と、ブート時の再接続を入れておきましょう。

/etc/hostname

linux-pc

/etc/systemd/network/20-wlan0.network

[Match]
Name=wlan0

[Network]
DHCP=ipv4

コマンドはこのような。

# systemctl enable iwd.service
# systemctl enable systemd-networkd.service
# systemctl enable systemd-resolved.service
# networkctl list

時間の設定

NTPとタイムゾーンの設定です。

# ln -sf /usr/share/zoneinfo/Asia/Tokyo /etc/localtime
# hwclock --systohc

# timedatectl set-ntp true
# timedatectl status

言語の追加

en_US.UTF-8ja_JP.UTF-8を追加します。

# echo 'en_US.UTF-8 UTF-8' >>/etc/locale.gen
# echo 'ja_JP.UTF-8 UTF-8' >>/etc/locale.gen
# locale-gen

これ以降、/etc/locale.confLANGを設定すると日本語化されますが、フォントがない状態でされても文字が読めなくて困るだけなので、これは後で設定します。

rootのパスワード

設定しておきます。

# passwd

systemd-homed

ホームディレクトリの管理もsystemd-homedを使うようにしました。ドキュメントはsystemd-homedです。ついでにvisudoも入れておきます。

# systemctl enable systemd-homed.service
# homectl create lufia
# pacman -S vi sudo
# visudo
# gpasswd -a lufia wheel

vimパッケージもありますが、簡単なテキストの編集にしか使わないのでviパッケージで十分でした。

スワップファイルの追加

swapパーティションを作らなかったので、ここでスワップファイルを設定します。ドキュメントはスワップです。

# fallocate -l 2G /swapfile
# chmod 600 /swapfile
# mkswap /swapfile
# swapon /swapfile

/etc/fstabにエントリを追加。

/swapfile    none    swap    defaults    0 0

デスクトップ環境

2021-05-16追加: この後でKDEに切り替えました

blog.lufia.org

Waylandを使おうとしているので、色々と面倒が少なそうなGNOMEをインストールしました。

# pacman -S gnome
# systemctl enable gdm
# pacman -S noto-fonts noto-fonts-cjk

これで特に何もしなくても、再起動すればGNOMEのセッションマネージャが使えます。ログイン後に環境変数XDG_SESSION_TYPEをみると、WaylandかX11のどちらが使われているかを調べられます。XDG_SESSION_TYPEx11の場合、セッションマネージャの右下にある設定ボタンからGNOMEを選んでログインするとwaylandに変わります。

ディスプレイの設定

RetinaディスプレイHiDPIディスプレイとも呼ばれるようです。GNOMEでは、デフォルトでサイズ調整は200%と設定されていたと思いますが、好みに応じてGNOMEの設定アプリから変更しましょう。

言語の設定

上で言語の追加をしましたが、グラフィックスを使えるようになったので、必要ならここで切り替えます。

# echo 'LANG=ja_JP.UTF-8' >/etc/locale.conf

スリープでD3coldへ遷移させない

12インチMacBookでは、D3coldへ遷移すると、うまく復帰できない問題があるそうです。実際に試したところ、スリープから復帰したログイン画面でパスワードを入力しても、先へ進むことができませんでした。そのため、D3coldへの遷移を止めます。

d3cold_allowedファイルへ0を書き込むことで無効となりますが、再起動するたびに入力するのは面倒なので、systemdを使って自動化します。まずはユニットファイルを/etc/systemd/system/d3cold-disable.serviceに作りました。

[Unit]
Description=Disables sleep from stopping nvme hardware on MacBook

[Service]
ExecStart=/sbin/d3cold 0
Type=oneshot
RemainAftrExit=yes

[Install]
WantedBy=multi-user.target

ExecStart=で実行しているコマンドは以下の内容です。

#!/bin/bash

v=${1:-0}
echo $v > /sys/bus/pci/devices/'0000:01:00.0'/d3cold_allowed

これを有効にします。

$ sudo chmod +x /sbin/d3cold
$ sudo systemctl enable d3cold-disable.service

サーマルスロットリング

12インチMacBookファンレスなこともあり、Go自身のコンパイルをしていると突然スリープに落ちたりします。それでは困るので、thermald.serviceを有効にします。

# pacman -S thermald
# systemctl enable thermald.service

thermaldは、基本的に何も設定しなくても適切に動作するらしいですが、MacBookファンレスなこともあって、しばらく負荷をかけると

systemd-logind[299]: Suspend key pressed.

というログがjournaldに記録されてサスペンドします。なので明示的に/etc/thermald/thermal-conf.xmlに設定を書きます。この設定では、CPUパッケージの温度が75℃を越えたら色々な方法で温度を下げます。

<?xml version="1.0"?>
<ThermalConfiguration>
  <Platform>
    <Name>Macbook 2017</Name>
    <ProductName>*</ProductName>
    <Preference>QUIET</Preference>
    <ThermalZones>
      <ThermalZone>
        <Type>x86_pkg_temp</Type>
        <TripPoints>
          <TripPoint>
            <SensorType>x86_pkg_temp</SensorType>
            <Temperature>75000</Temperature>
            <type>passive</type>
            <ControlType>PARALLEL</ControlType>
            <CoolingDevice>
              <index>1</index>
              <type>rapl_controller</type>
              <influence>50</influence>
            </CoolingDevice>
            <CoolingDevice>
              <index>2</index>
              <type>intel_pstate</type>
              <influence>40</influence>
            </CoolingDevice>
            <CoolingDevice>
              <index>3</index>
              <type>intel_powerclamp</type>
              <influence>30</influence>
            </CoolingDevice>
            <CoolingDevice>
              <index>4</index>
              <type>cpufreq</type>
              <influence>20</influence>
            </CoolingDevice>
            <CoolingDevice>
              <index>5</index>
              <type>Processor</type>
              <influence>10</influence>
            </CoolingDevice>
          </TripPoint>
        </TripPoints>
      </ThermalZone>
    </ThermalZones>
  </Platform>
</ThermalConfiguration>

CPU 周波数スケーリングによると、他にも便利なものはあるようですが、今のところ特には困っていないのでいいかな。

/sys/devices/system/cpu/cpu?/cpufreq/scaling_governorを読むと、どのgovernorがセットされているか調べられますし、/sys/class/thermal/cooling_device?/typeでデバイスの種類が分かります。

$ grep . /sys/devices/system/cpu/cpu?/cpufreq/scaling_governor
/sys/devices/system/cpu/cpu0/cpufreq/scaling_governor:powersave
/sys/devices/system/cpu/cpu1/cpufreq/scaling_governor:powersave
/sys/devices/system/cpu/cpu2/cpufreq/scaling_governor:powersave
/sys/devices/system/cpu/cpu3/cpufreq/scaling_governor:powersave

$ grep . /sys/class/thermal/cooling_device?/type
/sys/class/thermal/cooling_device0/type:Processor
/sys/class/thermal/cooling_device1/type:Processor
/sys/class/thermal/cooling_device2/type:Processor
/sys/class/thermal/cooling_device3/type:Processor
/sys/class/thermal/cooling_device4/type:intel_powerclamp
/sys/class/thermal/cooling_device5/type:LCD

CPUクロックや温度はlscpu/sys/class/thermal/thermal_zone?/tempで読めます。tempファイルの値は、3330033.3℃のようです。

$ lscpu | grep 'CPU MHz'
CPU MHz:                             2084.082

$ grep . /sys/class/thermal/thermal_zone?/temp
/sys/class/thermal/thermal_zone0/temp:33300
/sys/class/thermal/thermal_zone1/temp:43000

再起動

ここで再起動します。ログイン画面が表示されたら、さきほど作成した一般ユーザーでログインしましょう。

ログイン後は主に個人環境に対する設定を行います。

CapsLockをControlにする

実現方法はいくつかありますが、GNOMEの設定で対応しました。もともとlv3:ralt_switchが含まれていたので、そこに追記した形です。

$ gsettings set org.gnome.desktop.input-sources xkb-options "['lv3:ralt_switch', 'ctrl:nocaps']"

サウンドの設定

MacBookでは追加のドライバが必要です。このドライバのインストールスクリプトではsymlinksコマンドが必要なので、先にAURからインストールしておきます。

$ git clone https://aur.archlinux.org/symlinks.git
$ cd symlinks
$ sudo pacman -S fakeroot
$ makepkg -si

GitHubから12インチMacBook用のドライバをインストールします。

$ git clone https://github.com/leifliddy/macbook12-audio-driver.git
$ cd macbook12-audio-driver
$ sudo ./install.cirrus.driver.sh

MacBook Pro用のドライバもありますが、こちらは12インチMacBookでは認識しませんでした。

Bluetoothの設定

12インチMacBook用のBluetoothドライバです。インストール方法はサウンドの時と同じ。

$ git clone https://github.com/leifliddy/macbook12-bluetooth-driver.git
$ cd macbook12-bluetooth-driver
$ sudo ./install.bluetooth.sh

手元にはBluetoothキーボードトラックパッドがあるので、それぞれ接続しました。ドキュメントはBluetoothにあります。

$ sudo pacman -S bluez bluez-utils
$ sudo systemctl enable bluetooth
$ bluetoothctl
[bluetooth] agent on
[bluetooth] scan on
[bluetooth] devices
[bluetooth] pair 00:00:00:00:00:00
[agent] PIN code: xxxxxx
Pairing successful
[bluetooth] connect 00:00:00:00:00:00
Connection successful

[bluetooth] pair 11:11:11:11:11:11
Pairing successful
[bluetooth] connect 11:11:11:11:11:11
Connection successful

再起動した後に自動接続されるように、/etc/bluetooth/main.confへ設定をしておきます。

[Policy]
AutoEnable=true

外部ディスプレイを繋いでいるときはスリープしない

クラムシェルとかリッドクローズドと呼ばれているものです。/etc/systemd/logind.confを以下のように変更しました。とはいえ、GNOMEによって電源管理されているようなので、この設定がどの程度有効かは分かりません。

--- /etc/systemd/logind.orig 2021-03-29 22:36:55.053860982 +0900
+++ /etc/systemd/logind.conf  2021-03-29 22:56:55.439920429 +0900
@@ -24,8 +24,8 @@
 #HandleSuspendKey=suspend
 #HandleHibernateKey=hibernate
 #HandleLidSwitch=suspend
-#HandleLidSwitchExternalPower=suspend
-#HandleLidSwitchDocked=ignore
+HandleLidSwitchExternalPower=suspend
+HandleLidSwitchDocked=ignore
 #HandleRebootKey=reboot
 #PowerKeyIgnoreInhibited=no
 #SuspendKeyIgnoreInhibited=no

トラックパッド

Waylandではlibinputが動作していて、トラックパッドを接続した後は特に何も設定する必要はありませんでしたが、タップでクリックなどいくつか変更しています。ここは完全に好みです。

$ gsettings list-recursively org.gnome.desktop.peripherals.touchpad
org.gnome.desktop.peripherals.touchpad tap-button-map 'default'
org.gnome.desktop.peripherals.touchpad click-method 'fingers'
org.gnome.desktop.peripherals.touchpad edge-scrolling-enabled false
org.gnome.desktop.peripherals.touchpad disable-while-typing true
org.gnome.desktop.peripherals.touchpad two-finger-scrolling-enabled true
org.gnome.desktop.peripherals.touchpad send-events 'enabled'
org.gnome.desktop.peripherals.touchpad speed 0.0
org.gnome.desktop.peripherals.touchpad tap-and-drag true
org.gnome.desktop.peripherals.touchpad natural-scroll true
org.gnome.desktop.peripherals.touchpad middle-click-emulation false
org.gnome.desktop.peripherals.touchpad left-handed 'mouse'
org.gnome.desktop.peripherals.touchpad tap-to-click true
org.gnome.desktop.peripherals.touchpad tap-and-drag-lock true

日本語入力

一般的に、IBusまたはFcitxのうちどちらかと、MozcまたはSKKのどちらかを組み合わせて使うことになると思います。Linuxのかな漢字変換の興亡SKKが勧められていたので、ここではibus-skkを使うようにします。Mozcの方が簡単だろうけれど、どのみちPlan 9ではSKKを使うことになるし、合わせておくのもいいかなと思ったのでした。

$ sudo pacman -S ibus-skk skk-jisyo

Waylandを使っているので、IBusを使うための環境変数~/.pam_environmentに追加します。

GTK_IM_MODULE DEFAULT=ibus
XMODIFIERS DEFAULT=@im=ibus
QT_IM_MODULE DEFAULT=ibus

また、SKKのキーボードレイアウトは日本語配列になっているので、/usr/share/ibus/component/skk.xmllayoutを変更します。

--- /usr/share/ibus/component/skk.orig   2021-03-21 00:03:32.931254445 +0900
+++ /usr/share/ibus/component/skk.xml 2021-03-21 20:26:27.483580937 +0900
@@ -17,7 +17,7 @@
            <license>GPL</license>
            <author>Daiki Ueno &lt;ueno@unixuser.org&gt;</author>
            <icon>/usr/share/ibus-skk/icons/ibus-skk.svg</icon>
-          <layout>ja</layout>
+           <layout>us</layout>
            <longname>SKK</longname>
            <description>SKK Input Method</description>
            <rank>70</rank>

せっかくなので追加の辞書を入れました。

$ git clone https://github.com/tokuhirom/jawiki-kana-kanji-dict.git
$ cd jawiki-kana-kanji-dict
$ echo ';; -*- coding: utf-8 -*-' >~/.config/ibus-skk/SKK-JISHO.jawiki
$ cat SKK-JISHO.jawiki >>~/.config/ibus-skk/SKK-JISHO.jawiki

あとは、ibus-setupの「入力メソッド」にSKKを追加して、そこへSKK-JISHO.jawikiの辞書も追加すれば終わりです。ただし、SKK-JISHO.jawikiは必ずシステム辞書として追加しましょう。ユーザー辞書として追加しても動作はしますが、候補を確定するときすべてのユーザー辞書へ更新が行なわれるらしく、確定する度に2、3秒待たされることになります。

$ ibus-setup

今の設定はこんな雰囲気。

  1. ~/.config/ibus-skk/user.dict (ユーザー辞書)
  2. /usr/share/skk/SKK-JISHO.L (システム辞書)
  3. ~/.config/ibus-skk/SKK-JISHO.jawiki (システム辞書)
  4. localhost:1178 (サーバ)

参考情報

Firefox

Firefoxを使っているので、言語パックも一緒にインストールします。

$ sudo pacman -S firefox firefox-i18n-ja

標準ではCtrl+Hキーに履歴の表示をトグルするショートカットキーが割り当てられていますが、個人的にはCtrl+Hをバックスペースとして使うのでとてもイライラするので変更します。対応するための方法はいくつかありますが、GNOME側でキーテーマをEmacs風なものに変更すると、FirefoxでもCtrl+Hでバックスペースを入力できるようになります。

$ gsettings set org.gnome.desktop.interface gtk-key-theme 'Emacs'

これで、Ctrl+Aで行頭にカーソルを移動させられたりなど好みの挙動になりました。それぞれのキーに何が割り当てられているかは、/usr/share/themes/Emacs/gtk-3.0/gtk-keys.cssを読むとなんとなく分かると思います。

ターミナル

FirefoxCtrl+CでコピーするけどターミナルではCtrl+Shift+Cでコピーするのは、どちらかに統一したいですね。ターミナルはCtrl+Cで割り込みを発生させるので難しいかなと思っていたけれど割り込みはCtrl+Alt+Cでも起こせたので、以下のコマンドでキーマップを変更しました。

$ gsettings set org.gnome.Terminal.Legacy.Keybindings:/org/gnome/terminal/legacy/keybindings/ copy '<Control>c'
$ gsettings set org.gnome.Terminal.Legacy.Keybindings:/org/gnome/terminal/legacy/keybindings/ paste '<Control>v'

これで、どのアプリでも、Ctrl+CでコピーしてCtrl+Vで貼り付けができる。

クリップボード

クリップボードにはPRIMARY*2CLIPBOARDの2種類あるようで、テキストを選択してマウスの中ボタンで扱うような暗黙的なコピーはPRIMARYを参照して、明示的にコピーをする動作はCLIPBOARDを参照するようです。日本語版のWikiより英語版の方が詳しいのでClipboardのSelectionを読みましょう。GNOMEPrimary Selectionにも色々と書かれています。

Waylandでコマンドラインからクリップボードを操作するためにwl-clipboardを入れておきます。

$ sudo pacman -S wl-clipboard

別件で、最後に選択したテキストがマウスの中ボタンでペーストされてしまうのは誤操作の原因になるので止めてしまいます。

$ gsettings set org.gnome.desktop.interface gtk-enable-primary-paste false

Firefoxは別の設定になるようです。about:configmiddlemouse.pastefalseに切り替えます。

バックアップ

rsyncでコピーする方法がRsync によるフルシステムバックアップで紹介されていますが、rsyncは転送先でそのままファイルとして扱えるため便利ではあるものの、転送先のファイルシステムPOSIX準拠でない場合に色々な問題が発生するのであまり使いたくはありません*3。なのでBorg Backupを使ってみることにしました。exFATでフォーマットされているMOBILEディスクにbackupsディレクトリを作成して、それをBorgのリポジトリにします。borg init -e repokeyで暗号化しています。また、紛失しないようにリポジトリの鍵をエクスポートして保管しておきます。

$ sudo pacman -S borg
$ borg init -e repokey /run/media/lufia/MOBILE/backups
$ borg key export /run/media/lufia/MOBILE/backups repo.key

上では手元のディスクを使いましたが、Google Cloudを使っているならCloud Storage FUSEの方が便利かもしれません。

$ git clone https://aur.archlinux.org/gcsfuse.git
$ cd gcsfuse
$ sudo pacman -S fakeroot # なければ入れる
$ makepkg -si

これで後はバックアップを作成するだけです。毎回コマンドを組み立てるのは面倒なので以下のスクリプトを書きました。

#!/bin/bash

set -eu

if [[ $# -ne 1 ]]
then
    echo usage: $(basename $0) repo >&2
    exit 1
fi
h=$(hostnamectl --static)
d=$(date +%Y-%m%d)
sudo borg create --progress "$1::$h-$d" / --exclude-from ~/lib/borg.exclude

コピーする必要のないファイルもあるので、borg.excludeシステムメンテナンスを参考にこのような内容です。

/dev/*
/proc/*
/sys/*
/tmp/*
/run/*
/mnt/*
/media/*
/lost+found
/var/run/*
/swapfile
/home/*.home
/home/*/.cache
/home/*/.local/share/Trash/*
/home/*/Downloads/*

ところで、systemd-homedを使っていると、/home/$user.homeに大きなファイルが作られています。これはホームディレクトリのLUKSイメージらしく、ユーザーがログインするなどでアクティベートされると/home/$userにマウントされます*4。このイメージは、手元では300GBほどあって、毎回のバックアップで対象とするには大きく時間がかかります。上のスクリプトではどうせ一人しか使わないので、必ずホームディレクトリはマウントされているものとして扱うことにしました。ちなみにアクティベートされたユーザはhomectl listすると分かりますし、homectl activateでアクティベートするようです。

$ homectl list
NAME  UID   GID   STATE  REALNAME HOME        SHELL    
lufia 60331 60331 active lufia    /home/lufia /bin/bash

1 home areas listed.

参考情報

日常の利用

感想など

トラックパッドに慣性スクロールが欲しいなど、まだ気になるところはありますが、これならデスクトップ環境として使えると思えるところまで設定しました。購入して電源を入れるだけで快適に使えるmacOSはすごいなと思うものの、一度設定してしまえばLinuxでもそんなに困らないはずだし、癖や好みの話でもあるので、しばらくこのままLinuxデスクトップで生活してみる予定です。

*1:10行程度?

*2:SECONDARYもあるらしい

*3:symlinkの未サポートやファイル名のcase-insensitiveなど

*4:イメージの種類によりファイル名の末尾が.homedirなどに変わる

Plan 9の権利がPlan 9財団に譲渡され、MITライセンスに変わりました

2021年3月23日に、ノキアから、Plan 9著作権Plan 9財団に譲渡する発表がありました。

www.bell-labs.com

翻訳はこちら。

okuranagaimo.blogspot.com

元々、AT&Tの一部門が独立してLucent Technologiesとなっていて、ベル研はそこに含まれていたけれども、いつの間にかノキアに買収されていたらしいですね。

何が変わったのか

以前のPlan 9はLucent Public License 1.02*1でライセンスされ、ベル研(plan9.bell-labs.com)により配布されていました。ユーザーはreplicaコマンドを使ってベル研の中央サーバからアップデートを取得し、必要ならサーバへパッチを送ることが行われていましたが、2015年1月を最後に、Plan 9のメンテナンスを行う人がいなくなり、しばらくしてからは*2ベル研のサービス自体も参照ができなくなっていました。

代わりに、0introさんにより9p.ioでplan9.bell-labs.comのミラーが提供されるようになり、今ではドキュメントの参照など基本的にこちらが使われるようになっています。他に、9legacyというパッチ集や9frontといったフォークが派生して今もそれぞれ活動していますが、9p.ioが配布する公式のソースコード自体は2015年のものから変わっていませんでした。余談ですがGoがサポートしているPlan 9は9legacyのことです。Go DashboardPlan 9イメージは9legacyの最新版を参照しています。

このように、ここ数年は残念な状況でしたが、今回の譲渡でPlan 9の権利はPlan 9財団へ譲渡されて、Foundation Activitiesによると9legacyのパッチが本家に取り込まれる告知が出ていました。

Preparing for a new release, including patches.

今後の運用がどうなるのかはわかりませんが、Goもplan9portもGitHubで運用されているので、Plan 9もそうなったら嬉しいですね。

Plan 9財団

Plan 9財団は2021年2月に、9fansでアナウンスされました。Plan 9界隈でよく名前を聞く人がメンバーになっています。

9fansでのアナウンス

Twitterアカウントはこちら

この財団は、現状ではPlan 9本体(従来はオープンではなかった1st〜3rd editionも公開されている!)のホストと、GSoCへの参加などを行なっているようです。Activityを見る限りでは2020年から活動していたようですが、まあ、2020年のメールにある署名は強い....

Forkはどうなるか

ここは完全に推測ですが、少なくとも9legacyのパッチは取り込まれることが告知されているので、9legacyは本家Plan 9に統合されそうです。9frontは、部分的にはバックポートされるかもしれないけれども、実装的にも思想的にもベル研Plan 9との差が大きくて、完全なマージは無理じゃないかな、と思っています。とはいえ9frontの方が進んでいるのは事実なので、前衛的な9frontと慎重な本家に分かれるんじゃないかな。plan9portは、以前rscさんが9fans/plan9port

Sorry, but plan9port is only aiming for compatibility with the original Plan 9, not the many forks.

と言っていたけど実際はいくつか拡張されているので、互換性を維持しながら変化していくかもしれません。Raspberry Piへの移植(9pi)は分からないけれど、これは9legacyに近いものなので、millerさんが望めばマージされるかもですね。

*1:SCOのゴタゴタがあってアップデートされた記憶がある

*2:2017年頃かな

Goアセンブリの書き方

Goアセンブリの書き方からビルド方法までを一通り調べました。Goアセンブリを書いたことのない人がコードを書いてリンクできるところまでは一通り書いているつもりですが、Goアセンブリの言語仕様を網羅してはいないので、興味があれば最後に書いた参考情報も読んでみてください。

この記事ではGo 1.16.xでAMD64命令セットを扱いますが、具体的な命令や値のサイズ以外は、他のアーキテクチャを使う場合でもだいたい同じだと思います。

アセンブリコードの書き方

GoのアセンブリPlan 9アセンブリを概ね踏襲していて、AT&T記法です。整数を受け取って、それに2を加算した値を返す関数func add2(i int32) int32を書いてみましょう。アセンブリのコードは.sファイルに書きます。また、アセンブリアーキテクチャに強く依存するので、Goの習慣にしたがってファイル名にはアーキテクチャ名も入れておきましょう。

func_amd64.s

TEXT ·add2(SB),$0-12
    MOVL i+0(FP),AX    // 引数iをAXレジスタに
    ADDL $2, AX        // 2を加算
    MOVL AX, ret+8(FP) // 計算結果を戻り値として返す
    RET

馴染みのない場合はおそらく何もわからないと思うので、少し丁寧に書きます。

Goのアセンブリでは、関数はTEXTディレクティブで開始します。上の関数は·add2という名前ですが、名前の頭に奇妙な·(middle dot/center dot; 0u00B7)が付いています。これはパッケージ名を区切る文字で、本来はmain.add2となりますが、アセンブリでは.(dot)が名前に使えないので、代わりに·を使っています*1macOSの場合、この文字はOpt+Shift+9で入力できます。Plan 9やplan9portならAlt..と順番にキーを叩けばいいです*2。そしてパッケージ名を省略するとリンク時に補完されるので、最終的にこの命令はmain.add2関数を定義することになります。関数名の後ろについている(SB)ですが、SBはStatic Base擬似レジスタで、プログラムアドレス空間の先頭オフセットを表します。「SBからの特定オフセットにmain.add2という名前をつけて以降の命令を関数ボディとする」が正確な意味らしいですが、関数を定義する場合は必ずこのように書く、と覚えてしまっていいと思います。

これでTEXT ·add2(SB)まで説明しました。その後に$0-12と続いている値は、演算ではなく$<スタックフレームのサイズ>-<引数と戻り値のサイズ>を表します。add2関数は、ローカル変数を持たないのでスタックフレームのサイズは0です。次に引数と戻り値のサイズですが、int32が2つなので8になると考えてしまいますが、GoのABIは戻り値の開始位置はワード長でアラインされるようです。AMD64のワード長は8なので、引数部分が(4+align)で8バイト、戻り値はそのまま4で合計12です。

次に MOVL i+0(FP),AX という行は、MOVLは32bitの数値を左から右へコピーする命令です。MOVxは他にもいくつかあり、それぞれ

  • MOVB(Byte): 8bit
  • MOVW(Word): 16bit
  • MOVL(Long): 32bit
  • MOVQ(Quad): 64bit
  • MOVO(Octo): 128bit

の値を扱います。Goのアセンブラでは、MOVxに限らず他の命令も、上記のような末尾文字で扱う値のサイズを決定します。命令の時点でサイズが決まるため、RAXEAXなどレジスタを使い分ける必要はありませんが、8bitを扱う場合はAHALも使えます。命令の後に続くi+0(FP)という表記は、FP擬似レジスタのオフセット0という意味です。i+の部分はシンボル名で、エラーを検出するため、FPレジスタを参照する場合はシンボル名が必須です。

最後に、コード中に現れる$nは即値です。この$TEXTディレクティブの最後に現れるものとは異なります。$(4+1)のように演算も行えます。

ビルドしてみる

では次に、この関数を使ってみましょう。参照する側は1箇所を除いてよくあるGoのコードです。

main.go

package main

import "fmt"

func add2(i int32) int32

func main() {
    i := add2(20)
    fmt.Println(i)
}

宣言だけのadd2関数がありますが、この宣言により、アセンブリで記述したmain.add2をGoの関数として参照できるようになります。ビルドする方法は普段通りgo buildです。

% go build -o a.out
% ./a.out
22

このとき、関数宣言に書く引数の名前はi+0(FP)の名前と合わせておきましょう。異なった名前にすると、go vetにより

% go vet
# asm
./func_amd64.s:2:1: [amd64] add2: unknown variable i; offset 0 is ix+0(FP)
./func_amd64.s:3:1: [amd64] add2: 8(SP) should be ix+0(FP)

のように怒られます。戻り値が1つの場合はデフォルトのretが使えますが、多値を返す場合は名前をつけましょう。

GoのABI(Application Binary Interface)

ABIとは、関数やシステムコールの呼び出し規約などの総称です。ABIについては以下の記事が分かりやすいなと思いました。

satoru-takeuchi.hatenablog.com

上のコードで見たように、Goとアセンブリの関数はスタックを経由して引数と戻り値を渡します。例えばmain

var i int32

i = 20
i = add2(i)

のようなコードがあった場合、add2を呼び出す前のスタック例(アドレスは適当)は

 ↑メモリの先頭アドレス
 0x120..0x127 [main関数から戻る際のリターンアドレス]
 0x128..0x12c [main関数の変数iが利用しているスタック領域]
 ↓メモリの末尾アドレス

のように積まれていますが、add2を呼び出すと

 ↑メモリの先頭アドレス
+0x100..0x107 [add2関数から戻る際のリターンアドレス]
+0x108..0x10b [add2関数の引数iで利用しているスタック領域]
+0x10c..0x10f [未使用(アラインメント)]
+0x110..0x113 [戻り値を書き込むスタック領域]
+0x114..0x117 [未使用(アラインメント)]
+0x118..0x11f [関数呼び出し前のBPレジスタ値]
 0x120..0x127 [main関数から戻る際のリターンアドレス]
 0x128..0x12c [main関数の変数iが利用しているスタック領域]
 ↓メモリの末尾アドレス

のように利用状況が変わります。このとき、SPレジスタは0x100を指し、FPレジスタは0x108を指します。なのでi+0(FP)とすると最初の引数を参照できますし、上では使っていませんが2つ目の引数があればj+4(FP)などと書けます。

SPレジスタとFPレジスタ

引数や戻り値を扱うとき、上ではFP擬似レジスタを使いましたが、go tool compile -Sgo build -gcflags=-Sで生成したアセンブリコードはSP擬似レジスタを使って引数などを参照します。i+0(FP)i+8(SP)は結局どちらも同じ場所を指すので、どちらを使っても支障はありませんが、FPレジスタの場合はアラインメント間違いなどを検出してエラーにしてくれるので、手書きする場合は基本的にFPレジスタを使う方が良いと思います。

ただし、複雑なのですがi+8(SP)8(SP)は必ずしも同じ場所を指すとは限りません。ハードウェアがSPレジスタを持つ場合、SP擬似レジスタを扱う場合はi+8(SP)のようにシンボル名が必須で、8(SP)のようにシンボル名を省略するとハードウェアレジスタを扱うことになります。

文字列とスライス

Goから引数として文字列またはスライスをアセンブリのコードに渡す場合、文字列の場合はポインタと長さ、スライスの場合はポインタと長さとキャパシティの3つが渡されます。

func str(s string)
func slice(p []byte)

の場合、アセンブリから参照する場合は以下のようになります。

TEXT ·str(SB),$0-16
    MOVQ s+0(FP),AX
    MOVQ s_len+8(FP),CX
    RET

TEXT ·slice(SB),$0-24
    MOVQ p+0(FP),AX
    MOVQ p_len+8(FP),CX
    MOVQ p_cap+16(FP),DX
    RET

独自型を使う場合

独自に定義した構造体などを受け渡しする場合、アセンブリから#include "go_asm.h"すると、それぞれのサイズやオフセットを定数で扱えるようになります。

type Point struct {
    X int
    Y int
}

func addpt1(p Point) Point

func main() {
    fmt.Println(addpt1(Point{X: 1, Y: 2}))
}

この場合、addpt1は以下のように書けます。定数を使うと、途中にフィールドが追加されたり、順番が変わったりしても安心ですね。

#include "go_asm.h"

TEXT ·addpt1(SB),$0-32
    MOVQ p+Point_X(FP),AX
    MOVQ p+Point_Y(FP),CX
    ...

go buildする場合、事前にgo_asm.hを用意する必要はなく、コンパイラが裏で作ってくれます。go_asm.hにどのような値が定義されるのか気になる場合は、以下のコマンドでgo_asm.hを出力できます。

% go tool compile -asmhdr go_asm.h *.go
% cat go_asm.h                                     
// generated by compile -asmhdr from package main

#define Point__size 16
#define Point_X 0
#define Point_Y 8

ここには現れてませんが、constで定義した定数も扱ってくれるようです。

他の関数を呼ぶ

ここまでで、Goの関数からアセンブリのコードを呼ぶ方法を書きました。今度はアセンブリ側からGoの関数を呼ぶ場合の手順です。結局は、呼び出す側で

 ↑メモリの先頭アドレス
+0x100..0x107 [add2関数から戻る際のリターンアドレス]
+0x108..0x10b [add2関数の引数iで利用しているスタック領域]
+0x10c..0x10f [未使用(アラインメント)]
+0x110..0x113 [戻り値を書き込むスタック領域]
+0x114..0x117 [未使用(アラインメント)]
+0x118..0x11f [関数呼び出し前のBPレジスタ値]
 0x120..0x127 [main関数から戻る際のリターンアドレス]
 0x128..0x12c [main関数の変数iが利用しているスタック領域]
 ↓メモリの末尾アドレス

のようなメモリレイアウトを作る必要があります。例として、

func neg(i int32) int32 {
    return -i
}

をadd2から呼んで、2つ目の戻り値としてその結果を返すコードを書いてみましょう。Go側のプロトタイプは

func add2(i int32) (ret1 int32, ret2 int32)

に変更します。FPから戻り値のオフセットを参照するため、名前をつけているところにも気をつけてください。以下アセンブリのコードです。Goアセンブリでは、PUSH命令やPOP命令を使わずSPを直接操作します。

TEXT ·add2(SB),$24-16
    // 1つ目の値は引数に+2するだけ、今までと同じ
    MOVL i+0(FP), AX
    ADDL $2, AX
    MOVL AX, ret1+8(FP)

    SUBQ $24, SP    // neg関数の引数と戻り値サイズ+BPレジスタの退避先を確保
    MOVQ BP, 16(SP) // 現在のBPレジスタをpush
    LEAQ 16(SP), BP // BPレジスタを新しいスタックに更新
    MOVQ AX, (SP)   // 最初の引数iを渡す
    CALL ·neg(SB)   // main.negを呼ぶ
    MOVL 8(SP), AX  // main.negの戻り値をAXレジスタに取り出す
    MOVQ 16(SP), BP // 退避していたBPレジスタをpop
    ADDQ $24, SP    // スタックサイズを戻す
    MOVL AX, ret2+12(FP) // 2番目の戻り値として返す
    RET

ところで、気づいた人もいるかもしれませんが、CALL命令を実行する前はSPを24バイト減算していて、0(SP)が最初の引数になっています。しかしmain.neg8(SP)が最初の引数であることを期待します。この差は何なのかというと、CALL命令が暗黙的に、SPレジスタへリターンアドレスをpushしていることによるものです。RETで戻ると、リターンアドレス分がpopされて、SPレジスタCALLする前の値へ戻ります。

また、他に注意した方が良い話題は、AMD64アーキテクチャの場合BPレジスタの値に連動してFP疑似レジスタも変わります。そのため、ADDQ $24, SPでスタック位置を戻した後で戻り値をメモリに書き出す必要があります。逆にしてしまうと、意図しないメモリを更新することになります。

ABI0とABIInternal

これまで、Goとアセンブリのコードがどのように値を交換するのかをみてきました。基本的にスタックを経由してそれを行いますが、これはABI0というルールに則ります。

Go 1.16時点では、利用できるABIはABI0しかありません*3が、以下のプロポーザルによるとレジスタを使った値渡しのABIも検討されているようです。これが安定すれば、ABI1として利用可能になるかもしれません。

ABIの調べ方

上記以外に、errorinterface{}など色々な型を渡したくなると思います。またはポインタをGCで管理したくなるかもしれません*4。その場合、go tool compile -Sまたはgo build -gcflags=-Sを使うと、コンパイルした結果を出力してくれるので、そうやって出力されたアセンブリのコードを眺めると良いかもしれません。ただし、最適化によって関数がインライン化される場合もあるので、//go:noinlineコメントで展開をしないようにコメントしておくといいでしょう。

% cat main.go
package main

import (
    "fmt"
    "io"
)

//go:noinline
func isEOF(err error) bool {
    return err == io.EOF
}

func main() {
    fmt.Println(isEOF(fmt.Errorf("error")))
}

実行結果の例です。長いので最初数行だけ。

% go tool compile -S main.go
"".isEOF STEXT size=105 args=0x18 locals=0x28 funcid=0x0
    0x0000 00000 (fn.go:9)  TEXT "".isEOF(SB), ABIInternal, $40-24
    0x0000 00000 (fn.go:9)  MOVQ (TLS), CX
    0x0009 00009 (fn.go:9)  CMPQ SP, 16(CX)
...

または、go tool objdump.oや実行ファイルからアセンブリコードを出力できますが、特に実行ファイルをgo tool objdumpした場合はとても長くなるので、目的の行を探すのは少し面倒かもしれません。

NOSPLITディレクティブ

ところで、標準パッケージのコードを見ると、アセンブリで書かれた関数のほとんどでNOSPLITディレクティブ(フラグ)をセットしています。

#include "textflag.h"

TEXT ·func(SB),NOSPLIT,$0
    ...

このNOSPLITruntime/textflag.h#defineされている値です。A Quick Guide to Go's Assemblerで、利用できるディレクティブが列挙されています。

#define NOSPLIT 4

通常、ゴルーチンは固有のスタックを持っていて、その初期サイズは決まっています*5。関数を呼び出したとき、コンパイラは「SPレジスタのアドレスと現在のスタック上限を比べて必要なら拡大する」処理を埋め込みますが、NOSPLITはこの動作を抑制するものです。実際に埋め込まれるコードは、この記事の最後に参考情報として挙げたGoアセンブリ入門によると、

again:
    MOVQ    (TLS), CX
    CMPQ    SP, 16(CX)
    JLS morestack // JBEと同じ意味
    ...
morestack:
    CALL    runtime.morestack_noctxt(SB)
    JMP again

といったコードが挿入されるそうです。また、$GOROOT/src/runtime/stack.goのコメントでは、スタックの大きさによって3通りに分岐することが書かれていました。該当部分を引用します。

guard = g->stackguard
frame = function's stack frame size
argsize = size of function arguments (call + return)

stack frame size <= StackSmall:
    CMPQ guard, SP
    JHI 3(PC)
    MOVQ m->morearg, $(argsize << 32)
    CALL morestack(SB)

stack frame size > StackSmall but < StackBig
    LEAQ (frame-StackSmall)(SP), R0
    CMPQ guard, R0
    JHI 3(PC)
    MOVQ m->morearg, $(argsize << 32)
    CALL morestack(SB)

stack frame size >= StackBig:
    MOVQ m->morearg, $((argsize << 32) | frame)
    CALL morestack(SB)

では次に、どういった場合にアセンブリで書いた関数へNOSPLITディレクティブ(フラグ)をセットすると良いのでしょうか。個人的には、正しい基準はあまり分かっていません。一切スタックを使わずCALLもしない関数を除いて、基本的にはNOSPLITを付与しないほうが安全に思えます。とはいえ公式のコードはほとんど全てNOSPLITを与えているし、$GOROOT/src/cmd/internal/obj/x86stacksplitprocessesを読むと、

// この定数は、実際は$GOROOT/src/cmd/internal/objabiで定義されている
const (
    StackSmall = 128
    StackBig = 4096
)

// これは擬似コードです
func processes(ctx, cursym, newprog) {
    p := cursym.Func().Text
    autooffset := p.To.Offset
    ...
    if autooffset < objabi.StackSmall && !p.From.Sym.NoSplit() {
        leaf := true
        if [CALL命令で引数が1つ以上ある関数を呼んでいる] {
            leaf = false
        }
        if [DUFFCOPY, DUFFZEROが使われている && autooffset >= objabi.StackSmall-8] {
            leaf = false
        }
        if leaf {
            p.From.Sym.Set(obj.AttrNoSplit, true)
        }
    }
    ...
}

のようにNOSPLITを付与しているので、利用するスタックサイズが小さくCALL命令も使わない関数はNOSPLITを与えておくと良いかもしれません。

(雑談)go buildを使わずビルドする

基本的にはgo buildを使うだけで十分なんですが、裏で何が行われているのかを知っておくと便利なこともあるかもしれないので紹介します。

まず、go_asm.hがなければ定数が扱えないので、Goのソースコードからgo_asm.hを生成する必要があります。これはgo tool compileで行います。

% go tool compile -asmhdr go_asm.h *.go

生成したら、アセンブリで書いたコードをビルドしましょう。.sに対応する.oファイルが作られたら正常です。go tool nmコマンドで、.oに定義されたシンボルや未解決のシンボルなどを調べられます。

% go tool asm -p main func_amd64.s
% go tool nm func_amd64.o

go tool nmが出力する2番目のフィールドはシンボルのタイプです。

  • T テキストシンボル
  • U 未解決のシンボル

などいくつかあり、それらはgo doc cmd/nmにまとめられています。

次に、アセンブリのコードがどのABIを利用しているか、をファイルとして用意しておく必要があります。今は手書きするコードなら全てABI0ですが、上で触れたように新しいABIが追加されるかもしれません。これはgo tool asmで行います。

% go tool asm -gensymabis -o symabis *.s
% cat symabis
def "".add2 ABI0

上で作ったsymabisgo tool compileに渡します。これを渡しておかないと、

main.main: relocation target main.add2 not defined for ABIInternal (but is defined for ABI0)

のようなエラーでgo tool linkが失敗します。

% go tool compile -symabis symabis -p main main.go

上で作ったオブジェクトファイル(.oファイル)を.aファイルにまとめます。cは新しくアーカイブファイルを作るというオプションです。

% go tool pack c main.a *.o

最後にリンクして終わり。

% go tool link main.a

一通りの手順をMakefileに書くとこのようになります。-Pオプションでパッケージ名を与えてますが、どちらでも良いと思います。

PKG=main
TARG=a.out

.PHONY: all
all: $(TARG)

$(TARG): main.a
  go tool link main.a

main.a: main.o func_amd64.o
  go tool pack c $@ $^

main.o: main.go symabis
  go tool compile -symabis symabis -p $(PKG) $<

func_amd64.o: func_amd64.s go_asm.h
  go tool asm -p $(PKG) $<

func_amd64.s: go_asm.h

symabis: *.s
  go tool asm -gensymabis -o $@ $^

go_asm.h: *.go
  go tool compile -asmhdr $@ $^

.PHONY: clean
clean:
  rm -f *.o *.a

.PHONY: nuke
nuke:
  rm -f *.o *.a $(TARG) symabis go_asm.h

最後に、ここで書いたgo toolコマンドで生成するオブジェクトは完全にgo buildと同じではありません。go buildの場合は同一パッケージに複数のfunc init()があってもビルドが通るように調整などがされるようなので、基本はgo buildを使うと良いでしょう。

おわり。

参考情報

物理本ですが、The Plan9 Assembler Handbookも参考になります。GoのアセンブラPlan 9のものを下地にしているので、大部分はそのまま役に立ちます。意外とインターネットにはまとまった情報がないので、個人的にはとてもおすすめ。

*1:同様に/も使えないので(division slash; 0u2215)で代用します

*2:lib/keyboardにリストがある

*3:runtimeパッケージなどではABIInternalもある

*4:FUNCDATAPCDATAを使うようです

*5:AMD64の場合は4KBらしいですね

Google Cloud Client Library for Goでのリトライとエラー

Google Cloud Client Library for Goはデフォルトでリトライするので、あまり意識する必要はないと思いますが、場合によってはリトライを細かく制御したくなることはあるかもしれません。この記事では、リトライのために必要そうなオプションをまとめました。

リトライ

クライアントライブラリで実装された一部のAPI、例えばmonitoring.MetricClient.ListTimeSeriesなど、可変長引数でgax.CallOptionを取るものがあります。

import (
    "context"

    gax "github.com/googleapis/gax-go/v2"
    monitoringpb "google.golang.org/genproto/googleapis/monitoring/v3"
)

func (c *MetricClient) ListTimeSeries(ctx context.Context, req *monitoringpb.ListTimeSeriesRequest, opts ...gax.CallOption) *TimeSeriesIterator

これらの関数はgax.CallOptionとしてリトライオプションを持っていて、gax.WithRetryRetryerを渡すことでオプションを生成します。Functional Optionパターンな実装になっていますね。

package gax

import "time"

type CallSettings struct {
    // Retry returns a Retryer to be used to control retry logic of a method call.
    // If Retry is nil or the returned Retryer is nil, the call will not be retried.
    Retry func() Retryer

    // CallOptions to be forwarded to GRPC.
    GRPC []grpc.CallOption
}

type CallOption interface {
    Resolve(cs *CallSettings)
}

type Retryer interface {
    Retry(err error) (pause time.Duration, shouldRetry bool)
}

func WithRetry(fn func() Retryer) CallOption

Retryerを自分で全部実装するのは意外と面倒なんですが、指数バックオフを行うRetryerはクライアントライブラリが用意してくれているので、これを使うといいでしょう。

import (
    "time"

    gax "github.com/googleapis/gax-go/v2"
    "google.golang.org/grpc/codes"
)

var RetryableCodes = []codes.Code{
    codes.Canceled,
    codes.Unknown,
    codes.DeadlineExceeded,
    codes.ResourceExhausted,
    codes.Aborted,
    codes.Internal,
    codes.Unavailable,
    codes.DataLoss,
}

func DefaultRetryOption() gax.Retryer {
    // This configuration performs to retry 3 times; 200ms, 400ms, 800ms
    return gax.OnCodes(RetryableCodes, gax.Backoff{
        Initial:    200 * time.Millisecond,
        Max:        1 * time.Second,
        Multiplier: 2.0,
    })
}

c.ListTimeSeries(ctx, &monitoringpb.ListTimeSeriesRequest{...}, gax.WithRetry(DefaultRetryOption))

ところで、上に引用したCallSettingsのコメントでは、Retrynilの場合はリトライしないと書いていますが、monitoring.MetricClientなどクライアントライブラリで実装されたクライアントは、デフォルトでリトライオプションを持っているので、デフォルトで支障がなければオプションを渡す必要はありません。具体的には、上で例に挙げたListTimeSeriesのデフォルトは以下のような内容です。

import (
    "time"

    gax "github.com/googleapis/gax-go/v2"
    "google.golang.org/grpc/codes"
)

gax.WithRetry(func() gax.Retryer {
    return gax.OnCodes([]codes.Code{
        codes.DeadlineExceeded,
        codes.Unavailable,
    }, gax.Backoff{
        Initial:    100 * time.Millisecond,
        Max:        30000 * time.Millisecond,
        Multiplier: 1.30,
    })
})

このデフォルト値は、それぞれのクライアント構造体メンバー変数に持っています。以下はmonitoring.MetricClientの例ですが、他のクライアントもだいたい同じ作りになっているようにみえます。

type MetricClient struct {
    CallOptions *MetricCallOptions
}

type MetricCallOptions struct {
    ListMonitoredResourceDescriptors []gax.CallOption
    GetMonitoredResourceDescriptor   []gax.CallOption
    ListMetricDescriptors            []gax.CallOption
    GetMetricDescriptor              []gax.CallOption
    CreateMetricDescriptor           []gax.CallOption
    DeleteMetricDescriptor           []gax.CallOption
    ListTimeSeries                   []gax.CallOption
    CreateTimeSeries                 []gax.CallOption
}

エラーの詳細を取得する

Retryerでリトライしても解決しない場合、エラーの内容によって、メッセージキューに戻すか破棄するか、など判断したいことがあるかもしれません。その場合、クライアントライブラリはほとんどの場合にgRPCのエラーを返すので、status.FromErrorなどを使うとgRPCのステータスコードなど詳細を調べられます。

import "google.golang.org/grpc/status"

s, ok := status.FromError(err) // status.Convertでも良い
if !ok {
    return
}
code := s.Code()

ただし、アカウントの認証に失敗したとか、APIが無効になっているなどgRPCへ到達する前のエラーが発生することもありますが、こういったエラーではstatus.FromErrorを使っても詳細を取得できません。その場合はgoogleapi.Errorを使うと、HTTP/1.1ステータスコードなどの詳細な情報を確認できます。

import (
    "errors"

    "google.golang.org/api/googleapi"
)

var e *googleapi.Error
if errors.As(err, &e) {
    return e.Code
}

codes.Codeのコメントによると、gRPCのエラーコードはHTTP/1.1のステータスコードに置き換え可能なので、まとめて扱えるようにしておくと便利かもしれませんね。

import (
    "errors"
    "net/http"

    "google.golang.org/api/googleapi"
    "google.golang.org/grpc/codes"
    "google.golang.org/grpc/status"
)

// https://github.com/googleapis/googleapis/blob/master/google/rpc/code.proto
var codeMappings = map[codes.Code]int{
    codes.OK:                 http.StatusOK,
    codes.Canceled:           499, // Go 1.16のnet/httpには定数がない
    codes.Unknown:            http.StatusInternalServerError,
    codes.InvalidArgument:    http.StatusBadRequest,
    codes.DeadlineExceeded:   http.StatusGatewayTimeout,
    codes.NotFound:           http.StatusNotFound,
    codes.AlreadyExists:      http.StatusConflict,
    codes.PermissionDenied:   http.StatusForbidden,
    codes.ResourceExhausted:  http.StatusTooManyRequests,
    codes.FailedPrecondition: http.StatusBadRequest,
    codes.Aborted:            http.StatusConflict,
    codes.OutOfRange:         http.StatusBadRequest,
    codes.Unimplemented:      http.StatusNotImplemented,
    codes.Internal:           http.StatusInternalServerError,
    codes.Unavailable:        http.StatusServiceUnavailable,
    codes.DataLoss:           http.StatusInternalServerError,
    codes.Unauthenticated:    http.StatusUnauthorized,
}

// Code returns HTTP/1.1 Status Code.
func Code(err error) int {
    if err == nil {
        return http.StatusOK
    }
    if v := errors.Unwrap(err); v != nil {
        err = v
    }
    var e *googleapi.Error
    if errors.As(err, &e) {
        return e.Code
    }
    s, ok := status.FromError(err)
    if !ok {
        return http.StatusInternalServerError
    }
    c, ok := codeMappings[s.Code()]
    if !ok {
        return http.StatusInternalServerError
    }
    return c
}

// IsPermissionError returns true if err is an error categorised of permission denied.
func IsPermissionError(err error) bool {
    switch Code(err) {
    case http.StatusUnauthorized, http.StatusForbidden:
        return true
    default:
        return false
    }
}

他にも何か気づいたら追記します。おわり。

Plan 9におけるTLSの実装

Plan 9(9legacy)は、安定版のパッチを当ててもTLS_RSA_WITH_AES_128_CBC_SHA256までしか対応してなく、そろそろ古くなってきています。9frontはもっと強い暗号スイートに対応しているので、必要なものを移植しようと思いました。とはいえTLSについて詳しくないので、何がどう関連しているのかを中心に既存実装を読んだメモです。

TLSの概要

TLSは、以下の要素(暗号スイート)で構成されている。

  • 鍵交換(暗号化で利用する共通鍵の交換方法)
  • 認証(なりすまし防止)
    • これで証明書に含まれる公開鍵の種類が決まる
  • 暗号化
  • メッセージ認証(改ざん防止、ハッシュ)

RFC 5246 - TLS Protocol Version 1.2では、これらを一つの文字列に連結して、例えばTLS_RSA_WITH_AES_128_CBC_SHA256のようなIDとして表現している。このIDはTLS 1.2の場合、

// TLS 1.2
TLS_[鍵交換]_[認証]_WITH_[共通鍵暗号]_[メッセージ認証]

のように、それぞれの位置に対応するアルゴリズムを当てはめる。このとき鍵交換認証がどちらもRSAの場合はTLS_RSA_WITH_とまとめられるようだった。この記事ではそこまで触れないが、TLS 1.3の場合は大幅に簡素化されて

// TLS 1.3
TLS_[AEAD]_[HASH]

となった。AEADは雑にいうと共通鍵暗号メッセージ認証を同時に行うアルゴリズムで、AES_128_GCMなどがある。そのためTLS 1.3では、具体的にはTLS_AES_128_GCM_SHA256のようなIDとなる。

TLSはまた、複数のプロトコル層を持っている。

TLS一般的な話はこの記事が分かりやすかった。

Plan 9での実際

Plan 9では、複数のコンポーネントが関わってTLSを実現している。具体的には以下の3つ。

まずは簡単にTLS接続の流れを追う。

事前準備

サーバは、サーバ証明書ファイルシステム(例えば/sys/lib/tls/cert.pem)に保存しておき、対応する秘密鍵をホストオーナー(普通はbootes)のfactotumにロードしておく。factotumのデータはホストの再起動によって揮発するので、一般的には、再起動時にsecstoreから読み込むように構成する。

% auth/secstore -G factotum
% cat /mnt/factotum/ctl
key proto=rsa service=tls !p=... !q=...

鍵交換とシークレット生成

TLS接続を開始するとき、クライアントでもサーバでも、libsecの関数を使う。自身がサーバとなる場合はtlsServerを使い、クライアントとなる場合はtlsClientを使う。

#include <mp.h>
#include <libsec.h>

/* fdはTLS通信相手とのコネクション */
int            tlsClient(int fd, TLSconn *conn);
int            tlsServer(int fd, TLSconn *conn);

上記どちらの関数も、渡したfdの内容をTLSで包んだ新しいファイルディスクリプタを返す。関数から返されたファイルディスクリプタは、戻った時点で暗号通信を開始した状態なので、呼び出した側はそのままTLSに乗せるプロトコル*1を喋れば良い。

tlsClienttlsServerがやっていることをもう少し追っていくと、サーバでもクライアントでも、渡されたfdを介したTLSハンドシェイクで暗号スイートの決定と、それに従った鍵交換を行う。また、サーバなら「事前準備」で用意しておいた秘密鍵をfactotumから取り出してシークレットの生成に利用する。これで、暗号スイートのうち鍵交換認証は完了していて、暗号とメッセージ認証のアルゴリズムも決まっていて、暗号化で使うシークレットも用意できたことになる。

次に、tlsClientまたはtlsServer関数はカーネルに処理を引き渡す。

カーネルの役割

Plan 9では、TLSレコードプロトコルカーネルで実装している。上記で暗号に必要な値は全て決まったので、tlsClientまたはtlsServerカーネルが提供するファイルに必要な値を書き込む。実際はCで書かれているが、擬似的には以下のような処理を行う。

n=`{cat '#a'/tls/clone}
echo fd $fd >'#a'/tls/$n/ctl
echo version $protoVersion >'#a'/tls/$n/ctl
echo secret aes_128_cbc sha256 $isclient $secret >'#a'/tls/$n/ctl
# '#a'/tls/encalgsと'#a'/tls/hashalgsを読むと利用可能な暗号関数、ハッシュ関数がわかる

cat '#a'/tls/$n/hand  # TLSハンドシェイクプロトコルする場合はこのファイルを読み書きする
cat '#a'/tls/$n/data  # TLSレコードプロトコルする場合はこのファイルを読み書きする

カーネルに実装されたdevtlsドライバは、最初は暗号化しないが、ctlファイルにシークレットが書き込まれた後は全ての通過するデータを暗号化または複合する。

強い暗号に対応するには

最初に書いたように、9legacyでサポートされている暗号スイートは、まだ禁止されてはいないものの推奨されなくなっている。なのでこのままではまずいのだが、どこを改善すると良いのか。暗号に関わるもののデバッグは困難なので、まずは簡単なところから対応すると良いのだろう。最初はTLS_RSA_WITH_AES_128_GCM_SHA256AES_128_GCMを確認して、次にTLS_DHE_RSA_WITH_AES_128_GCM_SHA256DHEに対応する方針が妥当に思える。意外とTLS_DHE_RSA_WITH_AES_256_GCM_SHA256の組み合わせは存在しなかった。

鍵交換

DHEまたはECDHETLS 1.3でも認められているので、この辺りなら良いだろうと思う。鍵交換はlibsecの鍵交換処理に追加すれば良い。やればいいだけなんだけれど、DHEAES_128_CBCの組み合わせはなさそうだったので、先にAES_128_GCM対応が必要だと思う。

認証(証明書)

これは、factotumに新しくprotoを追加する必要があって、そうすると仮に9legacyへパッチを送ってもマージされるかどうかわからない。なので、やるとしても最後にやる。

AEAD

AEADは認証付き暗号と呼ばれるもので、AES_128_GCMもその一つ。

TLS 1.2プロトコル Appendixによると、以前までのTLSでは、ストリーム暗号とブロック暗号が考慮されていたところに、新しくAEAD暗号が追加されたらしい。

TLS 1.2のレコード層では、

struct ProtocolVersion {
    uint8 major;
    uint8 minor;
};

enum {
    ChangeCipherSpec = 20,
    Alert = 21,
    Handshake = 22,
    ApplicationData = 23,
} ContentType;

/* 最初の平文 */
struct TLSPlaintext {
    ContentType type;
    ProtocolVersion version;
    uint16 length;
    uchar opaque[];
};

/* 暗号テキストは3つに分岐 */
struct GenericStreamCipher {
    /* あまり重要ではないので省略 */
};
struct GenericBlockCipher {
    uchar iv[];      /* データの長さはアルゴリズムによって決まる */
    uchar content[]; /* データの長さはTLSCiphertext.length */
    uchar mac[];     /* データの長さはアルゴリズムによって決まる */
    uint8 padding[];
    uint8 padding_length;
};
struct GenericAEADCipher {
    uchar nonce[];    /* データの長さはアルゴリズムによって決まる */
    uchar content[]; /* データの長さはTLSCiphertext.length */
};
struct TLSCiphertext {
    ContentType type;
    ProtocolVersion version;
    uint16 length;
    union {
        GenericStreamCipher stream;
        GenericBlockCipher block;
        GenericAEADCipher aead;
    };
};

のように分岐していて、これまでのブロック暗号では、暗号化とメッセージ認証を分けて計算していたが、分けて計算することによる問題があるらしく*2、AEADでは暗号化と同時に認証タグと呼ばれる値も生成する。この認証タグを、複合時にも使うものらしい。

ブロック暗号の場合

AEADと比べるために、最初にブロック暗号をみる。ブロック暗号で暗号化する場合、カーネルは例えば以下のようにデータを暗号化する。ここでは、暗号化はaes128_cbcで、メッセージ認証はsha256を使うと仮定する。

TLSCiphertext b;

/* TLS 1.2 */
b.type = ApplicationData;
b.version.major = 3;
b.version.minor = 3;
b.length = len(body);
b.block.iv = (最初は空);
b.block.content = body;
b.block.mac = (最初は空);
/* 面倒なのでパディングは省略 */

/* 未暗号化(ivとmacは空)の状態でハッシュ値を計算する */
b.block.mac = hmac_sha256(64bitシーケンス番号 + b);
b.length += len(b.block.mac);

/* 暗号化 */
b.block.iv = (乱数生成); /* 長さは暗号関数に依存する; 例えばAESは16バイト */
b.length += len(b.block.iv);
b.length = aes128_cbc(&b.block, b.length);

これで、最終的にb.blockは暗号化されて、b.lengthは暗号化されたb.blockの長さを持つ。ここで重要なのは平文をSHA256したハッシュ値を平文の末尾に加えて、それを暗号化しているところで、AEADの場合はここが異なる。

AEAD(認証付き暗号)の場合

ここではAES_128_GCMを使うと仮定して具体的な動きをみる。

まず前提として、AES128-GCMでは

  • 平文
  • 初期化ベクトル(IV)
  • 追加データ(aad)
    • 認証には利用されるが暗号化はしないデータ

を与えると、

  • 暗号文
  • 認証タグ

を返す。Plan 9(9front)の場合は以下の関数プロトタイプを持つ。

#include <mp.h>
#include <libsec.h>

void   setupAESGCMstate(AESGCMstate *s, uchar *key, int keylen, uchar *iv, int ivlen);
void   aesgcm_setiv(AESGCMstate *s, uchar *iv, int ivlen);
void   aesgcm_encrypt(uchar *p, ulong n, uchar *aad, ulong naad, uchar tag[16], AESGCMstate *s);
int    aesgcm_decrypt(uchar *p, ulong n, uchar *aad, ulong naad, uchar tag[16], AESGCMstate *s);

もう少し具体的なコードでみると、

TLSCiphertext b;

/* TLS 1.2 */
b.type = ApplicationData;
b.version.major = 3;
b.version.minor = 3;
b.length = len(body);

aad = 64bitシーケンス番号 + b;   /* [seq:8][type:1][major:1][minor:1][len:2]で13byte */
iv[4:12] ^= aad[0:8];       /* IVの上位4バイトはそのまま残して、後ろ8バイトをNonceで埋める */
aesgcm_setiv(state, iv, len(iv));
aesgcm_encrypt(body, len(body), aad, len(aad), &tag, state);
b.nonce = iv[4:12];           /* 暗号化に使ったivの末尾8バイトをメッセージに含める */
b.content = body + tag;     /* 暗号化したbodyの後ろに認証タグを加える */
b.length = len(b.nonce) + len(body) + len(tag);

これをブロックごとに計算する。AEADに関する記事はこの辺りが面白い。

メッセージ認証の意味

ところで、上記でみたようにAEAD暗号ではメッセージ認証を計算しなくなっているのがわかる。なのでPlan 9(9front)のdevtlsでは、aes_128_gcm_aeadで暗号化する場合のハッシュ関数clearを使うようになっている。だけどもTLSの暗号スイートには依然としてTLS_RSA_WITH_AES_128_GCM_SHA256のようにSHA256という名前が残っているが、これはどこで使っているのか。

RFC 5288 - AES Galois Counter Mode (GCM) Cipher Suites for TLSによると、TLS 1.2の場合はPRF(Pseudo Random Function)でだけ使うと書かれていた。なので鍵交換が終わってしまえば、それ以降使われることはないらしい。

TLS 1.3の場合は、HMACベース鍵導出関数(hkdf)に使うらしいが詳しくは調べていない。

*1:例えばHTTPSの場合はHTTP

*2:詳しくないので詳細はわからないけれども