Goで秘密情報をメモリ上で守る — memguard を実際に使ってみた

最近 Go でデスクトップ向けの小さなアプリを書いていて、起動中に「ユーザが入力した認証用の秘密」をメモリに保持する必要に迫られた。コード的にはフィールドに string で持つだけなのだが、改めて考えると気持ちが悪い。Go の string は immutable で、ログアウト時に空文字を代入しても元の領域が GC されるまで残る。そもそも swap に書かれてしまえばディスクにも残る。

個人用ツールなので大袈裟ではあるのだけれど、せっかくなので memguard を試してみたメモを残しておく。

memguard はそもそも何

awnumar/memguard は Go で書かれた「メモリ上の秘密情報保護ライブラリ」。Pure Go(C 依存なし)で macOS / Linux / Windows をサポートしている。

提供するものは大きく分けて4つ。

  • LockedBuffer: mlock 済み + ガードページに挟まれた []byte。使用していない間は mprotect で「読み書き禁止」に切り替えられる
  • Enclave: アイドル時用の「金庫」。中身を session 鍵で XSalsa20Poly1305(認証付き暗号) で暗号化して保管する。cold-boot 攻撃 にも耐性のあるスキームが採用されている
  • シグナルハンドラ: CatchInterrupt() で SIGINT を、CatchSignal(func, signals...) で任意のシグナル(SIGTERM など)を捕まえてエンクレーブをゼロ埋めしてから終了
  • SafePanic / SafeExit: 通常の panic() / os.Exit() の代わりに使うと、secrets を wipe してから panic / exit する

「OS ユーザ内で動く非特権マルウェアからの軽量保護」というのが現実的な位置付け。とはいえ cold-boot まで考慮された設計なので、想定脅威の幅は素直に書いておくと案外広い。root 権限を持つ攻撃者まではさすがに防げない。

なぜ「素の []byte」ではダメか

リアルな脅威を整理しておく。

  1. swap への流出: メモリ不足になると OS がプロセスのページをディスクへ書き出す。swapfile を後から漁られて秘密が出てくる
  2. コアダンプ: プロセスがクラッシュすると /cores/ などにメモリイメージが残る。crashreporter が転送することもある
  3. 同ユーザのプロセス: マルウェアが同じ UID で動いていれば ptrace/proc/self/mem で他プロセスのメモリを覗ける(macOS は SIP が緩和しているが Linux はデフォルトで可能)
  4. GC コピー: Go の GC は世代間でオブジェクトをコピーする。「古いコピー」がヒープのどこかに残ることがある
  5. string の immutability: 秘密を string で持つと書き換えられない。s = "" で代入しても元バッファは GC されるまで存在する

memguard はこのうち 1, 2, 3 と「秘密情報の寿命を制御する」部分にアプローチする。

ハロー・LockedBuffer

まずは一番シンプルな使い方から。

package main

import (
	"fmt"

	"github.com/awnumar/memguard"
)

func main() {
	defer memguard.Purge() // プロセス終了時に全部ゼロ埋め

	// 32バイトの mlock 済みバッファを確保
	buf := memguard.NewBuffer(32)
	defer buf.Destroy() // 使い終わったらゼロ埋め+解放

	copy(buf.Bytes(), []byte("very-secret-value"))
	fmt.Printf("len=%d, value=%s\n", buf.Size(), string(buf.Bytes()))
}

memguard.NewBuffer(n) で確保したメモリは内部で:

  • mlock(2)(Windows なら VirtualLock)が呼ばれており swap されない
  • 前後にガードページが挟まっていて、典型的なバッファオーバーフロー(buf[33] = 0 的なもの)で SIGSEGV する
  • データ末尾からページ境界までの領域に canary(ランダムバイト列)が書き込まれ、preguard / postguard と一致比較することで書き換えを検知する

buf.Destroy() は確保した領域を 必ず ゼロ埋めしてから OS に返す。defer で呼ぶ運用にすれば、関数を抜けた瞬間に消える。

既存の []byte から作る

API キーをファイルから読んだ場合などはこちら。

raw, _ := os.ReadFile("api.key")
buf := memguard.NewBufferFromBytes(raw)
// この時点で raw[:] はゼロ埋めされている
defer buf.Destroy()

ポイントは NewBufferFromBytes引数の slice を消費 すること。呼び出し直後に raw を見ると全部ゼロになっている。これで「中間バッファに残る」事故を 1 段階減らせる。

アイドル時は Enclave に封入する

LockedBuffer は使用中こそ安全だが、確保しっぱなしだと「読める状態」のページが居座る。長時間保持するなら Enclave に封入したい。

// 受け取り直後に Enclave 化
keyBuf := memguard.NewBufferFromBytes([]byte(password))
enclave := keyBuf.Seal()
// この時点で keyBuf は無効化済み(Seal が内部で Destroy する)

// ... 数分後、必要になったら ...
opened, err := enclave.Open()
if err != nil {
	// canary 改竄や復号失敗
	panic(err)
}
defer opened.Destroy()

useTheKey(opened.Bytes())

