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

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.

FilePurpose
vault.datCurrent encrypted vault on OneDrive and Dropbox
vault.bak1.datvault.bak3.datRotating backups on OneDrive and Dropbox
vault.replaced.*.datSnapshot retained when one version replaces another
commits/c-*.datImmutable Google Drive commit history; one complete container per save
sync_meta.jsonNon-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

← Back to Secret Fort