2026-09-19

SeedSigner は何を信頼して動いているのか

#SeedSigner #Bitcoin #サプライチェーン

最近、ハードウェアウォレットの乱数生成に不具合が見つかり、弱いシードが作られていたことが公表された。
一部の機種とファームウェアで、期待される 128 ビットに対して実効的なエントロピーが大きく不足していたという。

COLDCARD Mk3 Seed Generation Warningファームウェアの不具合で弱いシードが生成されていた。50 回以上のダイスロールで作ったシードは影響を受けないblog.coinkite.com

ハードウェアウォレットの一つとして、SeedSigner という、
Raspberry Pi をベースとしたデバイスにオープンソースの OS をインストールすれば DIY できるものが存在し、実際に送金に利用している。
オープンソースという透明性のある仕様である一方、「何をトラストしているのか」気になったので調べてみた。

SeedSigner とは

Mini Pill ケースに入った SeedSigner(SeedSigner 公式リポジトリより / MIT License, (c) 2021 SeedSigner)
Mini Pill ケースに入った SeedSigner(SeedSigner 公式リポジトリより / MIT License, (c) 2021 SeedSigner)

Bitcoin の署名だけをするエアギャップ端末で、汎用部品から自分で組み立てる。
Raspberry Pi Zero にカメラと 240x240 の小さい LCD を付け、配布されている OS イメージを MicroSD に書き込めば動く。
専用のセキュアエレメントは載っていない。

推奨構成の Raspberry Pi Zero v1.3 は Wi-Fi も Bluetooth も積んでおらず、外とのやりとりは QR だけに絞られている。
送金内容は外部ウォレットが出した QR をカメラで読み取り、署名済みのデータは画面に QR として出して読み返してもらう。

シードを端末に持たないのも特徴で、署名のたびに紙から読み込ませ、電源を切れば消える。
残るのは手元の紙だけ、という前提で設計されている。

SeedSigner/seedsignerエアギャップされた Bitcoin 署名端末。汎用部品で自作でき、シードを端末に保存しないgithub.com

SeedSigner って何でできてるの?

アプリ本体とは別に、それを載せる OS が専用に作られていて、こちらも別リポジトリで管理されている。

SeedSigner/seedsigner-osSeedSigner 専用に buildroot で組み立てる最小の Raspberry Pi イメージgithub.com

OS 自体をスクラッチで書いているわけではなく、各レイヤーで既にあるものを組み合わせている。
カーネルも busybox も C のライブラリも buildroot が持ってくる上流パッケージで、SeedSigner 側が足しているのは 6 パッケージと、アプリ本体を置くオーバーレイだけになる。

端末の操作に関わる部分はすべて Python で書かれている。
署名も QR のデコードも最終的には C のライブラリが担うが、その上の画面遷移やユースケースは Python のまま読める。

利用者から見えるもの — 端末の外側と LCD に映る画面seedsigner-os — buildroot フォークが 1 枚のイメージに組み立てるハードウェア — ソフトウェアからは検証できないビルドと配布 — ここから入ってきたものが上の層になる利用者ボタンとカメラに見せる QR だけPSBT の QRUR / Specter / BBQR / base64署名要求の QRsignmessage {path} ascii:{msg}SeedQR紙に手で転写。12 語 = 25x25ダイス入力画面エントロピー源を入力スキャン画面カメラを起動して QR を読む確認画面送金先・金額・変更先を表示QR 出力画面UR PSBT / xpub / 署名文字列CPython 3.12 — initramfs として RAM 上に展開されるC のネイティブコード — buildroot がビルド。個別のハッシュ固定は無いOS — buildroot フォークが供給カメラ基板QR の唯一の入力口 / CSILCD 基板ST7789 240x240 / SPIボタン・ジョイスティックGPIO。利用者の操作入力Raspberry Pi ZeroBCM2835。書き込み先の MicroSDPyPItarball を buildroot が取得GitHub リリース tarballpyzbar / urtypesGitHub Releases.img + sha256 + GPG 署名ユースケース — src/seedsigner/views。コミットで固定アプリのモジュール — src/seedsigner。コミットで固定Python 依存 — external-packages が sha256 で固定libsecp256k1embit 同梱の zkp フォークlibzbarQR デコードの実体qrencodesubprocess で呼ばれる CLIfreetype / glibc / libcryptoPillow と hashlib の先Linux OSカーネル・busybox・init① 新規シード作成サイコロ・コイン・カメラ② シード読み込みSeedQR・手入力③ PSBT 署名PSBT QR → 署名済み QR④ xpub の出力Export xpub⑤ メッセージ署名sign_message QRmodels/QR と PSBT を解析するhelpers/署名と QR 生成のヘルパgui/画面を Pillow で合成するcontroller.pyシードと設定を保持する翻訳カタログgettext .mo / submodulehardware/displaysSPI と DC/RST に流すhardware/cameraフレームを取り出して渡すhardware/buttonsGPIO の入力をイベントにpython-embit 0.8.0BIP32/39・PSBT・署名python-pillow-ep 10.3.0ソースからビルドpython-pyzbar 0.1.9-ssQR デコードの入口python-urtypes 0.1.0UR の復元と組み立てpython-picamera 1.13カメラからフレームを取るpython-spidev 3.6SPI 経由で LCD に書くpython-RPi.GPIOGPIO の読み書きpython-numpyカメラのフレームを配列で扱うpython-qrcodeSeedQR と qrencode の代替
上から下へ、固定の強さが単調に弱くなる。sha256 で内容まで固定されているのは Python 依存の 1 段だけで、その下は buildroot がビルドしたものになる