Seal の中では「ライブラリ初期化時にランダムで作られた session 鍵」で XSalsa20Poly1305(authenticated encryption) で暗号化して、ciphertext を別の LockedBuffer に保管する。session 鍵自体も mprotect で「読み取り禁止」されており、復号する瞬間だけアンロックする。

XSalsa20Poly1305 は NaCl 系の AEAD で、改竄を Poly1305 タグで検出する。enclave.Open() で復号に失敗するとエラーが返るのは、典型的にはタグ検証で弾かれているケース。

つまりエンクレーブの中身を「停止状態のスナップショット」で奪うのは、session 鍵を別途復元しない限り無理ということになる。さらに cold-boot 攻撃(電源断直後の DRAM 残留電荷から鍵を復元する系の攻撃)に対しても、session 鍵と secret を物理的に同じページに置かない / session 鍵自体を分割保管するなどの工夫で「DRAM をそのまま吸われても復元しにくい」設計になっている。

シグナル対応

これを忘れがちなので忘れずに。

func main() {
	memguard.CatchInterrupt() // SIGINT (Ctrl+C) をフック
	defer memguard.Purge()    // 正常終了時のクリーンアップ

	// アプリ本体
	run()
}

CatchInterrupt は実体としては CatchSignal(func(_ os.Signal){}, os.Interrupt) の薄いラッパーで、SIGINT のみ を捕まえる。SIGTERM や SIGHUP も拾いたい場合は明示的に書く。

memguard.CatchSignal(func(s os.Signal) {
	log.Printf("received %s, cleaning up", s)
	// 自分のクリーンアップ処理
}, os.Interrupt, syscall.SIGTERM)

CatchSignal のハンドラが返ると memguard 側で session を wipe し、core.Exit(1) で終了する流れ。

Purge は手動でも呼べる「全部消す」ボタン。既存の Enclave も復号不能になる ので、ログアウト操作で能動的に呼んでもよい。defer memguard.Purge()main に置いておけば return 経由でも片付く。

なお panic() してしまうと defer は走るがメモリは GC まで残るので、致命的エラーは memguard.SafePanic(err) を使うと wipe してから panic してくれる。同様に memguard.SafeExit(code) も用意されている。

実例: ユーザの認証情報を「セッションが生きている間だけ」持つ

実際に試したコードに近い形で示す。流れはこう。

  1. ユーザが UI 経由で秘密(API トークン / パスフレーズなど)を入力 → アプリに string として届く
  2. それを即座に Enclave に封入してアプリ構造体に保持
  3. 必要時に Open で短時間だけ平文化、使い終わったらゼロ埋め
  4. ログアウト時 / セッション終了時に Destroy
type Vault struct {
	mu  sync.Mutex
	key *memguard.Enclave // nil なら未保持
}

// 受け取り
func (v *Vault) Store(secret string) {
	v.mu.Lock()
	defer v.mu.Unlock()

	if v.key != nil {
		// 既存のキーを先に破棄
		if buf, err := v.key.Open(); err == nil {
			buf.Destroy()
		}
	}
	// NewBufferFromBytes は []byte を消費するので、
	// secret string からの中間コピーは1度だけ
	v.key = memguard.NewBufferFromBytes([]byte(secret)).Seal()
}

// 取り出し(短命コピー)
func (v *Vault) Snapshot() ([]byte, bool) {
	v.mu.Lock()
	enc := v.key
	v.mu.Unlock()
	if enc == nil {
		return nil, false
	}
	opened, err := enc.Open()
	if err != nil {
		return nil, false
	}
	defer opened.Destroy()

	// 呼び出し元に渡すコピーを作る
	out := make([]byte, len(opened.Bytes()))
	copy(out, opened.Bytes())
	return out, true
}

// 廃棄
func (v *Vault) Clear() {
	v.mu.Lock()
	defer v.mu.Unlock()
	if v.key == nil {
		return
	}
	if buf, err := v.key.Open(); err == nil {
		buf.Destroy()
	}
	v.key = nil
}

呼び出し側はこんな雰囲気。

key, ok := vault.Snapshot()
if !ok {
	return errors.New("no key loaded")
}
defer memguard.WipeBytes(key) // 使ったら必ずゼロ埋め

if err := callExternalAPI(key); err != nil {
	return err
}

memguard.WipeBytes は引数の slice を for i := range b { b[i] = 0 } する単純なヘルパ。defer 1行で「使用後は必ず消す」契約を表現できるのが気に入っている。

動作の中身

ざっくり中で何が起きているのか。

LockedBuffer の構造

メモリ上に確保される領域は、上から下に向かって以下のように並ぶ。

#領域サイズ保護 / 役割
1Guard Page (preguard)1 pagePROT_NONE で読み書き不可。前方への overrun で SIGSEGV。canary 値も保持
2Payload (inner)データ長mlock 済みの実データ領域。Bytes() が指すのはここの先頭 N バイト
3Canary 領域inner の余白データ終端からページ境界までの空きを埋めるランダム値
4Guard Page (postguard)1 pagePROT_NONE で読み書き不可。後方への overrun で SIGSEGV。preguard と一致比較で改竄検知

