Plan 9とGo言語のブログ

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

個人的によく眺めるPlan 9情報

この記事はPlan 9 Advent Calendar 2019の6日目です。

Plan 9関連の情報を集めるときに、個人的によく使う場所を紹介します。これらの他にも、DiscordコミュニティやIRCチャンネルなどもあるようですが、だいたい9fans眺めていれば済む気はします。

ML

Plan 9、9front、plan9portなど全体的に話をする場所です。週に10〜20通くらいの流量。

plan9portの開発に関連する場所です。流量は少ないので、最近は9fans/plan9portのIssuesを眺めるだけでも良いかもしれない?

SNS

Plan 9関連の話題がたまにポストされるのでsubscribeしています。

たまに眺めています。エド・ウッドの映画や芸人さんの情報も混ざっているのでノイズは多め。

月に数回程度ですが、たまに眺めています。

意外と難しいos/execの話

Goのos/execパッケージは、別のコマンドを扱うためのパッケージですが、使い方を間違えたり、気が散っていたりするとリソースリークを引き起こす可能性があります。こないだリークするコードを目にしたので、どういった理由なのかも含めて紹介します。ここに書いたコードは単純化していますが、大筋は現実のコードと同じです。

v1

まず問題のあるコード。これは

3
4
9

のような1行に1つ数値が書かれたテキストを出力するコマンドを実行して、数値を合計を返す関数です。

func Sum(name string, args ...string) (int, error) {
    cmd := exec.Command(name, args...)
    r, err := cmd.StdoutPipe()
    if err != nil {
        return 0, err
    }
    if err := cmd.Start(); err != nil {
        return 0, err
    }

    var sum int
    scanner := bufio.NewScanner(r)
    for scanner.Scan() {
        s := strings.TrimSpace(scanner.Text())
        n, err := strconv.Atoi(s)
        if err != nil {
            return 0, fmt.Errorf("invalid number: %w", err)
        }
        sum += n
    }
    if err := scanner.Err(); err != nil {
        return 0, err
    }
    if err := cmd.Wait(); err != nil {
        return 0, err
    }
    return sum, nil
}

この実装の問題は、exec.Cmd.Waitしないところです。exec.Cmd.Start で生成したプロセスは、処理を終えても親プロセスに終了コードを返すまでは残り続けます。親プロセスはexec.Cmd.Waitで終了コードを取り出すため、この実装ではstrconv.Atoiがエラーになってしまうとプロセスを回収することができません。サービスのように稼働し続けるプログラムの場合、回収されないプロセスがそのうちプロセス数の上限に達してしまい、それ以上プロセスが作られずエラーになります。運が悪い場合は、kill(1)さえ起動できなくなって再起動するしか方法がなくなります。そのため、exec.Cmd.Startした場合は必ずexec.Cmd.Waitしなければなりません。

v2

次に、returnする前にexec.Cmd.Waitを呼ぶようにしたバージョンです。

func Sum(name string, args ...string) (int, error) {
    cmd := exec.Command(name, args...)
    r, err := cmd.StdoutPipe()
    if err != nil {
        return 0, err
    }
    if err := cmd.Start(); err != nil {
        return 0, err
    }

    var sum int
    scanner := bufio.NewScanner(r)
    for scanner.Scan() {
        s := strings.TrimSpace(scanner.Text())
        n, err := strconv.Atoi(s)
        if err != nil {
            cmd.Wait() // Waitするように変更、エラーはAtoiの方を返すので無視する
            return 0, fmt.Errorf("invalid number: %w", err)
        }
        sum += n
    }
    if err := cmd.Wait(); err != nil { // scanner.Errより先にWaitするように変更
        return 0, err
    }
    if err := scanner.Err(); err != nil {
        return 0, err
    }
    return sum, nil
}

これでstrconv.Atoiに失敗した場合は概ねプロセスが回収されるようになりますが、まだ問題は残っています。exec.Cmd.Waitプロセスが終了するまで待つので、何らかの原因によりプロセスが終了できない場合は無限に待ち続けてしまいます。具体的には、

#!/bin/sh

awk '
BEGIN {
  for(i = 1; i <= 100000; i++)
      print "10000a"
}
'

Sumに与えると、macOSの場合は途中で停止します(環境によって異なる場合があります)。呼び出す側はこんな雰囲気。

func main() {
    n, err := Sum("sh", "long.sh")
    if err != nil {
        log.Println("Sum:", err)
        continue
    }
    log.Println("Sum:", n)
}

