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?

【開発日誌 Day5】メモアプリにAES-GCMを入れて詰まった3つの落とし穴

0
Posted at

Captio式シンプルメモ開発日誌
2026年5月18日

メモを暗号化するとは何か。データを読めなくすることではない。実は「自分以外の誰にも、本人ですら不注意では読めない状態に保つ」という動線設計の話だ。

Day4(起動0.3秒を越えるのに、結局UISceneの再設計が一番効いた)では、起動速度の話を書いた。今回はその対極にある「触れない速度」、つまり暗号化レイヤーの話だ。

CryptoKitでAES-GCMを書くだけなら、Appleの公式サンプルに5行のコードがある。しかし、それを「自分のアプリに組み込む」段階になると、ドキュメントに書かれていない3つの落とし穴が順番に待っていた。Day5の今日は、その3つを順番に潰した記録を残しておく。

なぜ個人開発のメモアプリに、わざわざ暗号化が必要なのか

「メモなんてただのテキストだろう」と何人かに言われた。違う。

メモには、未公開のアイデア、未送信のメール下書き、パスワードの一時控え、誰にも見せない感情の整理、そういう雑多な「外に出ていないもの」が混ざる。これらが「他人のデバイスから可読な状態」で保存されるリスクは、写真より高いと思っている。

iCloudに平文同期されるメモアプリは、Appleのサーバ側で(建前上は)保管時暗号化されている。しかし、エンドツーエンドではない。Apple従業員によるアクセス、政府要請、運用ミス、漏洩事故、そのすべての可能性を遮断したいなら、「鍵は自分のデバイスにしか存在しない」という構造を作るしかない。

これがエンドツーエンド暗号化(E2EE)と呼ばれる構造で、2026年現在、メモアプリでアプリ全体をE2EE化しているプロダクトは数えるほどしかない。標準のAppleメモも、E2EE対応は「ロックメモ」機能だけだ。今回はアプリ内のレコードすべてをE2EE化することをゴールに据えた。

検索すれば「ios 暗号化 個人開発」というクエリで似た議論は出てくる。ところが、ほとんどの記事が「とりあえずAES-256でセキュア」で終わっていて、ノンス管理や認証付きデータ(AAD)、鍵ローテーションまで踏み込んでいるものは少ない。だから自分で書くことにした。

AES-GCMとChaCha20Poly1305、2026年のiOSではどちらを選ぶべきか

CryptoKitには、対称鍵のAEAD(Authenticated Encryption with Associated Data)が2つ用意されている。AES-GCMとChaCha20Poly1305だ。

AES-GCMは、AES暗号にGalois/Counter Modeを組み合わせた認証付き暗号方式である。暗号化と改ざん検出を同時に行う。鍵長は128/192/256bitから選べ、メモアプリなら迷わず256bitだ。

ChaCha20Poly1305は、AES-NIなどのハードウェアアクセラレータを持たないCPUでAES-GCMより速い。古いAndroid低価格機や、AES命令を持たない一部の組み込みARMではこちらが優位だ。

しかし、iPhoneのA7チップ(2013年)以降、すべてのApple SoCはAES命令を内蔵している。逆に2026年現在、Apple Silicon上で同じ平文を流して測ったベンチでは、AES-GCMのほうがChaCha20Poly1305より体感1.5〜2倍速い。バッテリー消費も少ない。

だからこそ、iOS専用アプリならAES-GCMで迷う必要がない。AES命令の上に乗ったほうが、コードもシンプルになる。

import CryptoKit

let key = SymmetricKey(size: .bits256)
let sealed = try AES.GCM.seal(plaintext, using: key)
let combined = sealed.combined!  // nonce(12) + ciphertext + tag(16)

5行で動く。動くが、本番はここからだ。

CryptoKitの最短コードと、その先に潜む3つの落とし穴

AES.GCM.seal は内部でランダムなノンスを自動生成する。combined プロパティを使えば「12バイトのノンス+暗号文+16バイトの認証タグ」が1つのDataにまとめて返ってくる。

復号は対称形だ。

let box = try AES.GCM.SealedBox(combined: storedData)
let decrypted = try AES.GCM.open(box, using: key)
let plaintext = String(data: decrypted, encoding: .utf8)

ここまでは綺麗だ。しかし、これを実際のアプリ動線に組み込もうとした瞬間、3つの落とし穴に順番に落ちた。順番に書いていく。

落とし穴1:鍵を「どこに」「どう」保存するか