CPython ランタイムの枠がアプリ層と Python 依存の両方を囲み、その外側の seedsigner-os の枠が C のネイティブコードと OS まで含んでいる。
そして特徴的なのは、ルートファイルシステムは initramfs としてカーネルに埋め込まれ、起動時に RAM へ展開される。
MicroSD に入っているのは 25MB の FAT パーティション 1 つだけで、Linux のルートパーティションは存在しない。
/ から /tmp まで全部が RAM にあり、電源を切れば消える。

図の下 4 段が、実機に載る OSS の全量になる。
それぞれを誰が管理していて、どこから取ってきているかを並べるとこうなる。

パッケージ プロジェクト 取得元
embit github.com/diybitcoinhardware/embit PyPI
Pillow github.com/python-pillow/Pillow PyPI
pyzbar github.com/SeedSigner/pyzbar GitHub リリース
urtypes github.com/selfcustody/urtypes GitHub リリース
picamera github.com/waveform80/picamera PyPI
spidev github.com/doceme/py-spidev PyPI
RPi.GPIO sourceforge.net/projects/raspberry-gpio-python PyPI
numpy github.com/numpy/numpy GitHub リリース
qrcode github.com/lincolnloop/python-qrcode PyPI
libsecp256k1 github.com/BlockstreamResearch/secp256k1-zkp embit の tarball に同梱
libzbar github.com/mchehab/zbar linuxtv.org
qrencode github.com/fukuchi/libqrencode fukuchi.org
freetype github.com/freetype/freetype savannah.gnu.org
libraqm github.com/HOST-Oman/libraqm GitHub リリース
カーネル・busybox github.com/seedsigner/buildroot buildroot の上流

管理元も取得元も、1 か所というわけではなく、色々なorganizationのものを利用している。
このうち 6 つは SeedSigner 自身がパッケージ定義を書いたもので、opt/external-packages/ に置かれている。
pyzbar は上流をそのまま使わず、SeedSigner が自分のフォークをリリースして取り込んでいる。

そして libsecp256k1 だけ、取得元が他と違い、
単独で取ってくるものではなく、embit の tarball の中にビルド済みで入っている。

署名のツールチェーンってどうなってるの?

ハードウェアウォレットの核となる署名機能についてさらに深ぼってみる。
秘密鍵に触れてから署名が出てくるまで、コードは 4 つの層で構成されていそう。

src/seedsigner 署名の手順を組み立てる。演算は自分で持たない
embit BIP32/39 と PSBT を扱う。署名は ctypes で下に投げる
libsecp256k1 ECDSA を実際に計算する C のライブラリ
bitcoin-core/secp256k1 そのライブラリの本家
① 新規シード作成サイコロ・コイン・カメラ② シード読み込みSeedQR・手入力③ PSBT 署名PSBT QR → 署名済み QR④ xpub の出力Export xpub⑤ メッセージ署名sign_message QRmodels/QR と PSBT を解析するhelpers/署名と QR 生成のヘルパcontroller.pyシードと設定を保持するpython-embit 0.8.0BIP32/39・PSBT・署名libsecp256k1embit 同梱の zkp フォークfreetype / glibc / libcryptoPillow と hashlib の先 ニーモニック化psbt_parser で解析 embit_utils で署名シードと設定を保持PSBT / シードの解析鍵導出・署名ecdsa_sign_recoverablehmac-sha512 / pbkdf2sha256 で圧縮
アプリは embit を経て ctypes で C のライブラリに降りる。ECDSA を計算するのは一番下の libsecp256k1