buf.Freeze() で payload を PROT<em>READ に、buf.Melt()PROT</em>READ|PROT_WRITE に戻せる。「読むけど書かない」フェーズはフリーズしておくと、誤書き込みを SIGSEGV で検知できる。

Enclave の暗号化

memguard は起動時に Coffer という専用構造の中に 32 バイトの session 鍵を保管する。実装(core/coffer.go)を読むと:

  • left(32B)と right(32B)の 2 つの LockedBuffer に 値を分割保管 している(left XOR right で session 鍵を復元する形)
  • 別の 32B 乱数バッファ rand を使って、500ms ごとに Rekey() を呼んで left/right を再シャッフル している(const interval = 500 * time.Millisecond)

メモリスキャナーが「ちょっと止めて鍵らしき 32B を抜く」を試みても、止まったタイミングで left/right が再シャッフル中だったり、片方だけしか抜けなかったりで復元しにくい設計になっている。Enclave はこの session 鍵で XSalsa20Poly1305 暗号化された ciphertext を持つだけで、本体の secret 自体は短く済む。

ちなみに XSalsa20 は ChaCha20 と同じ ARX 系のストリーム暗号で、Salsa20 を 192bit nonce に拡張したもの。NaCl / libsodium で secretbox として広く使われているプリミティブで、AES-NI が無い環境でも素直に速い。

panic / exit のセーフネット

memguard 自身は runtime レベルの panic フックは仕込まない。代わりに公式 API として memguard.SafePanic(v any)memguard.SafeExit(code int) を提供しており、これらは内部で core.Panic / core.Exit を呼んで secrets を wipe してから panic / exit する。

つまり「致命的エラーで落とすとき」は panic(err) の代わりに memguard.SafePanic(err) を使う必要がある。普通の panic ではエンクレーブが GC まで残るので、ここを徹底するかどうかで実際の保護強度が変わる。

注意点

実装してみて気付いた「ここまでは守れない」もまとめておく。

1. string の immutability は越えられない

Go の string は zero 化できない(reflect.StringHeader 経由で書き換えるとランタイムがクラッシュする)。[]byte(s) でコピーすれば書き換え可能だが、元の s は GC まで残る。

つまり「外部から string で受け取る境界」は memguard で守れない。Web フレームワークや IPC 越しに JSON で string が飛んでくるシステムだと、その瞬間に Go string 化されて GC まで残る。

回避策: API 境界を []byte(JSON では base64)にしたうえで、受け取り直後に NewBufferFromBytes する。

2. 上位 API が string を要求する

例えばデータベースドライバが接続文字列の中に認証情報を埋めて受け取る設計だと、その URL を組み立てる過程で必ず string が生成され、内部に渡る。driver 側に「パラメータ化された認証 API」がない限りこの string は残る。

これは memguard の問題ではなく、上位 API が秘密情報を文字列で受け取る前提で設計されている古典的な問題。新しめのライブラリだと []byte を取るオプションを提供していることもあるので、選択時に確認する価値はある。

3. macOS の圧縮メモリ

macOS 12+ はデフォルトで swap せず圧縮メモリを使う。mlock は swap への書き出しを抑止するだけで、圧縮メモリ内には居る。圧縮メモリは別プロセスから直接読めはしないが、swapusage がカウントされる環境では効く。

4. root / SIP 緩和環境

root 権限を持つプロセスは mprotect / mlock を無効化できる。Linux の ptrace も root なら制限なし。脅威モデルに root attacker を含むなら memguard だけでは不十分で、TPM / HSM / セキュアエンクレーブ(macOS の SEP, Apple silicon の Data Protection)など別レイヤーが必要。

5. デバッガ

dlv attachlldb -p でアタッチされると memguard の保護は完全に貫通される。CI 環境などでは debugger アタッチを許可しない設定にしておく。

まとめ

memguard は「ハードコアな防御ライブラリ」というよりも「漫然と string に持っていた秘密を、明示的に寿命管理する型に置き換える」道具だと思って使うとしっくりくる。

導入のコストはとても小さい。

  • 依存: Pure Go の 1 パッケージ + indirect 1 つ
  • バイナリ増分: 800KB 程度
  • パフォーマンス: キーが数十バイトなら無視できる(XSalsa20Poly1305 の cost より、Open/Destroy のシステムコール / mlock / mprotect の方が圧倒的に重い)

「Goで書かれた CLI / TUI / デスクトップアプリで API トークンや秘密鍵をメモリに持ちたい」ようなケースには、defer 1 行で消去契約を表現できる API もあって、入れて損はないと感じた。

ただし冒頭にも書いた通り「OSユーザ内の非特権マルウェアから防ぐ軽量レイヤー」が現実的な狙いで、root を取られた / コアダンプを取られた / デバッガを刺された場合までは守れない。本番で「秘密が露出すると致命的」なものを扱うなら、TPM や HSM、もしくはサーバ側に秘密を留めて毎回認証する設計を併用するべき。

そのあたりの線引きさえ理解しておけば、Go アプリで秘密を扱うときの「とりあえずの安全弁」として悪くない選択肢。

参考リンク

コメント

タイトルとURLをコピーしました