zero-knowledge

アーキテクチャ的な盲目性としての Zero-Knowledge (2/5)

ほとんどのシークレットマネージャーは getSecret(name) を公開しており、平文が AI のトランスクリプトに載ってしまう。lodos のスキーマはそのやり取りを構造的に 不可能にする、AI はシークレットの値を見ることができず、ビルドがそれを強制する。

2 分で読めます

あなたの AI アシスタントが、たった今 Stripe のライブキーを見せてほしいと頼んできたとする。ほとんどのシークレットマネージャーはそれを許してしまう、API が getSecret(name) を公開し、エージェントがそれを呼び出し、平文がトランスクリプトに載り、そこからエージェントが話しているどんなパイプラインにも流れ込む。興味深いのは、そのやり取りを構造的に不可能にするにはどんなスキーマが必要か、という問いだ。

lodos では、宣伝しやすく、しかしエンジニアリングするのは難しいポジションを選んだ。AI はボールト内のどのシークレットの値も見ることができない。「見ない」ではなく、見られない。呼び出し元の AI に平文のシークレットを返すコードパスは存在しない。Prisma スキーマには AI が読めるカラムがなく、IPC 層には復号済みフィールドを返すメソッドがなく、MCP ツールレジストリには「値をくれ」という契約を持つツールがない。もし未来の自分がそれを追加しようとしても、ビルドスクリプトがマージ前に検知する。

この投稿はスキーマの解説だ、ポリシーによる盲目性ではなくアーキテクチャ的な盲目性を設計するとはどういうことか、そしてその引き換えに何を諦めるのか。ボールトは自分が最も誇りに思っているレイヤーであり、その理由の一部は、このエンジニアリングが「やらないこと」について誠実だからだ。

ふたつのストアモデル

素朴な設計はカラムひとつで済む。encryptedJson がセクションのペイロードを保持し、読み取り時に復号し、復号済みの blob からフィールドを取り出す。これは動くし、バグのクラスもひとつだけだ、どんな読み取りパスも、いずれ復号に行き着いてしまう。公開情報である account_id を欲しがっただけのルックアップが、機微な api_key まで一緒に復号してしまう。監査ログはその無駄な復号を捕捉できるかもしれないが、LLM のトランスクリプトへの漏洩はおそらく捕捉できない。

そこでボールトのセクションはふたつのストアを並べて持つ。

model SecretSection {
  id            String  @id @default(cuid())
  name          String  // "stripe", "aws", "github"
  encryptedJson Bytes   // libsodium secretbox(sensitive-fields, sectionKey)
  nonSensitive  Json    // { account_id, region, username, ... } - plain
  fieldSchema   Json    // [{key, type, sensitive, required}, ...]
  keyWrapped    Bytes   // sectionKey, wrapped by masterDEK
  // ...
}

ふたつのストアモデル: 書き込み時に各フィールドは sensitive フラグによって振り分けられる、機微なフィールドは libsodium を通って encryptedJson へ、非機微なフィールドは平文の JSON カラムへ、そのため公開フィールドを読み取る操作がシークレットを復号することは決してない。

すべてのフィールドは、自分が sensitive: true なのか sensitive: false なのかを知っている。振り分けは書き込み時に一度だけ行われる。機微なフィールドは libsodium を通り、非機微なフィールドは平文の JSON カラムに収まる。非機微なフィールドの読み取りは単なる SQLite の読み取りだ、復号もなく、DEK へのアクセスもなく、監査ログへの書き込みもない。機微なフィールドの読み取りにはマスターパスワードが必要になる。

失うもの: どのフィールドがどちら側なのか、自分自身に対して嘘をつく余地。読み取り時にその場で決めることはできない。スキーマがその判断を事前に強制する。

得るもの: バグのクラスがまるごと消える。チャット用の MCP ツール secrets_field_metadata は、非機微なフィールドについては実際の長さを返し、機微なフィールドについては 0 を返す、長さを隠したからではなく、メタデータのパスがそもそも encryptedJson に触れないからだ。この規律はコメントのレベルではなく、型のレベルで保たれている。

暗号化パイプライン

機微な側は Argon2id と libsodium の上で動く。何も奇をてらったものはない。

// On unlock
const masterDEK = await argon2id(masterPassword, {
  memLimit: 64 * 1024 * 1024,  // 64MB
  opsLimit: 3,
  salt: vault.argon2Salt,
});
// HMAC check against vault.masterKeyCheck - fail-fast on wrong password