SeedSigner は embit の高水準 API だけでなく、from embit.util import secp256k1 で低層を直接 import し、生の秘密鍵を渡して署名させている。
embit のバックエンド選択は「同梱の共有ライブラリ → システムの libsecp256k1 → 純 Python 実装」の順で、Pi Zero では最初の候補に当たる。

最終的に依存している機能はBitcoin Core が使っているものと同じ系譜のコードに行き着く。

実機に載っているのは secp256k1-zkp という派生で、Liquid 向けに機能を足したものになる。
その README は冒頭で、これは bitcoin-core/secp256k1 のフォークだと明言している。

BlockstreamResearch/secp256k1-zkpbitcoin-core/secp256k1 のフォーク。Confidential Assets などの実験的モジュールを追加しているgithub.com

つまり署名の計算そのものは、Bitcoin 本体が長く使ってきたコードを利用しており、
SeedSigner も embit も、そこを自前で書き直してはいない。

依存しているライブラリのバージョンはどうしてる?

昨今のサプライチェーン攻撃では、最新のライブラリバージョンに差し込むことによって、
攻撃を通す方式が多い。
それに対して、ライブラリのバージョンを固定することはある程度決められており、当然SeedSignerでもそのような運用をしている。
最初固定しているのはてっきりrequirements.txt で、全行が sha256 でハッシュ固定されていて、一見すると実機の依存を押さえているように読める。
しかし seedsigner-os は requirements.txt を読んでなさそうだった。

実機の依存は buildroot の外部パッケージとして定義されていて、opt/external-packages/ の各ディレクトリに .mk と .hash の組が入っている。
取得も照合も buildroot が直接おこなう。
つまり実機のピンの正本は、アプリ側のリポジトリではなく OS 側にある。

固定しているのはバージョン番号ではなく、tarball の中身そのものになる。
python-embit.hash なら embit-0.8.0.tar.gz の sha256 が直に書いてあり、PyPI のどのページから取ったかもコメントで残っている。

イメージ上の置き場も別々になっている。

アプリ /opt/src/seedsigner(rootfs-overlay 由来)
依存 /usr/lib/python3/site-packages/(buildroot 由来)

そのバイナリってどこでビルドされている?

コードの系譜はたどれても、そのバイナリがどう作られたかはたどれない。

素性を調べると、同梱されているのは署名のツールチェーンの章で見た secp256k1-zkp からビルドされたものだった。
根拠は .so が公開している関数名になる。
strings をかけると secp256k1_rangeproof_* secp256k1_musig_* secp256k1_surjectionproof_* secp256k1_pedersen_* secp256k1_generator_* が並んでいる。
これらは Confidential Assets 向けに secp256k1-zkp が足したもので、bitcoin-core/secp256k1 には無い。
secp256k1_* の 220 個のうち 93 個がそれにあたるので、ビルド元が本家ではなく派生のほうだと、バイナリ側から言える。

sha256 による固定が保証するのは embit の tarball の同一性までで、その中の .so がどのソースからどう作られたかは追えない。
バイナリに焼き込まれたビルド環境は GCC 8.3.0 (Raspbian) で、tarball の公開時期よりかなり古い世代のツールチェーンだった。

コードの系譜としてはBitcoin Coreのもので間違っていないが、
実機に載っているのは Bitcoin Core が配るものでも、そのソースから手元で組んだものでもない。

これは SeedSigner が固定している embit 0.8.0 についての話になる。
上流の embit はその後 prebuilt/ を捨てていて、現在の master にこのディレクトリは無い。
seedsigner-os 側にも libsecp256k1 を自前でビルドする PR が開いているようだ。

Build libsecp256k1 from source code同梱バイナリをやめ、libsecp256k1 をソースからビルドする提案github.com