SymmetricKey(size: .bits256) で鍵を生成しても、それをどこかに保存しないとアプリ再起動時に過去のメモが全部復号不能になる。「鍵をどこに置くか」が暗号化システムで一番重要な決定だ。

UserDefaultsは論外。NSKeyedArchiverもダメ。あれは平文で保存される。

ファイルに書く方法もある。File Protection属性を .complete にすれば、Lockscreenが施錠されている間は読めない。しかし、解除中はマルウェアから普通に読まれる可能性がある。

正解はKeychainだ。kSecAttrAccessibleWhenUnlockedThisDeviceOnly を指定する。「このデバイスでしか使えない」「Lockscreen解除中のみ読み出せる」鍵が作れる。

import Security

enum KeychainError: Error { case unhandled(OSStatus) }

func saveKey(_ key: SymmetricKey, tag: String) throws {
    let keyData = key.withUnsafeBytes { Data(Array($0)) }
    let query: [String: Any] = [
        kSecClass as String: kSecClassKey,
        kSecAttrApplicationTag as String: tag.data(using: .utf8)!,
        kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
        kSecValueData as String: keyData
    ]
    SecItemDelete(query as CFDictionary)
    let status = SecItemAdd(query as CFDictionary, nil)
    guard status == errSecSuccess else { throw KeychainError.unhandled(status) }
}

func loadKey(tag: String) throws -> SymmetricKey {
    let query: [String: Any] = [
        kSecClass as String: kSecClassKey,
        kSecAttrApplicationTag as String: tag.data(using: .utf8)!,
        kSecReturnData as String: true,
        kSecMatchLimit as String: kSecMatchLimitOne
    ]
    var item: CFTypeRef?
    let status = SecItemCopyMatching(query as CFDictionary, &item)
    guard status == errSecSuccess, let data = item as? Data else {
        throw KeychainError.unhandled(status)
    }
    return SymmetricKey(data: data)
}

ThisDeviceOnly を付けないと、iCloud Keychain経由で他デバイスに鍵が同期されてしまう。同期されると「鍵はこのデバイスにしか存在しない」という前提が崩れる。E2EEとして成立しない。

逆に、ユーザーが「複数デバイスで同じメモを見たい」と要求してきたら、別の仕組みでマスター鍵を派生させる必要がある。たとえばユーザーパスフレーズからHKDFで鍵導出する、Secure Enclaveに紐付くキーペアでラップする、などだ。これは別の長い話なので、今日は触れない。

実装上もう一つ重要な点がある。Keychainエントリ自体をiTunes/iCloudバックアップ対象から外したい場合、kSecAttrSynchronizablefalse にしておく。ThisDeviceOnly 系のアクセシビリティを使えば暗黙的に同期不可になるが、明示的に書いておくと将来の自分が読んだときに迷わない。

落とし穴2:ノンスを使い回した瞬間に、AES-GCMは壊れる

AES-GCMのノンスは、同じ鍵で2回使うと致命的に壊れる。「壊れる」というのは比喩ではない。GCMモードのカウンタ機構の特性で、2つの平文の差分が漏洩する。鍵そのものが復元できる場合もある。

AES.GCM.seal(plaintext, using: key) はノンスを自動生成するので、自分で書いている限りは安全だ。ところが、「キャッシュキーに使いたいから決定的にしたい」「ベンチが速いから固定にしたい」みたいな最適化欲求が出た瞬間、地獄が始まる。

CryptoKitのデフォルトはCSPRNG(暗号学的に安全な乱数生成器)由来の12バイトノンスだ。これに人力で勝とうとしないこと。「決定的暗号化」をビジネス要件として要求されない限り、自動生成で良い。

// NG: ノンスを固定。同じ鍵で2回呼ぶと致命的
let badNonce = try AES.GCM.Nonce(data: Data(repeating: 0, count: 12))
let bad = try AES.GCM.seal(plaintext, using: key, nonce: badNonce)

// OK: ノンスは省略してCryptoKitに任せる
let good = try AES.GCM.seal(plaintext, using: key)

combined プロパティで取り出せば、ノンスは暗号文と一緒に保存される。復号時には自動的に取り出される。この設計に乗ることが、最も安全で、最も速い。

なお、12バイトノンスは2の96乗通りある。CSPRNGで生成しているなら、同じ鍵で2回同じノンスを引く確率は天文学的に低い。理論上は同じ鍵で約2の32乗回まで安全に使えるとされている。メモアプリの保存頻度なら、鍵をローテーションする前に2の32乗回に到達することはない。

落とし穴3:Authenticated Dataを設計しないと、文脈の改ざんに気づけない

