0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ObsidianのAPIキーをPCからAndroidへ復旧するプラグインを作った

0
Posted at

はじめに

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名を持たせる用途として案内されている。

ここで重要なのは、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へ戻すだけ」の小さな復旧ツールだった。

でも実際には、

  1. 過剰なVault binding
  2. 実機を表現しないテストshim
  3. APIの誤診
  4. 長すぎるSecretStorage ID
  5. 半端なtransaction state
  6. Electronのwindow.prompt()
  7. genericすぎるエラー表示

と、かなり色々踏んだ。

最終的な教訓はシンプルだった。

セキュリティ設計を厳しくすることと、状態機械を複雑にすることは同じではない。

そして、リカバリーツールほど「正常系」より、途中で壊れた状態から本当に戻れるかをテストした方がいい。

結果として、APIキーを平文同期せず、PCのSecretStorageからAndroidのSecretStorageへ20件を一括復旧できるようになった。

かなり泥臭かったけど、こういう「最後の1個が実機でしか出ない」タイプのバグを一つずつ証拠で潰していくのは、やっぱり面白い。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?