脆弱性が見つかったという話ではなく、検証できる範囲を広げる方向の変更になる。

取得経路と配布イメージはどう検証できるの?

配られているのはビルド済みのイメージなので、中身をそのまま読んで確かめることはできない。
それでも「これは公開されているソースから作られたものか」を、自分でビルドしなくても言えるようにしてある。
依存はハッシュ照合、イメージは再現ビルドと GPG 署名で追える。

PyPItarball を buildroot が取得GitHub リリース tarballpyzbar / urtypesGitHub Releases.img + sha256 + GPG 署名python-embit 0.8.0BIP32/39・PSBT・署名python-pyzbar 0.1.9-ssQR デコードの入口Raspberry Pi ZeroBCM2835。書き込み先の MicroSDLinux OSカーネル・busybox・init .hash で照合.hash で照合GPG 検証後に書き込み 起動・ドライバ
PyPI と GitHub から取得した依存は buildroot が .hash で照合する。配布イメージはオフラインでビルドされ GPG 署名される

依存の取得は buildroot が .hash で照合するので、取得経路が汚染されても弾かれる。

配布イメージのほうはオフラインでビルドされ、sha256 マニフェストと GPG 署名が付く。
v0.7.0 以降は再現ビルドが成立していて、公開されているソースと設定から同じイメージを作り直して照合できる。

ただしこれで信頼が消えるわけではない。
ハッシュを更新する人、ソースのリポジトリ、ビルドに使うコンパイラは残る。
配布物をそのまま使うことは、リリースを作った人を信じることでもある。

ライブラリ内の不要な処理などってリスクにならないの?

使われないコードと、黙って劣化させうるコードが消されている。

ビルドの最後に走る opt/pi0/board/post-build.sh が、embit の Liquid 対応一式、非 ARM のバイナリ 5 種、純 Python の secp256k1 実装を .py と .pyc の両方で削除する。
その直後に verify-secp256k1-binary.sh が走り、消し残りがあればビルドを止める。

消しているのは暗号強度が足りないからではなく、想定した実装以外では動かさないためになる。

# Remove embit's pure-Python secp256k1 fallback: we must run only on the pre-compiled
# libsecp256k1 C code or not at all. We explicitly opt for hard failure over silent
# fallback.
# We must delete both the .py and the .pyc to fully remove the fallback.
rm -f ${TARGET_DIR}/usr/lib/python3/site-packages/embit/util/py_secp256k1.py
rm -f ${TARGET_DIR}/usr/lib/python3/site-packages/embit/util/py_secp256k1.pyc
SeedSigner/seedsigner-os opt/pi0/board/post-build.sh L40-L45

理由は検証スクリプトの冒頭に書かれている。

# Two things can go wrong, and they are not equally visible:
#
#   - libsecp256k1 is missing or unusable. embit raises at import and the app
#     never starts. Loud, but much better caught at build time than on first
#     boot. Checks 2-4.
#
#   - The pure python fallback is back in the image. Now if the library ever
#     fails to load, embit no longer raises -- it quietly signs with python
#     instead, on a device that looks and behaves like a working one. Check 1.
SeedSigner/seedsigner-os opt/external-packages/python-embit/verify-secp256k1-binary.sh L6-L14

起動しない壊れ方は騒がしいが、気づける。
対して純 Python が残っていると静かに Python で署名してしまい、見た目も挙動も正常な端末ができる。
この二つは同じ重さの失敗ではない、という整理になっている。

Pillow も同じ方向で、ソースからビルドされたうえで jpeg・webp・tiff・jpeg2000・lcms・xcb がいずれも無効になっている。
有効なのは freetype と libraqm だけで、PyPI の wheel が抱える画像デコーダ群は実機に載らない。

まとめ

ハードウェアウォレットとして、どのような主要機能があるのか、それをOSSプロジェクトとしてどう実装するべきかを考えさせられた。
RAM上で動きデータを管理するという作業データの揮発性も、純 Python フォールバックの排除も、Pillow のコーデック削減も、イメージからの削り込みも、「何も通知なく劣化した状態で動き続ける」ことを避ける方向で一貫している。

追ってみて分かったのは、SeedSigner はオープンソースで検証できる部分は可能な限り自分で検証できる構成になっているということだった。
アプリのソース、buildroot の設定、依存の sha256、再現ビルド、GPG 署名までは、その気になれば手元で照合できる。