この原因は、exec.Cmd.StdoutPipeでコマンドの出力をpipe(2)していますが、実はパイプにはバッファが存在するのでプログラムの出力がパイプのバッファを超えると、バッファが空くまでOSによって止められます。正常な場合はscanner.Scanが読み込みをするとバッファが空いて、後続の出力を書き出せるようになりすべての出力が終わればプロセスは終了しますが、上記のSum関数はエラーになったら以降を読まないため、ずっとバッファが解放されずにプロセスが終わりません。結果、Waitが無限に待ち続けることになります。

v3

これを解決する方法はいくつかありますが、個人的にはCommandContextを使う方法が無難かなと思います。

func Sum(name string, args ...string) (int, error) {
    ctx, cancel := context.WithCancel(context.Background())
    cmd := exec.CommandContext(ctx, name, args...)
    r, err := cmd.StdoutPipe()
    if err != nil {
        return 0, err
    }
    if err := cmd.Start(); err != nil {
        return 0, err
    }

    var sum int
    scanner := bufio.NewScanner(r)
    for scanner.Scan() {
        s := strings.TrimSpace(scanner.Text())
        n, err := strconv.Atoi(s)
        if err != nil {
            cancel()
            cmd.Wait() // Atoiのエラーを優先するのでWaitのエラーは無視
            return 0, fmt.Errorf("invalid number: %w", err)
        }
        sum += n
    }
    if err := scanner.Err(); err != nil {
        cancel()
        cmd.Wait() // scannerのエラーを優先するのでWaitのエラーは無視
        return 0, err
    }
    if err := cmd.Wait(); err != nil {
        return 0, err
    }
    return sum, nil
}

余談になりますが、StdoutPipepipe(2)を作っているので、ファイルが2つopenされた状態になります。ただしこのpipeは、StartでエラーになったりWaitが呼ばれたりすると閉じられるので、大きなリークに繋がることはおそらく無いでしょう。

今から始めるPlan 9

この記事はPlan 9 Advent Calendar 2019の2日目です。

ベル研の公式ディストリビューションは、2015年1月に最後のメンテナだったGeoff Collyerさんが離職されたことにより、現在活動が停止していますが、代わりにコミュニティで派生したものがいくつかあり、現在はそれらを使う方が主流です。あまり情報がまとまっていないので、個人的に把握しているものについて今から始める人のために様子をまとめてみます。

Plan 9 from User Space (plan9port)

インストール方法は過去に書いたのでこちらを参照ください。

これはOSではなく、Plan 9ツールをUnixに移植したものです。当然、Plan 9カーネルが提供する名前空間の編成などは使えませんが、acme(1)エディタやrc(1)シェルなどをPlan 9ネイティブのものと同じように使えますし、9p(1)コマンドで各9pサービスが提供する名前空間も扱えます。date(1)などUnixと同名のコマンドも含まれていますが、既存のシェルスクリプトなどを壊さないために、通常はUnix側を優先するようになっています。Plan 9側のコマンドを使いたい場合、9 lsのように9(1)で切り替えます。

おそらく、acme(1)またはrc(1)を使ってもらうのが実用的ですが、どちらも個性的なので最初は全然使い方がわからないと思いますので今後どこかで使い方を紹介する予定です。rc(1)コマンドライン履歴の機能を持っていないので、rlwrapから起動すると良いでしょう。

開発はGitHubのIssueやPull Requestを使って行います。以前はRuss Coxさんがマージしていましたが、2019年後半あたりからDan Crossさんが主にマージしているようです。(plan9port-dev)

9front

確か2011年ごろ、Plan 9公式の更新が滞り始めた頃にforkしたディストリビューションです。新しいハードウェア、暗号スイートの追加などが行われているので、ネイティブで動作するPlan 9をインストールするには、9frontを使うのが一番簡単だと思います。

ただし現在、公式のPlan 9とは異なる部分がいくつかあります。例えばファイルシステムcwfsまたはhjfsの2択で、公式が採用しているfossilディストリビューションに含まれてさえいないので、本来の雰囲気を楽しみたい人には向きません。個人的にもインストールはしたことがありますが、使ってはいないので、参考リンクだけ。

開発は独自のMercurialリポジトリで行なっているようです。

9legacy

ベル研Plan 9からの変更をパッチとして提供しています。当然パッチ集なので、

  1. ベル研Plan 9を入れる
  2. インターネットに接続できるように設定する
  3. 9legacyのパッチを適用する
  4. コンパイラなどのリビルド
  5. カーネルのリビルド

