PUBLIC DECRYPTION SPECIFICATION
Secret Fort External Decryption Specification v3
This public specification lets you recover your data without the Secret Fort app. You need one encrypted container file from your own cloud storage and your master password. Publishing this format does not expose your data: the key is derived from the password and is never stored.
What you need
- One encrypted container file:
- OneDrive / Dropbox:
vault.dat - Google Drive: a
c-*.datfile inside thecommits/folder
- OneDrive / Dropbox:
- Your master password
Each container is a complete, self-contained JSON document and can be decrypted independently.
Files in your cloud
Secret Fort stores data in its cloud folder: Secret Fort at the drive root on OneDrive and Google Drive, and SecretFort inside the Dropbox app folder.
| File | Purpose |
|---|---|
vault.dat | Current encrypted vault on OneDrive and Dropbox |
vault.bak1.dat–vault.bak3.dat | Rotating backups on OneDrive and Dropbox |
vault.replaced.*.dat | Snapshot retained when one version replaces another |
commits/c-*.dat | Immutable Google Drive commit history; one complete container per save |
sync_meta.json | Non-secret synchronization metadata; not needed for decryption |
For Google Drive, do not assume that the filename or provider timestamp proves which commit is current. Start with recently updated candidates, decrypt them, then compare their updatedAt, contents, vaultVersion, and parentCommitIds. You must choose the version you want to recover.
Container structure
{
"formatVersion": 3,
"vaultId": "<16-byte base64url ID without padding>",
"vaultVersion": 42,
"dekGeneration": 1,
"commitId": "<16-byte base64url ID without padding>",
"parentCommitIds": ["<zero to two 16-byte base64url IDs>"],
"passwordProfileId": "sf-nfc-v1",
"kdfProfileId": "sf-kdf-argon2id-1",
"updatedAt": "2026-07-19T12:34:56Z",
"cipher": { "algorithm": "AES-256-GCM", "nonceBase64": "<12-byte nonce>" },
"kdf": {
"algorithm": "Argon2id",
"saltBase64": "<16-byte salt>",
"memoryKiB": 65536,
"iterations": 3,
"parallelism": 1
},
"keyEnvelope": {
"algorithm": "AES-GCM",
"wrappedDekBase64": "<wrapped DEK plus 16-byte GCM tag>",
"nonceBase64": "<12-byte nonce>"
},
"ciphertextBase64": "<encrypted vault body without tag>",
"authTagBase64": "<16-byte vault-body GCM tag>"
}
Decryption procedure
Step 0: Parse the container
Read the file as UTF-8 and parse it as JSON. Version 3 is the current and only published format.
Step 1: Preprocess the password and derive the KEK
For sf-nfc-v1, map every non-ASCII Unicode space separator (category Zs) to U+0020, then apply Unicode NFC normalization and encode the result as UTF-8. Stop if the profile is unknown.
Derive a 32-byte KEK with Argon2id using the salt, memory, iteration, and parallelism values in kdf.
Step 2: Unwrap the DEK
Base64-decode keyEnvelope.wrappedDekBase64. Its last 16 bytes are the AES-GCM authentication tag. Decrypt it with the KEK and keyEnvelope.nonceBase64; no AAD is used for the envelope.
Step 3: Build the vault-body AAD
All integers use big-endian byte order.
lp(s) = uint32(len(bytes(s))) || bytes(s)
id(s) = base64urlDecode(s)
AAD = lp("SecretFort/v3/vault-body")
|| uint32(formatVersion)
|| id(vaultId)
|| uint64(vaultVersion)
|| uint64(dekGeneration)
|| id(commitId)
|| uint8(len(parentCommitIds))
|| each parent ID sorted by raw bytes
|| lp("AES-256-GCM")
Step 4: Decrypt the vault body
Decrypt ciphertextBase64 plus authTagBase64 with the DEK, cipher.nonceBase64, and the AAD from Step 3.
Step 5: Parse the plaintext JSON
The decrypted UTF-8 JSON contains tabs, items, settings, vaultVersion, and updatedAt. Sensitive field values are stored in items[].values.
Python reference implementation
Install dependencies with 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))
Versioning
- Current format version: 3
- Version 2 was retired on July 19, 2026. It existed only during development before distribution, so no user data uses it.
- After distribution begins, decryption instructions for older published versions will be retained.