// On read of a sensitive field
const sectionKey = unwrap(section.keyWrapped, masterDEK);
const plaintext = sodium.crypto_secretbox_open(
  section.encryptedJson, section.nonce, sectionKey,
);
const value = JSON.parse(plaintext)[field];
sectionKey.fill(0);  // zero-fill before GC

暗号化パイプライン: マスター DEK はメインプロセスの中にしか存在せず、レンダラーと Agent SDK のサブプロセスはボールトへのアクセス権も復号能力も一切持たない、そのため平文のシークレットが IPC 境界を越えて AI に渡ることは決してない。

盲目性の主張にとって重要な性質は3つある。第一に、マスター DEK はメインプロセスのメモリにのみ存在する、レンダラーにも、チャットエンジンのサブプロセスにも、IPC のペイロードにも決して現れない。第二に、単一セクションの侵害はボールト全体の侵害ではない。各セクションはそれぞれ独自のランダムな sectionKey を持ち、それがマスター DEK によってラップされている。第三に、アンロック状態にはアイドルタイムアウト5分と絶対上限30分が設定されており、それを過ぎるとすべてのセクションは再入力ひとつで読めなくなる距離に戻る。

ここでセキュリティに敏感な読者からは「それはそうだが」と言われ、メモリダンプやスワップファイル、GPU 上への残留について聞かれることを予想している。もっともな疑問であり、その大半はここでのスコープ外だ。重要な点は、AI がこのパイプラインに手を伸ばすことができないということだ。Agent SDK はボールトの状態に一切アクセスできないサブプロセスの中で動いており、メインプロセスとは IPC で通信するが、その IPC の表面には平文を返すメソッドがひとつもない。

パターン A、B、D:決して C ではない

このシステムでシークレットが使われうる方法は、正確に4通りしかない。

  • A:ボールトから決して出ない。 シークレットはワークフロー内で名前によって参照され、メインプロセス側で復号され、サブプロセスの環境変数に注入され、AI には決して返らない。AI が見るのは YAML の中の {{ secrets.stripe.live_key }} と、レスポンスの HTTP 200 だけだ。
  • B:狭い egress フィルター。 postgres://user:pass@host/db のような値がメインプロセス側で復号され、フィルターが host だけを抽出し、それが allowedEgress の値になる。シークレットそのものの値が IPC 境界を越えることは決してなく、派生した射影だけが越える。
  • C:復号して AI に返す。 ❌ 存在しない。登録された MCP ツールはない。IPC メソッドもない。コードパスもない。コードベースを _value_get$ にマッチするツール名でグレップすれば、その結果は構造的に空になる。ビルドスクリプトがそれを強制している。
  • D:注入して実行する。 サブプロセスがシークレットを環境変数に持った状態で起動される。シークレットとどんな AI 表層との間にも6層が存在する: env のみ(argv には載せない)、stdout/stderr へのエンコーディング・リダクター、シェル文字列を組み立てない、出力に対するコンテンツファイアウォール、不透明なパスを持つ HMAC 監査ログエントリ、呼び出しごとのハードコードされた egress 許可リスト。

パターン A、B、D はそれぞれ、シークレットの値を AI に一度も返すことなく有用な仕事をさせる。パターン C、復号して AI に返す、にはツールも、IPC メソッドも、コードパスも存在せず、ビルドスクリプトがその不在を強制している。

これらをパターンとして書き出すことの意味は、世の中のほとんどのプロダクトが出荷しているのが C だという点にある。彼らのシークレットマネージャーツールは平文をエージェントループに返し、それをインテグレーションと呼ぶ。パターン A から D もインテグレーションであることに変わりはない、ただ、シークレットをトランスクリプトに載せないというだけだ。

このトレードオフは正直に言う。パターン C を使えば楽になるワークフローもある。しかしパターン C が必須なワークフローはひとつもない。だから我々は A、B、D を出荷し、C を締め出すためにビルド時の grep を使う。

不透明な監査チェーン

監査ログは、設計していて最も驚かされた部分だ。

ボールトの監査行は「セクション X、フィールド Y がアクター Z によって時刻 T に注入された、署名 S、直前署名 P」と語る。素朴なスキーマなら fieldPath を平文で保存するだろう。それは漏洩だ、監査テーブルへの読み取り権限を持つ人なら誰でも、AI エージェントが stripe.live_key を注入したことが分かってしまう。「何かを注入した」ということだけではなく。規制対象の買い手がコンプライアンスレビューを行う場合、その監査テーブル自体が機密材料になってしまう。