など、慣れない環境でこれを行うのは大変です。Downloadページで、パッチを適用したISOイメージやディスクイメージも提供されているので、最初はこれらを使うのが良いでしょう。GoのGo Dashboardで稼働しているPlan 9は9legacyのはず。

メンテナはDavid du Colombier(@0intro)さん。 GitHubplan9-contribにPull Requestを送るとたまに拾ってくれますが、このリポジトリは9legacyと同期されていないので、どういう基準でパッチが採用されているのかは分かりません。

plan9-rpi

Plan 9Raspberry Pi用ポートです。メンテナはRichard Millerさん。最新のカーネルソースコード/sources/contrib/miller/9/bcmにあるようです。公式サイト等は特になさそう?でした。

残念ながらRPi持っていないので使ったことはありませんが、RPi 4で動いたとの噂は聞いたことがあります。インストールはPlan9 on raspberry pi 3が良さそうな雰囲気ですが、URLが古いのでplan9.bell-labs.com9p.ioに置き換えて読みましょう。

Harvey OS

Plan 9のコードをgccclangコンパイルできるように変更したディストリビューションです。ANSI/POSIX環境の拡張なども含まれているようです。ソースのビルドには、mkfileの代わりにbuild.jsonが使われます。

いつの間にかDockerイメージも作られるようになっていました。一応起動はしましたが、まだExperimentalなのでうまく動いてくれなかった...

$ docker run -ti --rm harveyos/harvey

ドキュメント

plan9.bell-labs.comのミラーが9p.ioに置かれています。Wikiインストーラ等もミラーされているので、ドキュメントを参照したい場合はこれか、cat-v.orgを読むといいでしょう。

macOS 10.15 Catalinaでexecvが失敗する

macOS 10.15では、execv(2)する前に実行していたプロセスの実行ファイルが削除されていると、execv(2)ENOENTを返す場合があります。どういうことかというと、

  1. a.outを実行
  2. a.out実行中にa.outファイルを削除(削除できる)
  3. a.outfork(2)して親は子プロセスを待つ
  4. 子プロセスがexecv(2)a.outとは別のプログラム(例えば/usr/bin/vm_stat)になる
  5. ENOENTエラーが返る

この動作は、発生する環境では常に発生しますが、しない場合は全く発生しないようです。現在確認できた環境だと、以下のような結果になりました。再現する場合としない場合で何が異なるのか分かっていませんが、Symantecなどのツールが入っていなくても再現するようでした。

  • macOS 10.15.1 (19B2106): 0/1の環境で再現しない
  • macOS 10.15.1 (19B88): 2/3の環境で再現する
  • macOS 10.14.x: 今のところ一度も再現しない

これを試してみたい場合、以下のプログラムを保存して、

cc program.c
./a.out /usr/bin/vm_stat

で実行してみてください。実行するとファイルが消えるので、もう一度行いたい場合は再コンパイルが必要です。

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#include <unistd.h>
#include <stdarg.h>

char *argv0;

static void
fatal(char *fmt, ...)
{
    va_list arg;

    va_start(arg, fmt);
    fprintf(stderr, "%s: ", argv0);
    vfprintf(stderr, fmt, arg);
    if(fmt[strlen(fmt)-1] == ':')
        fprintf(stderr, " %s\n", strerror(errno));
    va_end(arg);
    exit(1);
}

static void
run(char **args, int n)
{
    int i, pid, status;

    for(i = 0; i < n; i++){
        switch(pid = fork()){
        case -1:
            fatal("child fork:");
            break;
        case 0:
            if(execv(args[0], args) < 0)
                fatal("child exec:");
            break;
        default:
            if(waitpid(pid, &status, 0) < 0)
                fatal("child waitpid %d:", pid);
            fprintf(stderr, "child %s: exit %d\n", args[0], status);
            break;
        }
        sleep(1);
    }
}

static void
usage(void)
{
    fprintf(stderr, "usage: %s cmd [args...]\n", argv0);
    exit(2);
}

int
main(int argc, char **argv)
{
    int pid, status;

    argv0 = argv[0];
    if(argc <= 1)
        usage();
    if(unlink(argv0) < 0)
        fatal("unlink %s:", argv);
    switch(pid = fork()){
    case -1:
        fatal("fork failed:");
        break;
    case 0:
        run(argv+1, 100);
        break;
    default:
        if(waitpid(pid, &status, 0) < 0)
            fatal("waitpid %d:", pid);
        break;
    }
    return 0;
}