AES-GCMの本質は「機密性+完全性」だ。完全性のために16バイトの認証タグが暗号文と一緒に保存される。

しかし、認証対象が暗号文そのものだけだと、こういう攻撃に弱い。「メモAの暗号文ブロックを、メモBの保存位置に物理的にコピーする」攻撃だ。コピーしても暗号文・タグ内部の整合性は崩れない。復号は成功してしまう。文脈だけがすり替わる。

これを防ぐためにあるのが authenticating: パラメータだ。レコードごとに固有な識別子(UUIDなど)を渡しておくと、復号時に「このタグはこの識別子と一緒のときだけ有効」と検証される。

let memoID = memo.id.uuidString.data(using: .utf8)!
let sealed = try AES.GCM.seal(plaintext,
                              using: key,
                              authenticating: memoID)
// 保存後...
let box = try AES.GCM.SealedBox(combined: storedData)
let opened = try AES.GCM.open(box,
                              using: key,
                              authenticating: memoID)

この memoID は平文で保存しても構わない。むしろ平文で保存して、暗号文と紐付けるのが正しい使い方だ。改ざんされたら復号時に CryptoKit.CryptoKitError.authenticationFailure が確実に飛ぶ。

ここまで設計して、初めて「メモの暗号化」がプロダクトとして成立する。

なぜ日本語の cryptokit aes-gcm swift 解説で、AADの話はほとんど語られないのか

検索エンジンで「cryptokit aes-gcm swift」と打って出てくる日本語記事の大半は、authenticating: パラメータに触れていない。

理由は推測できる。Appleの公式サンプルが authenticating: なしで完結しているからだ。サンプルをコピペすれば動く。動くから、AAD(Additional Authenticated Data)の存在に気づかないまま完成する。

しかし、AADを使わない暗号化は「箱の中身は守れるが、箱に貼られた宛先ラベルは守れない」状態だ。実は本気の脅威モデルでは、これでは足りない。

たとえばDay3で書いたOutboxアーキテクチャでは、未送信メモがCore Data上に複数件並ぶ。AADがないと、攻撃者が暗号文ブロックを並び替えて「宛先Aのメモを宛先Bに届く順番に差し込む」ことが理論上可能になる。これは脅威モデルとしては小さいが、ゼロにできるならゼロにしたい。

鍵ローテーションをどう設計するか

ここまで書いて、もう1つ重要な話を残してしまった。鍵ローテーションだ。

長期間運用する暗号化システムは、定期的に鍵を更新したほうがいい。同じ鍵を3年使い続けると、鍵流出時の被害範囲が大きくなる。ところが、鍵を更新すると過去のデータが復号できなくなる。

現実的な選択肢は3つある。

第一は「定期再暗号化」だ。半年に一度、新しい鍵を生成し、既存のレコードを順次復号して再暗号化していく。ストレージI/Oが重いが、データ量がメモアプリ規模なら現実的だ。完了するまでは新旧2つの鍵を保持する。

第二は「鍵階層」だ。マスター鍵(KEK: Key Encryption Key)と、レコードごとのデータ鍵(DEK: Data Encryption Key)を分ける。マスター鍵だけをローテーションすれば、DEKをラップし直すだけで済む。実装は複雑になる。

第三は「ローテーションしない」だ。ThisDeviceOnly でデバイス紐付けし、OSのSecure Enclaveに守らせる前提なら、鍵流出のリスクはほぼゼロ。アプリ削除時に鍵も消える運用ならローテーション不要、という立場もある。

自分は第三案で行くと決めた。理由はシンプルで、メモアプリの利用期間は写真ライブラリほど長くないことが多く、複数デバイスE2EE同期もv1では対応しないからだ。第一案・第二案は、複数デバイス同期を実装する段階で改めて設計し直す。

// 鍵バージョンだけ平文で持っておくと、将来のローテに対応しやすい
struct EncryptedRecord {
    let keyVersion: UInt8       // 0x01 から開始
    let ciphertext: Data        // sealed.combined
    let recordID: UUID          // authenticating: 用
}

keyVersion を1バイト平文で添えておけば、将来「v1鍵で暗号化されたレコードはこの鍵で復号、v2はこっち」という分岐が書ける。1バイトの保険だ。これは後悔したくない設計判断だった。

反論:個人メモアプリで、ここまでの設計は過剰なのか

「メモアプリでそこまでやる必要ある?」という疑問は当然出てくる。自分でも書きながら何度も自問した。

