Container format (SPV1)
SecretPNG Vault (SPV1) is the versioned, documented file format behind every encrypted file and vault. It uses the extension .svault. This page describes it at the byte level so it can be reviewed.
SPV1 / .svault — on-disk layout
┌── preamble + header (public, but tamper-evident) ──────────────────┐
│ magic 8 bytes 0x89 'S' 'P' 'V' 0x0D 0x0A 0x1A 0x0A │
│ version 2 bytes u16 big-endian = 1 │
│ hdrLen 4 bytes u32 big-endian = byte length of header JSON │
│ header hdrLen UTF-8 JSON (see fields below) │
├── one or more length-prefixed AES-256-GCM frames ──────────────────┤
│ frame 0 4 bytes u32 big-endian = ciphertext length │
│ N bytes AES-256-GCM( chunk 0 ) │
│ frame 1 4 bytes u32 length │
│ N bytes AES-256-GCM( chunk 1 ) │
│ ... │
│ frame k 4 bytes u32 length │
│ N bytes AES-256-GCM( final chunk, final flag = 1 ) │
└────────────────────────────────────────────────────────────────────┘
AAD for every frame = SHA-256( magic || version || hdrLen || headerJSON )
Encrypted payload (the plaintext sealed inside the frames):
manifestLen u32 big-endian
manifest JSON: [ { path, size, type?, mtime? }, ... ]
file bytes concatenated in manifest order
Per-chunk nonce (12 bytes) = 7-byte random prefix
|| u32 big-endian counter
|| 1-byte final flagMagic bytes
Every container starts with the eight bytes 0x89 'S' 'P' 'V' 0x0D 0x0A 0x1A 0x0A. This is the same trick the PNG format uses: the high bit in the first byte, the CR-LF pair, the DOS end-of-file byte, and the trailing LF together act as a transfer-corruption canary. If a file is mangled by a text-mode transfer or a newline-rewriting tool, the magic no longer matches and we fail closed instead of trying to decrypt garbage.
Versioned header
After the magic comes a big-endian u16 version (currently 1) and a big-endian u32 header length, followed by a UTF-8 JSON header. The header is public — you can read it without the password — but it is not trusted blindly: it is tamper-evident (see below). Its fields are:
cipher— "AES-256-GCM".kdf— { id: "PBKDF2-SHA-256", iterations, salt } — salt is 16 bytes, base64. Records which key-derivation function and parameters were used.pw— { nonce (12 bytes), wrapped (48 bytes = 32-byte file key + 16-byte GCM tag) } — the file key encrypted under the password-derived key.rk (optional)— { salt (16 bytes), nonce, wrapped } — a second copy of the file key wrapped under a recovery key (KEK derived with HKDF-SHA-256). Present only if you opt into a recovery key.stream— { noncePrefix (7 bytes), chunkSize } — chunkSize defaults to 1 MiB.compression— "none" or "deflate-raw" (applied before encryption, if enabled).label (optional)— A short public label you may add. Plaintext, but still covered by the header authentication.
Header-bound authentication
Before any frame is written, we compute SHA-256(magic || version || headerLength || headerJSON) and use that digest as the AES-GCM additional authenticated data (AAD) for every frame. Because the AAD covers the exact header bytes, any change to the header — KDF parameters, salts, chunk size, compression flag, wrapped keys, or label — makes decryption fail authentication on the first chunk. The header is public but cannot be silently altered.
Frames and per-chunk nonces
The payload is sealed as one or more length-prefixed frames using the STREAM construction (the same shape used by age and Google Tink) with AES-256-GCM. Each frame is a big-endian u32length followed by that many ciphertext bytes. The default chunk size is 1 MiB. Each chunk's 12-byte nonce is built from a random 7-byte prefix, a 32-bit big-endian counter, and a 1-byte final flag. Because the counter and final flag are part of the authenticated nonce, reordering, duplicating, dropping, or truncating chunks all cause authentication to fail. An empty input still produces one final frame, so truncation before any content is also detectable.
Encrypted manifest — filenames are never visible
The plaintext that gets encrypted is a big-endian u32manifest length, then a JSON manifest, then every file's bytes concatenated in manifest order. The manifest holds each entry's relative path, size, optional MIME type, and optional modification time. All of it is inside the encrypted payload, so filenames, folder structure, and file count are never visible without the password. Paths are re-sanitized on extraction to refuse traversal tricks. No per-file checksums are needed: every byte of the payload is already covered by the container's authenticated encryption.
Versioning and migration
The header carries the format version and the KDF id and parameters, so a file records exactly how it was made. That is what lets the format evolve: a future version can add a KDF (for example, a memory-hard option) or change defaults while older files stay readable, because the reader dispatches on the version and KDF id it finds. We only claim compatibility with SecretPNG itself — no interoperability with other tools is claimed or implied.
Corruption, failure, and cleanup
Parsing is bounded and fail-closed: the header length is capped, the chunk size must fall within an accepted range, and frame lengths are validated before any allocation. If the magic is wrong, the version is unknown, the header is malformed, or any chunk fails authentication, decryption stops and reports a generic failure — we do not distinguish "wrong password" from "tampered file," since that distinction can leak information. When a decryption or extraction fails partway through, partial output is discarded rather than left on disk as a half-written, unauthenticated file.
File-size limits in the browser
Large files are streamed. In Chromium-based browsers we can stream output straight to disk using the File System Access API, so vault size is bounded mainly by free disk space. Where that API is unavailable (currently Safari and Firefox), output falls back to an in-memory Blob, which is bounded by available memory — realistically around 1–2 GB before the tab is at risk. The tools warn before you approach these limits. See supported browsers for the specifics.
Testing
The implementation ships with known-answer test vectors (fixed keys and nonces producing a byte-exact container) plus tamper, truncation, reordering, and duplication tests that assert decryption fails when it should. See the cryptography page for the algorithm choices and the threat model for what the format does and does not protect.
Last reviewed: 2026-07-14.