問題がない場合、vm_statの結果が100回出力されますが、エラーになる環境だと以下のエラーログが100回出力されます。

./a.out: child exec: No such file or directory
child vm_stat: exit 256

再現する場合としない場合で、macOSの仕様変更なのか何かのバグなのか分からないので、一応フィードバックアシスタントでAppleにフィードバックを送っておきました。

macOSのセキュリティとプライバシー保護

plan9portのAcmeエディタを通常のmacOSアプリケーションと同じような感覚で使えるようにするため、起動処理をAppleScriptで実装したappパッケージを使っています(GitHub)。AppleScriptを使っているのは、シェルスクリプトだけではファイルのドロップが実現できなかったという事情があります。

Finderを制御するアクセスを要求しています

いつからだったか忘れましたが、このアプリケーションを実行した際に、突然macOSからアクセス要求ダイアログが表示されるようになりました。

f:id:lufiabb:20191015135019p:plain

なぜ表示されたのかわからないので、確認してから許可しようと思って「許可しない」を選ぶと、次にアプリケーションを起動してもアクセス要求ダイアログは表示されず、常にFinderにApple Eventsを送信する権限がありませんというエラーが発生するようになってしまいました。

f:id:lufiabb:20191015135045p:plain

インターネットによると、システム環境設定→セキュリティとプライバシーと進んで、プライバシータブからオートメーションを選択するとアクセスを要求したアプリケーションがリストされていると書かれていましたが、今回遭遇したケースでは何も表示されません。

f:id:lufiabb:20191015135101p:plain

こうなってしまった場合、tccutil(1)でリセットすると再びダイアログを表示させることができます。

$ tccutil reset AppleEvents

TCC

TCCとはTCC: A Quick Primerによると、Transparency, Consent, and Controlのことで、アプリケーションがユーザデータへ無制限にアクセスさせないための保護機構のようです。また、コマンドのマニュアルにはPrivacy Databaseという名称も見て取れます。今回のエラーはAppleEventsサービスの設定をリセットしていますが、これ以外にもPhotosCameraなどいくつかあります。Helping Your Users Reset TCC Privacy Policy Decisionsにサービスの詳細なリストが掲載されています。

Mojave時点では、残念ながらtccutil(1)resetサブコマンドしか持っていないので、何を許可しているのかを調べる方法はありません。データベースは~/Library/Application Support/com.apple.TCC/TCC.dbまたは/Library/Application Support/com.apple.TCC/にありますが、SIP(System Integrity Protection)によってrootでさえアクセスを拒否されるので、どうしても読みたければセーフモードで起動させる必要があります。

$ sudo ls /Library/Application\ Support/com.apple.TCC/
Password:
ls: : Operation not permitted

セーフモードでcom.apple.TCCを読むための手順は以下のリンクを参考にしてください。

System Policy

TCCの他にもSystem Policyというシステム保護機構が存在していて、TCCはユーザデータを保護するもので、これは名前の通りシステムを保護するためのものです。例えばシステム環境設定→セキュリティとプライバシーApp Storeと確認済みの開発元からのアプリケーションなどから許可した開発元などが管理されているようです。System Policyは、spctl(8)コマンドを使うと現在の設定内容を取得できます(--listオプションはマニュアルに載っていませんが...)。

$ sudo spctl --list
8[Apple System] P20 allow lsopen
        anchor apple
3[Apple System] P20 allow execute
        anchor apple
2[Apple Installer] P20 allow install
        anchor apple generic and certificate 1[subject.CN] = "Apple Software Update Certification Authority"
2711[Mac App Store] P10 allow install
        anchor apple generic and certificate leaf[field.1.2.840.113635.100.6.1.10] exists
5[Mac App Store] P10 allow install
        anchor apple generic and certificate leaf[field.1.2.840.113635.100.6.1.10] exists
...

$ sudo spctl --status
assessments enabled

これだけ見ても何も分かりませんね。System Policyをリセットするには、デフォルトデータベースをコピーすればいいです。これはセーフモードでなくても上書きできます。

$ sudo cp /var/db/.SystemPolicy-default /var/db/SystemPolicy

参考

ACL

ファイルの属性では、伝統的なパーミッションの他にACL(おそらくPOSIX ACL)が使われていて、パーミッションは間違っていないけどファイルの更新ができないといったことが発生します。ACLls -leで確認できるので、おかしいなと思ったら眺めてみましょう。