そこでスキーマはパスそのものを保存しない。ハッシュを保存する。

fieldPathHash: sha256(sectionName + '.' + fieldKey).slice(0, 16)

16文字の不透明なトークンだ。エラーレスポンスすら、これを使う。注入が失敗したときのエラーは { pathHash: "a3f2…", sectionExists: true } であり、{ field: "stripe.live_key", error: "not found" } では決してない。監査ログの読み手は、メインプロセスでユーザー自身のコンテキストの中で動作し、逆引きを行う。AI が平文のパスを見ることは決してなく、漏洩した監査テーブルからも、どんなシークレットが保存されているかは何も分からない。

チェーンは HMAC でリンクされている、すべての行が、マスター DEK から派生したサブキーで (payload + prev-signature) に署名する。verifyAuditChain() は起動時に実行され、改ざんされた行や削除された行があればチェーンが壊れ、UI 上にフォレンジックアラートとして表示される。ハード削除は禁止されており、アーカイブは行をその場に残したままのソフト削除だ。

この監査テーブルをスクリーンショットにして SOC2 の監査人に見せることができる。監査人が目にするのは、アクター識別子、セクション、不透明なフィールドハッシュ、タイムスタンプ、そして HMAC チェーンだ。実際のシークレット名は漏れない。それこそが、コンプライアンスを気にする買い手がお金を払っている対象だ。

ユーザーがアーキテクチャとバグを見分けられなかったとき

最初の本格的なドッグフーディングの最中、あるユーザーが secret_inject_and_run 経由で echo $API_KEY を実行した。その呼び出しは MCP_LETHAL_TRIFECTA_GATE で失敗した。ユーザーはそれを AI アシスタントに持ち込んで診断させた。AI は約3000トークンを費やして、そのコマンドが「静的テキスト」だから拒否されたのだと説明し、curl 呼び出しへの切り替えを勧めた。誤りだ。ゲートの条件は allowedEgress.length === 0 && riskTier === 'novel' であり、その呼び出しには egress の許可リストがなかったため、宛先が宣言されていない潜在的な情報流出として拒否されていた。

このバグチケットは「モート(堀)の勝利」として再分類された。アーキテクチャは正しく機能しており、AI の混乱そのものがその証拠だった。システムはコマンドの内容ではなく egress の形状に基づいて拒否していたのであり、AI の誤診断は壊れた拒否機構の下流にあったのではなく、不親切なツールの説明文の下流にあった。我々はその説明文を修正したが、ゲートには手を触れなかった。

繰り返し立ち返ることになる教訓は、AI が理解できない拒否も依然として拒否である、ということだ。アーキテクチャ的な盲目性は、LLM がそれに納得していることを必要としない。

これが商業的にどれほどの価値を持つか

こうした仕事について考えている創業者たちに自分がする売り込みはシンプルだ。SOC2 や ISO の買い手は、「約束します」をポリシーとして受け入れない。彼らが受け入れるのは「不可能です」だ。あなたのライブキーを見ることができ、それを見ないと約束するベンダーは、ロギングスタックに時々 CVE を抱えるタイプのベンダーだ。スキーマ上その値を見ることができないベンダーは、リスクの種類そのものが違い、その種類は価格の付き方が違う。

初日から開けたままにしてある Phase-3 への道は、レシピエントごとのラップだ。セクションキーはすでに間接参照になっている。今日はマスター DEK によって一度だけラップされている。チームでの利用シーンでは、それが N 回、レシピエントの公開鍵ごとに一回、ラップされるようになり、それでもサーバーは秘密鍵を一切保持していないため復号できない。セクションごとの間接参照を最初のコミットから汎用的に保っておいたおかげで、このラップは書き直しではなく追加になる。その扉は、今それに向けた機能を何も出荷していない状態のまま開いている。

次のレイヤー

スキーマ設計は、シークレットを静止状態と注入時において守る。しかしスキーマはコードであり、コードは編集されうる。次の投稿、32 Build-Time Invariants That Fail My Build If I Regress、では、Prisma スキーマが後退しないよう保つスクリプト、ワークフローエンジンに eval が生えてこないようにする仕組み、そして MCP ツールレジストリに secret_value_get が芽吹かないようにする仕組みを見ていく。拒否は grep になり、その grep 自身が「グレップにまだ牙があること」を証明するための偽の違反を同梱して出荷される。

zero-knowledgeセキュリティarchitectureencryption

自分のマシンで会社を動かしましょう。

ウェイトリストに登録して、lodos の公開時に最初に使い始めましょう。