はじめに
ObsidianをPCとAndroidで使っていて、Vault自体はSyncthingで同期している。でもある日、セキュリティ周りを整理したタイミングでモバイル側だけAPIキーが消えた。
PC側にはObsidianのSecretStorageにAPIキーが残っている。一方で、APIキーをdata.jsonやMarkdownへ平文で書き戻して同期するのはやりたくない。
そこで、PCのSecretStorageに残っている秘密値を、同期フォルダへ平文で出さず、Android側のSecretStorageへ一度だけ安全に復旧する自作プラグインを作った。
最終的には20件のシークレットをPC側で暗号化し、Android側で自動復号・復旧まで成功した。
ただし、そこに至るまでかなりハマった。
この記事では、最終構成だけでなく、どこで壊れたか、なぜテストでは見抜けなかったか、どう切り分けたかまで全部まとめる。
この記事中のAPIキー、復旧コード、ローカルパスなどはすべて省略・抽象化している。
そもそもなぜ普通のファイル同期では戻らないのか
ObsidianのSecretStorageは、APIキーやトークンのような秘密値をプラグインのdata.jsonへ直接保存しないための仕組みだ。
公式ドキュメントでも、SecretStorageは秘密値を集中管理し、プラグイン設定側には秘密値そのものではなくSecret名を持たせる用途として案内されている。
- Obsidian公式: https://docs.obsidian.md/plugins/guides/secret-storage
- Obsidian API型定義: https://github.com/obsidianmd/obsidian-api/blob/master/obsidian.d.ts
ここで重要なのは、Vault内のMarkdownや.obsidian配下の通常ファイルを同期しても、SecretStorageの中身まで単純なファイル同期で移るわけではないこと。
つまり、
PC Vault Android Vault
| |
|---- Syncthing ----|
|
SecretStorage SecretStorage
API Keyあり API Keyなし
という状態が普通に起こり得る。
手入力し直せば終わる話ではあるが、対象が多い。しかもコピー&ペーストを繰り返すほど誤送信・クリップボード残留・入力ミスのリスクも増える。
そこで、復旧専用の小さな転送プロトコルを作ることにした。
最終的な設計
プラグイン名は仮にMobile Secret Recoveryとした。
やりたいことはこれだけ。
Android
1. 一時ECDH鍵ペアを用意
2. 24桁の復旧コードを生成
3. 公開鍵入りrequest.jsonを同期領域へ置く
↓ Syncthing
PC
4. ユーザーが24桁コードを入力
5. requestを照合
6. 許可したSecretだけSecretStorageから読む
7. ECDH + HKDFでAES鍵を導出
8. Secret群をAES-GCMで暗号化
9. response.jsonとして同期領域へ置く
↓ Syncthing
Android
10. responseを検証・復号
11. 空のSecretだけSecretStorageへ保存
12. request / response / 一時状態を削除
暗号周りは以下。
ECDH: P-256
KDF: HKDF-SHA-256
Encryption:AES-256-GCM
User code: 24桁数字
24桁コードは単なるUI上の合言葉ではなく、transaction locatorやKDF側のバインディングにも使う。
セキュリティ要件
最初に以下だけは固定した。
- APIキー本体を同期フォルダへ平文で置かない
- ログへ秘密値を出さない
- PC側は転送対象Secretをallowlistで制限する
- Android側に既存のSecretがあれば上書きしない
- request / responseにはTTLを持たせる
- 復旧完了後に一時ファイルを削除する
- 暗号文とrequestのidentityを相互にバインドする
この条件を守ったまま、実装をできるだけ小さくした。
ここからが本題:どこでハマったか
1. vault-id.txtを作ったら、むしろ壊れやすくなった
最初は「別Vaultのrequestと混ざったら怖い」と考えて、Vault固有IDをファイルに保存してバインドする設計にした。
.obsidian/plugins/mobile-secret-recovery/vault-id.txt
ところがAndroid実機では、このファイルが期待通り存在しないタイミングがあり、復旧コード生成より前に失敗した。
安全策として追加したはずのVault IDが、同期順序・初期状態・migrationまで生み、逆に攻撃面と故障点を増やしていた。
そこで設計を見直した。
SecretStorage自体が現在のVaultに紐づくローカルストレージとして扱われるため、今回の用途では別ファイルのVault IDを持たせなくてもよいと判断し、vault-id.txt依存とmigration処理を丸ごと削除した。
結果、コードも約50行減った。
学び
セキュリティ機構は多ければ多いほど安全、ではない。
既存の境界がすでに保証していることを別レイヤーで再実装すると、そのレイヤー自身が新しい状態管理になる。
2. ユニットテストが全部GREENなのにAndroidで落ちた
vault-idを消したあと、テストでは24桁コード生成まで通った。
それでも実機では、
モバイルAPIキー復旧に失敗しました。
秘密値はログへ出していません。
とだけ表示されて失敗した。
ここで痛かったのが、テスト用のObsidian shimが実機APIを十分に表現していなかったこと。
モックが便利すぎると、本番では存在しない挙動まで通してしまう。
この時点で方針を変えた。
「関数単体が通るか」ではなく、
コマンド実行
→ Mobile Identity作成
→ 24桁生成
→ request書込
→ 成功Notice表示
までを1本のスモークテストにした。
3. listSecrets()を一度、原因だと誤診した
次に疑ったのがSecretStorage.listSecrets()だった。
最初は「モバイルでは公開APIではないのでは」と判断して、固定allowlistを直接getSecret()する方式へ変えた。
しかし後から現行のObsidian APIを確認すると、listSecrets()は正式なAPIとして存在する。
つまり、これは誤診だった。
ここはかなり反省点。
実機障害を見た直後は、症状と近い「怪しそうなAPI」を原因認定したくなる。でも、API仕様はまず公式型定義とドキュメントで確認すべきだった。
最終的にPC側のSecret走査は、
for (const id of app.secretStorage.listSecrets()) {
if (!ALLOWLIST.has(id)) continue;
const value = app.secretStorage.getSecret(id);
// ...
}
のように、実在するSecret IDだけ列挙してallowlistと交差させる方式へ戻した。
学び
障害対応中の「それっぽい仮説」と「確認済み事実」は、はっきり分ける。
4. 「少し待ってから落ちる」に意味があった
Androidで再実行すると、今度は少し読み込みが入ってから失敗するようになった。
ここで同期領域を確認したところ、bridge/requestsにrequest JSONが実際に生成されていた。
これは大きかった。
鍵生成 PASS
24桁コード生成 PASS
locator生成 PASS
request JSON生成 PASS
request書き込み PASS
成功Notice 未到達
requestが残っている以上、失敗地点はかなり狭い。
request書込の後、成功Noticeの前に残っていた主な処理は、ローカルSecretStorageへrequest anchorを保存するところだった。
そこでSecret IDの長さを数えると、
identity ID : 約40文字
code ID : 約61文字
anchor ID : 71文字
anchorだけ長い。
元の形式はこんな感じ。
mobile-secret-recovery-request-anchor-vault-v1-<device-id>
ここで、Secret IDを64文字までに制限するモックを用意して現行コードを走らせたところ、実機と同じ位置で再現した。
Error: SECRET_ID_TOO_LONG:71
注意点として、Obsidian公式ドキュメントに「Secret名は必ず64文字以下」と明記されているわけではない。なので、64という数字をObsidian全体の公開仕様として断言するつもりはない。
ただし今回の実機挙動では、71文字のanchor IDが失敗要因になっていることと、64文字制限モックで同じ失敗経路を再現できた。
そこで意味を変えず、保存キー名だけ短縮した。
before:
mobile-secret-recovery-request-anchor-vault-v1-...
after:
mobile-secret-recovery-anchor-vault-v1-...
これで63文字になった。
暗号プロトコル内部のmsr-request-anchor-v1というドメイン分離文字列は変更していない。変えたのはSecretStorage上のラベルだけ。
修正後のスモークテストは、
MOBILE_START_24DIGIT_PASS
REQUEST_WRITE_PASS
SUCCESS_NOTICE_24DIGIT_PASS
SECRET_IDS_WITHIN_64_PASS
まで通った。
5. 「失敗途中からの再試行」をテストしていなかった
ここも重要だった。
実ユーザーは毎回クリーン状態から試すわけではない。
今回のように、
24桁コード: 保存済み
request: 書込済み
anchor: 保存失敗
という半端な状態が残ったまま次の実行をする。
そこで、失敗途中のrequestが残った状態から再実行し、古いtransactionを片付けて新しい24桁を発行できるかをテストに追加した。
FAILED_ANCHOR_RETRY_SELF_HEAL_PASS
RETRY_SUCCESS_NOTICE_24DIGIT_PASS
これでようやく「初回成功」ではなく、実際の障害復旧フローをテストできた。
学び
リカバリーツールのテストで一番重要なのはhappy pathではなく、壊れた途中状態からもう一度押したときだった。
6. Androidを突破したら、今度はPC版Obsidianで落ちた
Android側で24桁コードが出た。
「やっと終わった」と思ったら、PC側で同じコマンドを実行するとまた失敗。
そこでエラーを全部genericなNoticeへ潰すのをやめて、秘密値を一切含まないstage名だけ表示するようにした。
request-lookup
request-auth
secret-scan
encrypt
response-write
interactive
PCで出たのは、
失敗地点: interactive
だった。
これで暗号処理以前だと分かった。
該当箇所を見ると、24桁入力にブラウザのwindow.prompt()を使っていた。
const input = window.prompt("24桁コードを入力...");
しかしObsidianデスクトップ版はElectron上で動いており、Electronではprompt()がサポートされない。
Electron本体の現在のコードにも、promptをサポートしない分岐が残っている。
そこでwindow.prompt()を完全撤去し、ObsidianのModalを使った専用入力UIへ置き換えた。
簡略化するとこんな形。
class RecoveryCodeInputModal extends Modal {
onOpen() {
const input = document.createElement("input");
input.inputMode = "numeric";
// submit / cancel / Enter handling
}
}
入力UIについても、
OBSIDIAN_MODAL_INPUT_PASS
SUBMIT_24DIGIT_PASS
CANCEL_PASS
ENTER_PASS
までテストした。
7. PC側も暗号化完了までスモークテストした
Android側だけ強くしても意味がないので、PC側にも別のスモークテストを作った。
確認したのは、
SecretStorage.listSecrets()
→ allowlist照合
→ getSecret()
→ ECDH
→ HKDF
→ AES-GCM
→ response JSON書込
まで。
結果は、
DESKTOP_LISTSECRETS_SCAN_PASS
DESKTOP_ECDH_HKDF_AESGCM_PASS
DESKTOP_RESPONSE_WRITE_PASS
だった。
最終結果
最終的にPC版Obsidianで24桁コードを入力すると、
モバイル向けに20件のシークレットを暗号化しました。
同期後、モバイル側で自動復旧します。
まで到達した。
その後、同期領域を確認すると、
requests 0件
responses 0件
になった。
Android側でresponseを受信・復号し、SecretStorageへ復旧した後のcleanupまで走った状態だ。
実際にモバイル側の各プラグインを使って確認し、全部復旧できた。
最終的なテスト戦略
今回の一番大きな改善は、コードそのものよりテストの考え方だった。
最終的には以下を分けて持った。
1. 暗号・プロトコル単体
- request hash
- transaction locator
- ECDH
- HKDF
- AES-GCM
- AAD binding
- TTL
- tamper検出
2. Mobile start smoke
コマンド
→ identity
→ 24桁生成
→ request書込
→ anchor保存
→ Notice表示
3. Broken-state retry smoke
requestだけ残った
→ 次回起動
→ cleanup
→ 新transaction生成
→ Notice表示
4. Desktop transfer smoke
Secret列挙
→ allowlist
→ 暗号化
→ response書込
5. Desktop UI smoke
Modal open
→ 24桁入力
→ Enter / Submit / Cancel
ユニットテスト1種類だけでは、今回の問題はまず拾えなかったと思う。
AIと一緒に実装していて特に感じたこと
今回かなりAIに実装・監査・切り分けを手伝ってもらった。
便利だった一方で、AI開発特有の罠も見えた。
「監査PASS」と「実機で動く」は別
静的レビューで暗号設計や競合条件をかなり潰しても、実機API差分やElectron固有UIまでは保証してくれない。
最終的には、
Security audit PASS
≠
Real device PASS
だった。
モックは本番より不便に作る
最初のモックは便利すぎた。
後半は逆に、
- Secret ID長を制限
- 不要なAPIを持たせない
- 途中失敗を注入する
- stale stateを残す
など、本番より意地悪なモックへ変えた。
その方が圧倒的に役に立った。
エラーを全部隠すと、自分も何も分からなくなる
秘密値をログへ出さないのは正しい。
でも、
失敗しました
だけでは調査不能になる。
最終的には秘密値を含めず、
interactive
secret-scan
encrypt
response-write
のような安全なstage telemetryだけ残した。
これはかなり効いた。
まとめ
最初は「PCに残っているAPIキーをAndroidへ戻すだけ」の小さな復旧ツールだった。
でも実際には、
- 過剰なVault binding
- 実機を表現しないテストshim
- APIの誤診
- 長すぎるSecretStorage ID
- 半端なtransaction state
- Electronの
window.prompt() - genericすぎるエラー表示
と、かなり色々踏んだ。
最終的な教訓はシンプルだった。
セキュリティ設計を厳しくすることと、状態機械を複雑にすることは同じではない。
そして、リカバリーツールほど「正常系」より、途中で壊れた状態から本当に戻れるかをテストした方がいい。
結果として、APIキーを平文同期せず、PCのSecretStorageからAndroidのSecretStorageへ20件を一括復旧できるようになった。
かなり泥臭かったけど、こういう「最後の1個が実機でしか出ない」タイプのバグを一つずつ証拠で潰していくのは、やっぱり面白い。