Acidの使い方(基本)

AcidはPlan 9またはPlan9portで使えるデバッガです。おそらくAcidの用途で最も多いのは、suicide*1したプロセスをアタッチして、stk()lstk()を使って落ちた様子をみてCtl-dで抜ける、ような使い方だと思いますが、Acidはこれ自体がシェルとCの中間みたいなプログラミング言語になっていて、やれることは非常に多いです。が、そこまで書くのは大変なので、今回の記事ではブレークポイントで止めて値を眺めるまでの基本的な使い方を書きました。

使い方

acidコマンドの引数にデバッグしたいファイルを渡すと、まだプロセスが作られていない状態でデバッガが起動します。acid:というプロンプトが表示されるので、引数が必要であればprogargsにスペース区切りで設定してプロセスを作成しましょう。

% acid /bin/git
/bin/git: 386 plan 9 executable
/sys/lib/acid/port
/sys/lib/acid/386

// 引数を設定(必要なら)
acid: progargs = "stash save Debugging"

// プロセス生成
acid: new()
1415: system call  _main       SUBL $0xc,SP
1415: breakpoint   main+0x3   CALL trace2_initialize_clock(SB)

// プロセスの処理を開始
acid: cont()

acidはプロセスIDを与えると直接そのプロセスをアタッチします。この場合は、プロセスはすでに動いているためnew()でプロセスを作成する必要はありません。

% acid <プロセスID>
/bin/git: 386 plan 9 executable
/sys/lib/acid/port
/sys/lib/acid/386

// スタックトレース取得
acid: stk()

Acidの変数

変数はプログラムのシンボルテーブルで扱うものがそのまま参照できます*2。具体的に書いた方が分かりやすいと思うので、以下のプログラムを説明のために使います。

#include <u.h>
#include <libc.h>

typedef struct Addr Addr;
struct Addr {
    char *host;
    int port;
};

char *network = "tcp";

void
main(void)
{
    Addr a;

    a.host = "localhost";
    a.port = 9000;
    print("%s!%s!%d\n", network, a.host, a.port);
    exits(nil);
}

Acidはシンボルテーブルを参照します。このため、デバッグ対象プログラムの変数を参照するには、ローカル変数の場合はfunc:nameのように関数名と変数名を:で繋げてアクセスします。グローバル変数ならnameのように変数名だけでアクセス可能です。

acid: network
0x00006010

ただし、Acidから参照した変数はそのアドレスを表します。Cで言うと(void*)&networkのような扱いです。単項*演算子を使うと、networkが指しているメモリの値を取り出すことができます。ソースコード上では*networkはC文字列へのポインタですが、AcidではCの型情報が失われているので、ただの整数です。

acid: *network
0x00006850

Acidでは、Cの型とは別に変数のフォーマットが存在します。現在変数が持っているフォーマットはwhatisで調べたり、fmtで変更したりできます。そして*演算子がメモリから取り出す値のサイズはAcidの変数が持つフォーマットに依存します。例えばs(文字列)なら\0まで読み込みますし、D(4バイト整数)なら4バイト読みます。

それではnetwork変数が指している文字列を表示してみましょう。

acid: whatis network
integer variable format X
acid: x = fmt(*network, 's') // *network=xが指すアドレスには文字列が格納されている(x自体はただの整数)
acid: x = *network\s         // fmtのショートハンド(*の方が結合強い)
acid: whatis x
integer variable format s
acid: s = *x
string variable
acid: s
tcp
acid: *(*network\s)          // これでもいい
tcp

フォーマットを変更すれば、整数や浮動小数なども読み取れます。*と似たような@演算子もあるようです。利用可能なすべてのフォーマットはAcid Manualを参照してください。

構造体などのメンバ変数

構造体などの場合、Acidは型情報を持っていないのでメンバ変数を名前で参照できません。もちろん変数の先頭アドレスからオフセット分だけ加算すればアクセス可能ですが、アラインメントなどを考慮すると非常に面倒です。8c -aオプションを使うと、Acidで利用できる型を生成してくれるので、これを使うといいでしょう。他のコンパイラではDWARFとして埋め込まれているような情報も、Plan 9では人が読み書きできるようなテキストファイルとして分けて扱います。

# -nオプションを省略するとstdoutへ出力
# -aaオプションの場合は.hファイルの内容を出力に含まない
% 8c -an main.c