確かに、攻撃者が「iPhoneの中身を物理的に解析する」レベルの脅威モデルだと、AES-GCM一発で防げるものは限定的だ。Secure EnclaveやFile Protection、iOSのサンドボックス、それらが本来の防衛線である。アプリ側で頑張る範囲は、その上に「もう一枚」載せる程度の意味しかない。

ところが、個人開発で本当に恐ろしいのは「自分の実装ミス」のほうだ。バックアップを取った瞬間に平文がクラウドに送られる。iCloud同期を有効にした瞬間、平文がAppleのサーバに乗る。シェア機能を実装した瞬間、Share Sheet経由で平文が外部アプリに渡る。クラッシュレポートに平文が混入する。

つまり、AES-GCMをアプリ内部の保存層に「素材として暗号文しか触れない」形で挟むと、こうした「うっかり平文を外に出す動線」を物理的に塞げる。これが個人開発における暗号化の本質的な価値だ。「強固なセキュリティ」というより、「自分の将来のミスへの防衛線」として効く。

だからこそ、保存層・送信前のキュー・iCloud同期前のシリアライズ、すべての段階で暗号文しか触れないようにした。AES-GCMはその「動線統制」の道具として使っている。セキュリティのためではなく、設計のシンプルさのためだ。

今日から試せる、暗号化レイヤー導入チェックリスト

新規プロジェクトに暗号化を後付けで入れるのは難しい。最初から入れたほうがいい。最低限これだけは押さえたい。

  • 鍵長は256bit(SymmetricKey(size: .bits256))。妥協する場面は思いつかない
  • 鍵はKeychainに kSecAttrAccessibleWhenUnlockedThisDeviceOnly で保存
  • ノンスは AES.GCM.seal の自動生成に任せる。固定・カウンタ実装はしない
  • authenticating: にレコードIDを渡して文脈の改ざんを防ぐ
  • 復号失敗(authenticationFailure)は静かに無視せず、必ずユーザーかログに通知する
  • iCloudバックアップ対象から鍵Keychainエントリを除外する設定を、Info.plistとSwiftコード両方で確認
  • keyVersion を1バイト平文で添え、将来のローテーションに備える
  • クラッシュレポートツールに渡るデータが平文を含まないか、リリース前に必ず手動検証する

これだけで「箱」は守れる。中身を表示するUI側の動線、コピーペーストの扱い、スクリーンショット禁止、これらはまた別の話だ。まずは保存層から始めるのが筋がいい。

今日の学び:暗号化は機能ではなく、動線の設計だ

CryptoKitで5行書けば暗号化は動く。しかし、その5行を「アプリ全体の動線」に組み込むのは別の仕事だった。

Day2では送信体験を書いた。Day3ではOutboxを書いた。Day4では起動速度を書いた。そして今日、暗号化を書いて気づいたことがある。

これら全部、「ユーザーから何を遠ざけるか」の設計だった。送信成功トーストを遠ざける。再送ロジックを遠ざける。起動時のローディングを遠ざける。平文データを遠ざける。

メモアプリとは、書く体験だけを残して、それ以外を全部遠ざける装置なのかもしれない。だからこそ、暗号化はアプリの内側で静かに動いていればいい。ユーザーが「暗号化されている」と意識した時点で、何かが間違っている。

次回(Day6)予告:Relay APIの設計

Day6では、送信を支える Relay API の設計について書く予定だ。

  • なぜ「Gmail送信」をアプリから切り離したか
  • SMTPレスにしたことで得たものと、失ったもの
  • メール認証(SPF・DKIM・DMARC)を個人開発の予算で通すときの現実

メール送信は、暗号化と並ぶ「アプリの裏側」だ。表に出さないけれど、ここが甘いとメモが届かない。

コメントで教えてください

CryptoKitとKeychainの組み合わせで、実装ではまった話があれば教えてほしい。

特に「鍵ローテーションをどう設計したか」「iCloudバックアップから鍵をどう除外したか」「複数デバイスでのE2EE同期をどう実装したか」あたりは、定型解がないのでみんなの設計を見たい。クラッシュレポートツール経由で平文が漏洩したことがある人の事例も大歓迎する。


Captio式シンプルメモ
外部ライブラリ依存ゼロ。Swift + Apple純正フレームワークだけで作った、起動0.3秒のメモアプリ。
App Store:https://apps.apple.com/jp/app/captio%E5%BC%8F%E3%82%B7%E3%83%B3%E3%83%97%E3%83%AB%E3%83%A1%E3%83%A2/id6749649498

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?