PUBLIC DECRYPTION SPECIFICATION
Secret Fort 外部復号仕様書 v3
この文書について(公開用の前置き)
本書は Secret Fort の 公開版・外部復号仕様書(v3) です。Secret Fort は、暗号化と鍵管理をすべて利用者の端末上で完結させ、クラウドには暗号文のみを保存するゼロ知識設計を採用しています。本書は、アプリが使えなくなった場合でも利用者が自力でデータを復号できる手段 を、アプリの実装に依存せず公開することを目的としています。
復号に必要なのは、利用者自身がクラウドストレージに保有する暗号化コンテナファイル(
vault.dat等)と、利用者だけが知るマスターパスワードのみです。本書はこの2つを用いた復号手順・データ形式・参照実装を記載します。本書の公開によって秘密が漏れることはありません(鍵はマスターパスワードから導出され、どこにも保存されないため、パスワードを知らない第三者は本書があっても復号できません)。技術的内容は原本仕様書(
docs/decryption_spec.md・v3)と同一です。
概要
本仕様書は、Secret Fortアプリが廃止された場合にユーザーが自力でデータを復号するための手順を記述する。
復号に必要なもの:
- クラウドストレージ上の暗号化コンテナファイル1つ
- OneDrive / Dropbox:
vault.dat(復号用メタデータを内包した単一ファイル) - Google Drive:
commits/フォルダ内のc-〜.datファイル(各ファイルがvault.datと同一形式の完全なコンテナ)
- OneDrive / Dropbox:
- ユーザーのマスターパスワード
技術的な正典仕様(AADバイト仕様・テストベクター)は docs/基本設計書/08_外部復号仕様.md を参照。本書は復旧作業者向けの自己完結した手順書である。
ファイル構成
クラウドストレージの Secret Fort フォルダ(OneDrive はドライブ直下、Google Drive はマイドライブ直下。Dropbox はアプリフォルダ配下の SecretFort)に以下のファイルが保存される。
| ファイル名 | 用途 |
|---|---|
vault.dat | 暗号化Vault本文 + 復号用メタデータ(単一JSONコンテナ)。OneDrive / Dropbox のみ |
sync_meta.json | 同期判定用メタデータ(平文・秘密情報なし。復号には不要) |
vault.bak1.dat 〜 vault.bak3.dat | 世代バックアップ(vault.dat と同一形式)。OneDrive / Dropbox のみ |
vault.replaced.{UTCタイムスタンプ}.dat | データ置き換え時のイベント退避ファイル(vault.dat と同一形式。全サービス共通) |
commits/c-〜.dat(Google Drive のみ) | 保存履歴(commit)ストア。1保存 = 1ファイルで、各ファイルは vault.dat と同一形式の完全なコンテナ |
すべてのファイルは完全に同一のJSONコンテナ形式であり、本書と同一の手順で1ファイル単位で復号できる。復号対象としてどのファイルを指定してもよい。
Google Drive の commit ストア(commits/c-〜.dat)
Google Drive では vault.dat の代わりに、保存のたびに commits/ フォルダへ新しい不変ファイルが追加される(過去の保存履歴はすべて残る)。復旧時の注意:
- どのファイルも単独で復号できる。ファイル名・作成日時から「どれが最新か」を機械的に断定することはできない(ファイル名は保存内容の識別子で、順序を表さない)
- 実用的には、Drive 上の更新日時が新しいファイルから順に復号を試し、復号後の
updatedAt(最終更新日時)と内容を確認して採用する版を利用者自身が選択する - 復号した JSON 内の
vaultVersion(単調増加の版番号)とparentCommitIds(親の保存ID)で保存の前後関係を確認できる - 候補の列挙・選択の詳細手順は
docs/基本設計書/08_外部復号仕様.mdを参照
世代バックアップ(vault.bak1〜3.dat)
- アプリがクラウドへアップロードする際にローテーションされる自動バックアップで、最大3世代を保持する
bak1が最も新しく、bak3が最も古いvault.datが破損している場合は、bak1→bak2→bak3の順に復号を試行するとよい
イベント退避ファイル(vault.replaced.*.dat)
データ置き換え(クラウド連携開始時の既存データ検出や競合解決で、どちらか一方の版を採用する操作)が実行された際に、採用されなかった側のvaultコンテナがそのまま保存されたファイル。
命名規則:
vault.replaced.{yyyyMMdd'T'HHmmss'Z'}.dat
例: vault.replaced.20260715T093012Z.dat
- タイムスタンプは退避時点のUTC時刻
- 同一秒内に複数の退避が発生した場合は
-N(N=1,2,…)のサフィックスが付く (例:vault.replaced.20260715T093012Z-1.dat。サフィックス付きの方が新しい)
保持ルール:
- 最新3件のみ保持され、新規作成で3件を超えると最古の1件だけが削除される
- 世代バックアップ(bak1〜3)のローテーションとは完全に独立しており、時間経過や通常の同期では削除されない
- アプリはローカル・
vault.dat・全バックアップ世代の復号に失敗した場合の最終復旧手段として、イベント退避ファイルを新しい順に復号試行する
vault.dat 構造
vault.dat はUTF-8エンコードされたJSONファイルで、復号に必要な公開メタデータと暗号化本文をすべて内包する。別のメタデータファイルは不要。
{
"formatVersion": 3,
"vaultId": "<16バイトIDのbase64url (padなし22文字)>",
"vaultVersion": 42,
"dekGeneration": 1,
"commitId": "<16バイトIDのbase64url (padなし22文字)>",
"parentCommitIds": ["<16バイトIDのbase64url (0〜2個)>"],
"passwordProfileId": "sf-nfc-v1",
"kdfProfileId": "sf-kdf-argon2id-1",
"updatedAt": "2026-07-19T12:34:56Z",
"cipher": {
"algorithm": "AES-256-GCM",
"nonceBase64": "<Vault本文暗号化時の12バイトnonce (Base64)>"
},
"kdf": {
"algorithm": "Argon2id",
"saltBase64": "<16バイトのsalt (Base64)>",
"memoryKiB": 65536,
"iterations": 3,
"parallelism": 1
},
"keyEnvelope": {
"algorithm": "AES-GCM",
"wrappedDekBase64": "<KEKで暗号化されたDEK + 16バイトGCM認証タグ (Base64)>",
"nonceBase64": "<DEKラップ時の12バイトnonce (Base64)>"
},
"ciphertextBase64": "<暗号化されたVault本文(認証タグを含まない) (Base64)>",
"authTagBase64": "<Vault本文の16バイトGCM認証タグ (Base64)>"
}
| フィールド | 説明 |
|---|---|
formatVersion | 保存フォーマットのバージョン(現行: 3)。本文AADに含まれる |
vaultId | Vault識別子(16バイト、base64url padなし)。本文AADに含まれる |
vaultVersion | Vaultデータのバージョン番号。本文AADに含まれる |
dekGeneration | DEK世代番号。本文AADに含まれる |
commitId | この保存(commit)のID(16バイト、base64url padなし)。本文AADに含まれる |
parentCommitIds | 編集元commitのID(0〜2個)。本文AADに含まれる(raw bytes昇順に整列) |
passwordProfileId | パスワード前処理プロファイル(現行: sf-nfc-v1) |
kdfProfileId | KDFプロファイル(現行: sf-kdf-argon2id-1) |
updatedAt | Vault最終更新日時(ISO 8601)。表示専用 |
cipher.nonceBase64 | Vault本文のAES-256-GCM復号に使用するnonce |
kdf.* | マスターパスワードからKEKを導出するArgon2idパラメータ |
keyEnvelope.wrappedDekBase64 | KEKでラップされたDEK。末尾16バイトがGCM認証タグ(ciphertext ‖ tag の連結) |
keyEnvelope.nonceBase64 | DEKアンラップに使用するnonce |
ciphertextBase64 | Vault本文の暗号文(認証タグは含まない) |
authTagBase64 | Vault本文の認証タグ(復号時は暗号文の末尾に連結する) |
復号手順
Step 0: vault.dat のパース
vault.dat をUTF-8テキストとして読み込み、JSONとしてパースする。
Step 1: パスワード前処理と KEK 導出
passwordProfileId が sf-nfc-v1 の場合、マスターパスワードに以下の前処理を適用してから UTF-8 バイト列にする(ASCII のみのパスワードでは結果は変わらない):
- 非ASCIIのUnicodeスペース区切り文字(カテゴリ Zs)を ASCII スペース U+0020 へ写像する
- Unicode NFC 正規化を適用する
未知の passwordProfileId の場合は復号を中止する(別の前処理を試さない)。
前処理済みパスワードと kdf セクションのパラメータで KEK を導出する。
KEK = Argon2id(
password = 前処理済みマスターパスワード (UTF-8バイト列),
salt = Base64Decode(kdf.saltBase64),
hashLength = 32,
memorySizeKB = kdf.memoryKiB,
iterations = kdf.iterations,
parallelism = kdf.parallelism
)
Step 2: DEKアンラップ
KEK を使用して keyEnvelope から DEK を復元する。AAD は使用しない。
wrappedDEK = Base64Decode(keyEnvelope.wrappedDekBase64)
nonce = Base64Decode(keyEnvelope.nonceBase64)
# wrappedDEK は ciphertext + 16バイトのGCM認証タグ
ciphertext = wrappedDEK[0 : len(wrappedDEK) - 16]
tag = wrappedDEK[len(wrappedDEK) - 16 :]
DEK = AES-256-GCM-Decrypt(
key = KEK,
nonce = nonce,
ciphertext = ciphertext,
tag = tag
)
Step 3: 本文 AAD の構築
v3 では、コンテナの識別メタデータが AES-256-GCM の AAD(追加認証データ)として本文暗号化に束縛されている。以下のバイト列を構築する(すべてビッグエンディアン)。
lp(s) = uint32(len(bytes(s))) ‖ bytes(s) # 長さ接頭辞付きASCII文字列
id(s) = base64urlDecode(s) # 16バイト(padなし22文字)
AAD = lp("SecretFort/v3/vault-body")
‖ uint32(formatVersion)
‖ id(vaultId)
‖ uint64(vaultVersion)
‖ uint64(dekGeneration)
‖ id(commitId)
‖ uint8(len(parentCommitIds))
‖ id(parent) を raw bytes 昇順で連結
‖ lp("AES-256-GCM")
Step 4: Vault本文の復号
DEK と Step 3 の AAD を使用して ciphertextBase64 + authTagBase64 を復号する。
ciphertext = Base64Decode(ciphertextBase64)
tag = Base64Decode(authTagBase64)
nonce = Base64Decode(cipher.nonceBase64)
vaultJSON = AES-256-GCM-Decrypt(
key = DEK,
nonce = nonce,
ciphertext = ciphertext,
tag = tag,
aad = AAD # Step 3 で構築したバイト列
)
Step 5: JSONパース
復号された vaultJSON はUTF-8エンコードされたJSONで、以下の構造を持つ。
{
"tabs": [...],
"items": [...],
"settings": {...},
"vaultVersion": 42,
"updatedAt": "..."
}
items[].values に各アイテムのフィールド値(パスワード等)が平文で格納されている。
Python参照実装
依存パッケージ: pip install argon2-cffi cryptography
#!/usr/bin/env python3
"""Secret Fort vault decryption script (format version 3)."""
import json
import struct
import sys
import unicodedata
from base64 import b64decode, urlsafe_b64decode
from getpass import getpass
from argon2.low_level import hash_secret_raw, Type
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
def preprocess_password(password: str, profile_id: str) -> bytes:
# sf-nfc-v1: map non-ASCII space separators (Zs) to U+0020, then NFC.
if profile_id != "sf-nfc-v1":
raise ValueError(f"unknown passwordProfileId: {profile_id}")
mapped = "".join(
" " if (unicodedata.category(ch) == "Zs" and ch != " ") else ch
for ch in password
)
return unicodedata.normalize("NFC", mapped).encode("utf-8")
def _id(s: str) -> bytes:
raw = urlsafe_b64decode(s + "==")
if len(raw) != 16:
raise ValueError("id must be 16 bytes")
return raw
def _lp(s: str) -> bytes:
b = s.encode("ascii")
return struct.pack(">I", len(b)) + b
def build_body_aad(container: dict) -> bytes:
parents = sorted(_id(p) for p in container["parentCommitIds"])
aad = _lp("SecretFort/v3/vault-body")
aad += struct.pack(">I", container["formatVersion"])
aad += _id(container["vaultId"])
aad += struct.pack(">Q", container["vaultVersion"])
aad += struct.pack(">Q", container["dekGeneration"])
aad += _id(container["commitId"])
aad += struct.pack(">B", len(parents))
for p in parents:
aad += p
aad += _lp("AES-256-GCM")
return aad
def decrypt_vault(dat_path: str, password: str) -> dict:
# Step 0: Parse vault.dat (single JSON container)
with open(dat_path, "rb") as f:
container = json.loads(f.read().decode("utf-8"))
if container["formatVersion"] != 3:
raise ValueError(f"unsupported formatVersion: {container['formatVersion']}")
# Step 1: Preprocess password (sf-nfc-v1) and derive KEK
kdf = container["kdf"]
salt = b64decode(kdf["saltBase64"])
kek = hash_secret_raw(
secret=preprocess_password(password, container["passwordProfileId"]),
salt=salt,
time_cost=kdf["iterations"],
memory_cost=kdf["memoryKiB"],
parallelism=kdf["parallelism"],
hash_len=32,
type=Type.ID,
)
# Step 2: Unwrap DEK (wrappedDek = ciphertext + 16-byte GCM tag, no AAD)
envelope = container["keyEnvelope"]
wrapped_dek = b64decode(envelope["wrappedDekBase64"])
wrap_nonce = b64decode(envelope["nonceBase64"])
dek = AESGCM(kek).decrypt(wrap_nonce, wrapped_dek, None)
# Step 3+4: Decrypt vault body with the v3 body AAD
ciphertext = b64decode(container["ciphertextBase64"])
tag = b64decode(container["authTagBase64"])
vault_nonce = b64decode(container["cipher"]["nonceBase64"])
aad = build_body_aad(container)
vault_json = AESGCM(dek).decrypt(vault_nonce, ciphertext + tag, aad)
# Step 5: Parse decrypted JSON
return json.loads(vault_json)
if __name__ == "__main__":
if len(sys.argv) < 2:
print(f"Usage: {sys.argv[0]} <vault.dat>")
sys.exit(1)
password = getpass("Master Password: ")
vault = decrypt_vault(sys.argv[1], password)
print(json.dumps(vault, indent=2, ensure_ascii=False))
バージョン管理
- 現在のバージョン: 3
vault.dat内のformatVersionフィールドでバージョンを識別する- v2 は廃止済み(2026-07-19)。v2 形式はアプリ未配布の開発期間中にのみ存在し、実データは存在しないため、現行アプリ・本書とも v2 の復号手順を提供しない
- 仕様変更時は新バージョン番号を付与する。配布開始後は旧バージョンの復号手順を維持し、既存データの復号不能を防止する