-aオプションが生成したファイルには、以下のようにCの構造体や共用体と同じ名前の型や関数が含まれます。

sizeofAddr = 8;
aggr Addr
{
    'X' 0 host;
    'D' 4 port;
};

defn
Addr(addr) {
    complex Addr addr;
    print("  host    ", addr.host\X, "\n");
    print("  port    ", addr.port, "\n");
};

これらのファイルは、acid -lオプションやinclude(string)などで読み込んで使います。

% acid -l ./main.acid 8.out
// キャストする場合
acid: x = (Addr)main:a
acid: x
     host 0x00006854
     port 9000
acid: *(x.host)
localhost

// 関数を使う場合
acid: Addr(main:a)
     host 0x00006854
     port 9000

ブレークポイント設定

ブレークポイントの設定はbpset(address)bpdel(address)関数で行います。acidはシンボルテーブルを参照するので、address引数には関数名をそのまま渡してもいいし、filepc(where)関数でファイル名と行番号から明示的に与えることもできます。また、現在設定中のブレークポイントが知りたければbptab()関数が使えます。

acid: bpset(strbuf_vinsertf)
acid: bpset(filepc("strbuf.c:262"))
acid: bpset(filepc("strbuf.c:274"))
acid: bptab()
    0x00236588 strbuf_vinsertf     SUBL    $0x20,SP
    0x002365ad strbuf_vinsertf+0x25       MOVL    0x8(BP),CX
    0x002366b4 strbuf_vinsertf+0x12c  MOVL    0x8(SI),CX
acid: cont()

ブレーク中の操作

ブレークポイントで停止すると、プロセスはStopped*3状態に遷移して、acidが入力を受け付けるようになります。

ソースコードレジスタの表示

停止している場所を調べるためにソースコードの参照ができます。

acid: src(addr)   // addrを中心に10行Cのソースを表示
acid: line(addr)  // addrの行だけCのソースを表示
acid: asm(addr)   // addrから30行アセンブリのソースを表示

PCレジスタに現在のプログラムカウンタが設定されているので、これを使うのが便利でしょう。

acid: src(*PC)
/tmp/x/git/strbuf.c:274
 269       strbuf_grow(sb, len);
 270       memmove(sb->buf + pos + len, sb->buf + pos, sb->len - pos);
 271       /* vsnprintf() will append a NUL, overwriting one of our characters */
 272       save = sb->buf[pos + len];
 273       len2 = vsnprintf(sb->buf + pos, len + 1, fmt, ap);
>274        sb->buf[pos + len] = save;
 275       if (len2 != len)
 276           BUG("your vsnprintf is broken (returns inconsistent lengths)");
 277       strbuf_setlen(sb, sb->len + len);
 278   }
 279   

演算もできるので現在位置から少し前も見られます。

acid: asm(*PC-10)
strbuf_vinsertf+0x122 0x002366aa   MOVL sb+0x0(FP),SI
strbuf_vinsertf+0x126 0x002366ae   MOVL len+0x1c(SP),BP
strbuf_vinsertf+0x12a 0x002366b2   MOVL AX,BX
strbuf_vinsertf+0x12c 0x002366b4   MOVL 0x8(SI),CX
strbuf_vinsertf+0x12d 0x002366b5   DECL SI
strbuf_vinsertf+0x12e 0x002366b6   ORB  CL,0xa048dea(CX)
strbuf_vinsertf+0x134 0x002366bc   ADDL pos+0x4(FP),AX
strbuf_vinsertf+0x138 0x002366c0   MOVBSX   save+0x17(SP),CX
strbuf_vinsertf+0x13d 0x002366c5   MOVB CL,0x0(AX)
...

レジスタに保存されている値はregs()などで表示できます。

acid: regs()
PC  0x002366b4 strbuf_vinsertf+0x12c  /tmp/x/git/strbuf.c:274
SP  0xdfffe884 ECODE 0xf01006d6 EFLAG 0x00000692
CS  0x00000023 DS   0x0000001b SS 0x0000001b
GS  0x0000001b FS   0x0000001b ES 0x0000001b
TRAP    0x00000003 breakpoint
AX  0x0000000b BX  0x0000000b CX  0x0074571b DX  0x004ebd7c
DI  0xffffffff SI  0xdfffeabc BP  0x00000016

他に、汎用レジスタ浮動小数レジスタだけ表示する関数もありますが、通常はregs()があれば十分でしょう。

スタックトレースの取得

