PUBLIC DECRYPTION SPECIFICATION

Secret Fort 外部復号仕様書 v3

この文書について(公開用の前置き)

本書は Secret Fort の 公開版・外部復号仕様書(v3) です。Secret Fort は、暗号化と鍵管理をすべて利用者の端末上で完結させ、クラウドには暗号文のみを保存するゼロ知識設計を採用しています。本書は、アプリが使えなくなった場合でも利用者が自力でデータを復号できる手段 を、アプリの実装に依存せず公開することを目的としています。

復号に必要なのは、利用者自身がクラウドストレージに保有する暗号化コンテナファイル(vault.dat 等)と、利用者だけが知るマスターパスワードのみです。本書はこの2つを用いた復号手順・データ形式・参照実装を記載します。本書の公開によって秘密が漏れることはありません(鍵はマスターパスワードから導出され、どこにも保存されないため、パスワードを知らない第三者は本書があっても復号できません)。

技術的内容は原本仕様書(docs/decryption_spec.md・v3)と同一です。

概要

本仕様書は、Secret Fortアプリが廃止された場合にユーザーが自力でデータを復号するための手順を記述する。

復号に必要なもの:

技術的な正典仕様(AADバイト仕様・テストベクター)は docs/基本設計書/08_外部復号仕様.md を参照。本書は復旧作業者向けの自己完結した手順書である。

ファイル構成

クラウドストレージの Secret Fort フォルダ(OneDrive はドライブ直下、Google Drive はマイドライブ直下。Dropbox はアプリフォルダ配下の SecretFort)に以下のファイルが保存される。

ファイル名用途
vault.dat暗号化Vault本文 + 復号用メタデータ(単一JSONコンテナ)。OneDrive / Dropbox のみ
sync_meta.json同期判定用メタデータ(平文・秘密情報なし。復号には不要)
vault.bak1.datvault.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/ フォルダへ新しい不変ファイルが追加される(過去の保存履歴はすべて残る)。復旧時の注意:

世代バックアップ(vault.bak1〜3.dat)

イベント退避ファイル(vault.replaced.*.dat)

データ置き換え(クラウド連携開始時の既存データ検出や競合解決で、どちらか一方の版を採用する操作)が実行された際に、採用されなかった側のvaultコンテナがそのまま保存されたファイル。

命名規則:

vault.replaced.{yyyyMMdd'T'HHmmss'Z'}.dat
例: vault.replaced.20260715T093012Z.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に含まれる
vaultIdVault識別子(16バイト、base64url padなし)。本文AADに含まれる
vaultVersionVaultデータのバージョン番号。本文AADに含まれる
dekGenerationDEK世代番号。本文AADに含まれる
commitIdこの保存(commit)のID(16バイト、base64url padなし)。本文AADに含まれる
parentCommitIds編集元commitのID(0〜2個)。本文AADに含まれる(raw bytes昇順に整列)
passwordProfileIdパスワード前処理プロファイル(現行: sf-nfc-v1)
kdfProfileIdKDFプロファイル(現行: sf-kdf-argon2id-1)
updatedAtVault最終更新日時(ISO 8601)。表示専用
cipher.nonceBase64Vault本文のAES-256-GCM復号に使用するnonce
kdf.*マスターパスワードからKEKを導出するArgon2idパラメータ
keyEnvelope.wrappedDekBase64KEKでラップされたDEK。末尾16バイトがGCM認証タグ(ciphertext ‖ tag の連結)
keyEnvelope.nonceBase64DEKアンラップに使用するnonce
ciphertextBase64Vault本文の暗号文(認証タグは含まない)
authTagBase64Vault本文の認証タグ(復号時は暗号文の末尾に連結する)

復号手順

Step 0: vault.dat のパース

vault.dat をUTF-8テキストとして読み込み、JSONとしてパースする。

Step 1: パスワード前処理と KEK 導出

passwordProfileIdsf-nfc-v1 の場合、マスターパスワードに以下の前処理を適用してから UTF-8 バイト列にする(ASCII のみのパスワードでは結果は変わらない):

  1. 非ASCIIのUnicodeスペース区切り文字(カテゴリ Zs)を ASCII スペース U+0020 へ写像する
  2. 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))

バージョン管理

← Secret Fort紹介ページへ戻る