一方でそこから下、同梱の libsecp256k1 と、それを作ったコンパイラ、SoC とファームウェア、カメラや LCD の基板は、読んでも確かめようがない部分もある。

個人的にSeedSignerを利用するときにトラストしているポイントは大きく4 つに絞れる。

  • 自分で調達したハードウェア
  • seedsigner-os の buildroot フォークが選んだ上流パッケージ一式
  • embit が tarball に同梱して配っている libsecp256k1 のビルド済みバイナリ
  • 配布イメージに付く GPG 署名

そして一番重要な署名関連の機能はbitcoin-coreを起点としたもので実装されており、トラストする部分はBitcoin Communityのものになっているのも頷ける。

結局のところ、どこまでを自分で確かめるかを選べること自体が、この端末の性格なのだと思う。

Appendix: 読みながら浮かんだ疑問

本筋からは外れるが、読みながら気になって調べたものを残しておく。

署名のたびに乱数の質が問題になることはあるか

ならない。
ECDSA のナンスは RFC6979 で秘密鍵とメッセージから決定的に導かれるので、署名の時点では乱数生成器を引かない。

シードのエントロピーはどこから来るのか

サイコロ、コイン投げ、カメラ画像の 3 つ。
いずれも hashlib.sha256 で圧縮してから embit.bip39 に渡される。 os.urandom や PRNG は使っていない。
乱数源は利用者が物理的に用意したもので、ソフトウェア側の乱数実装には依存しない。

ただし sha256 はエントロピーを増やさない。 コインを 111111... と入れれば、そのとおり弱いシードができる。
強さを決めているのは、入力そのものが予測困難かどうかになる。

真っ暗な場所で撮った写真からシードを作るとどうなるか

コードは暗い画像でエントロピーが落ちている事自体は止めない。
プレビューのチェックは「単色でない」「重複しない」の 2 つだけで、最終画像にはチェックが無い。
暗所ではカメラがゲインを上げるぶんセンサーノイズ自体は増えるが、その出力が何ビットの min-entropy を持つかは、この実装を読むだけでは分からない。

リスクがある部分としては「予測可能性がある」ということ。
値が 0 付近に丸められる、固定パターンのノイズしか残らない、といった状態になると弱くなる。
最終画像は JPEG を経由するので、ノイズのある高周波成分も削られる。
確実にしたいならサイコロを使うのが早い。

カメラから入ってくるデータの攻撃面はどれくらいあるか

通常の署名の流れで、信頼できない外部データがアプリ層まで届く主な経路になる。
そこに 3 段のパーサが並んでいる。
C 実装の libzbar が QR をデコードし、urtypes が UR を組み立て、最後に PSBT のパーサが動く。
PSBT の受け口は UR・Specter・BBQR・base64・base43 の 5 形式あるのに対し、出す側は UR の 1 形式しかない。
入力の許容度が高いぶん、パーサの系統数も多い。

Pillow の既知の脆弱性は実機に影響するか

PyPI の wheel と実機のビルドは別物なので、そのままは当てはまらない。
画像デコーダ由来の CVE の大半は、実機に載っていないコードの話になる。

GitHub Actions が乗っ取られたら配布イメージは汚染されるか

公式リリースには直結しない。
CI が作るイメージは検証用で、配布されるものはオフラインでビルドされ GPG 署名される。

投機的実行の攻撃は効くのか

推奨機の Pi Zero は ARM1176 でインオーダ実行なので、Arm が公表している影響コア一覧にも載っていない。
Cortex-A72 を積む pi4 だけが原理的に対象になる。

そもそも攻撃者のコードを同じ CPU で走らせる足場が無い。
無線ハードを積む pi02w や pi4 向けでも、4 つのボード設定のいずれにもドライバもスタックも入っていない。
動くのは単一の Python アプリだけで、外から入るデータは QR として解析されるだけで実行されない。

表示された QR が /tmp に残るのは問題ないか

qrencode は生成した PNG を /tmp/qrcode.png に書く。
ただしルートは initramfs で RAM 上にあるので、電源を切れば消える。
SD カードには書かれない。

この経路を通るのは署名済み PSBT・xpub・署名文字列・アドレスで、転写用 SeedQR は別経路なのでシードは残らない。