stk()またはlstk()関数でスタックトレースを取得できます。lstk()stk()と似ていますが、ローカル変数の値も含めて出力されます。

ステップ

cont()またはstep()関数で処理を再開できます。

acid: step() // 1命令を実行
acid: cont() // なんらかで停止するまで実行

マルチプロセス

デバッグ中のプログラムがfork(2)した場合、新しいプロセスはStopped状態になって停止します。そのプロセスをデバッグしたい場合は

% acid <新しいプロセスのPID>

でアタッチしてcont()すればデバッグ可能ですし、処理を進めたいだけなら

% echo start >/proc/<新しいプロセスのPID>/ctl

で再開できます。プロセス操作の詳細はproc(3)を読んで下さい。

または、acid:プロンプトで待ち受けている状態であれば、以下の関数が使えるかもしれません。

acid: procs()      // アクティブなプロセスリスト取得
acid: setproc(pid) // pidをカレントプロセスに切り替え
acid: start(pid)   // 停止しているpidを再開

ライブラリ

acid-l libraryオプションを与えると/sys/lib/acid以下*4のライブラリを読み込みます。特にtrussは、実際に発行されたシステムコールを確認できるので便利です。

% acid -l truss /bin/git
acid: progargs = "xx"
acid: new()
acid: truss()

参考情報

*1:Unixで言うところのSEGV

*2:Acidの予約語と被った場合は名前の先頭に$を付ける

*3:suicideした場合はBroken

*4:p9pの場合は$PLAN9/acid

Titan Security Keyを使ってみた

ここ数年で、身の回りでは2段階認証が当たり前になってきたように思います*1。個人的には今まで、2段階認証が可能なサービスにはSMSとモバイルアプリを登録していました。しかしここ最近は、サービスにログインする度にIIJ SmartKeyやGoogle Authenticatorなどの認証アプリを起動して、表示された6桁の数字を入力することをめんどくさいと感じるようになりました。数字入力の手間を、Titan Security Keyではボタン押すだけで解決できそうだったので買ってみました。

Titan Security KeyはFIDO U2Fに準拠したデバイスです。FIDOには1と2のバージョンがあり、WebAuthnはFIDO2に含まれます*2。WebAuthnは後方互換によりFIDO U2F対応デバイスも利用可能なのでTitan Security Keyも使えますがパスワードレスログインはできません。

パスワードの不要な世界はいかにして実現されるのかによると、FIDO2では以下の3要素でデバイスを分類しています。

  • Transport : 接続方法
    • USB
    • BLE
    • NFC
    • ineternal
  • Attachment : デバイスに直接組み込まれているかどうか
    • platform(組み込み)
    • cross-platform(取り外し可能)
  • User Verification (UV) : 生体認証を含めた本人認証機能があるかどうか
    • あり
    • なし

例えばTitan Security KeyまたはApple Touch IDの場合は以下のように分類されます。

基準 Titan Security Key Apple Touch ID
Transport USB or BLE internal
Attachment cross-platform platform
User Verification なし あり

対応サイト

2019年現在、2段階認証の要素にセキュリティキーがサイトは以下のどちらかに対応しているようです。

  • WebAuthn (FIDO2)
  • U2F, Universal 2nd Factor (FIDO1)

これらの違いは、U2Fはあくまで2段階要素に使える仕様ですが、WebAuthnはUser Verificationありのデバイスに限りユーザ名やパスワードの代用も可能になっているところのようです。上にも書いたように、WebAuthnはU2Fデバイスも利用可能です。

使い方

Titan Security KeyはUSB版とBLE版の2つ梱包されています。Googleによると普段は一つだけ使い、もう一つは安全な場所に保管することを推奨しています。PCやMacだけで使うならUSBの方が使いやすいと思いましたが、モバイル端末でも使いたいのでBLEの方を普段使いに選びました。しかし現状、macOS 10.14ではBLEのペアリングができないので、付属のUSBケーブルでUSBデバイスとして接続する必要があります。また、iOSではSmart Lockアプリを使わなければ接続できません。以下の表は、BLE版を各プラットフォームで接続する方法です。

OS 接続方法 利用可能ブラウザ
macOS USB FirefoxまたはChrome
iOS BLE+Smart Lock Chromeのみ
Android BLE FirefoxまたはChrome

macOSで使う

macOS 10.14時点では、Safari U2FとWebAuthnどちらにも対応していません。そのためFirefoxまたはChromeを使う必要があります。また、システム環境設定のBluetoothからペアリングができないため、USBとして接続する必要があります。

  1. Titan Security KeyをUSBで接続する
  2. U2FまたはWebAuthn対応サイトで2段階認証を設定する
  3. 途中でボタンを押すように促されるのでTitan Security Key中央のボタンを押す(緑色に点滅し始める)
  4. 2段階認証でセキュリティキーが有効になる
  5. 再認証時に、セキュリティキーを選んでボタンを押す

USBとして接続させるためケーブルで繋げなければならない点を除いては、特に不満がありません。ブラウザからBLE機器へ接続するWeb Bluetooth APIが使えるなら事前のペアリングが不要になったりするのかなと期待しましたが、現時点でブラウザの対応状況は今ひとつのようです。

WebAuthnでBLEデバイスを使う方法によると、BlueTooth explorerを使うとBLEデバイスとしてペアリングできるそうですが、今回は試していません。

Androidで使う

利用前に、OSとデバイスの間でペアリング(鍵交換)しておく必要があります。FIDO Bluetooth Specification v1.0に、

Clients and Authenticators MUST create and use a long-term link key (LTK) and SHALL encrypt all communications. Authenticator MUST never use short term keys.

とあるのでペアリングは必須です。

  1. 対応サイトにログインする
  2. 2段階認証にセキュリティキーを使う
  3. ペアリングするように促されるので続ける
  4. Titan Security Keyのボタンを5秒間押して青い点滅になるまで待つ
  5. Androidからペアリングを行う
  6. PINコード(Titan Security Key裏面の数字)を入力する

これでペアリングが完了するので、サイトに戻ってセキュリティキー中央のボタンを押せばログインできます。次からはペアリングされた状態になっているので、ボタンを押すだけでログイン可能です。

BLEのペアリングには、SSP(Simple Secure Pairing)とPINのモードがあり、セキュリティモードで使い分けるようですが詳細は追っていません。

具体例

iOSで使う

設定アプリではTitan Security KeyとBLEペアリングできませんのでGoogle Smart Lockアプリを使う必要があります。

  • Smart LockアプリでGoogleアカウントにログインする(ログインが終われば閉じても良い)
  • ChromeGoogleアカウントの2段階認証を行う
  • Smart Lockアプリが起動するのでペアリングする

あとはAndroidと同じようにセキュリティキー中央のボタンを押せばログインできます。ただし、ChromeGoogleアカウントにログインする以外の用途には(Smart Lockが反応しないので)使えません。とても残念。

Linuxで使う

試していませんが利用可能なようです。

調べたこと

どうやってセキュリティキーからデータを受け取っているか

Androidでいくつか試した限り、セキュリティキーに常時BLE接続しているわけではなさそうでした。だとすると、ブラウザがセキュリティキーを必要とした時に、OSがペアリング済みのデバイスと接続して、ボタンが押されて認証を終えたら切断しているんでしょうか。詳細はわからないけどそんな気がする...

Touch IDや指紋認証と比べてどうなの

基本的に、デバイス内部で持っている秘密鍵を取り出せるべきではありません。WebAuthnの仕様に、

In general, it is expected that a credential private key never leaves the authenticator that created it. Losing an authenticator therefore, in general, means losing all credentials bound to the lost authenticator, which could lock the user out of an account if the user has only one credential registered with the Relying Party.

とあり、秘密鍵のバックアップを可能にするよりも複数のデバイスを登録可能にするよう書かれています。

そうすると、Attachmentがplatformなデバイスは端末に紐づくため、新しい端末を購入した場合は個別に設定が必要ということでしょうか。スマホを1〜2年に1回移行するように仮定すると、その度に各サイトへログインして、新しいデバイスの設定追加が必要になるのは少し面倒な気がします。サイトが数個程度なら苦ではないけれど、GitHub, Google, Twitter, AWS, ...など非常に多いし今後も増えることが推測できるので、ユーザIDやパスワードの入力することになっても、cross-platformなセキュリティキーを使ったほうが便利かなと思いました。

セキュリティキーはいくつまでペアリング可能か

1つのセキュリティキーを何台のデバイスからペアリング可能かは分かりませんでしたが、少なくともTitan Security KeyはAndroidiOSの2台までは確認しました。

開発

WebAuthn APIを使うときの情報。

*1:多要素認証とも呼ぶけどこの記事では2段階認証という名称を使う

*2:iOSのFIDO対応についての考察を参照