The .sv Format
Selvum Vault Format — v1.0 · Stable
shared/src/commonMain/kotlin/com/selvum/shared/VaultSerializer.kt
Overview
The .sv format is the binary container used by Selvum to store encrypted vaults on disk. It is designed to be:
- Self-contained — all decryption metadata (salt, nonce, flags) is embedded in the header.
- Tamper-evident — AES-256-GCM's authentication tag detects any modification to the file.
- Version-aware — a 2-byte version field allows future format evolution with a user-friendly error if an older app encounters a newer vault.
- KDF-agnostic — a flags byte identifies which key derivation function was used (Argon2id on Android, PBKDF2-SHA512 on iOS), so both platforms can open each other's vaults.
A .sv file is not a ZIP, not a database, and not a proprietary binary blob with undocumented structure. The entire format fits in this document.
File Structure
| Offset | Size | Field | Description |
|---|---|---|---|
0 | 4 | Magic | ASCII "SELV" — 0x53 0x45 0x4C 0x56 |
4 | 2 | Version | 0x00 0x01 (big-endian uint16) |
6 | 1 | Flags | KDF selector (see Flags section) |
7 | 16 | Salt | Random KDF salt (CSPRNG) |
23 | 12 | Nonce | AES-GCM nonce (CSPRNG, unique per save operation) |
35 | 4 | PayloadLength | Total bytes that follow (ciphertext + 16-byte GCM tag), big-endian uint32 |
39 | N | Payload | AES-256-GCM ciphertext with 16-byte auth tag appended at the end |
Fields
Magic [0..3]
53 45 4C 56
The ASCII string SELV. Any file not starting with this sequence is rejected immediately with a "corrupted file" error. This allows quick identification without relying on file extension.
Version [4..5]
00 01
Big-endian unsigned 16-bit integer. Currently only 0x00 0x01 is accepted. If the app encounters a higher major version it shows a user-friendly prompt: "This vault was created with a newer version of Selvum. Please update the app."
Flags [6]
Single byte indicating which key derivation function was used to produce the master key.
| Value | KDF | Platform |
|---|---|---|
0x00 | Argon2id | Android |
0x01 | PBKDF2-SHA512 | iOS |
0x02+ | Reserved | — |
The receiving platform reads this byte before decryption and dispatches to the correct KDF implementation. Both platforms can open vaults created by the other.
Salt [7..22]
16 bytes of cryptographically random data generated at vault creation time using a platform CSPRNG (java.security.SecureRandom on Android, SecRandomCopyBytes on iOS). The salt is stored in the header so the master key can be re-derived at each unlock without storing the key itself.
The same salt is reused across unlock operations for the same vault. A new salt is only generated when a new vault is created or when a vault is re-keyed.
Nonce [23..34]
12 bytes (96 bits) of cryptographically random data, generated fresh by the CSPRNG on every save operation. AES-GCM requires each (key, nonce) pair to be used at most once; regenerating the nonce on every save guarantees this property.
PayloadLength [35..38]
Unsigned 32-bit integer (big-endian) equal to the byte length of the Payload field. This value includes the 16-byte GCM authentication tag that is appended at the end of the ciphertext. Maximum accepted value is 10,485,760 (10 MB); values outside this range are rejected.
Payload [39..39+N]
N bytes of AES-256-GCM ciphertext. The last 16 bytes of this field are the GCM authentication tag. The tag is not stored as a separate field — it is appended directly to the ciphertext, which is the standard output format of the AES/GCM/NoPadding cipher.
The plaintext is the UTF-8 JSON encoding of the Vault object (see Plaintext Structure section).
Encryption Algorithm
Cipher: AES-256-GCM
Key size: 256 bits (32 bytes)
Nonce size: 96 bits (12 bytes)
Auth tag: 128 bits (16 bytes), appended to ciphertext
AAD: none
Authenticated encryption guarantees both confidentiality and integrity. Any modification to the header, ciphertext, or tag causes decryption to fail with a wrong-passphrase error before any plaintext is returned.
Key Derivation
Android — Argon2id Flags = 0x00
The master key is derived using Argon2id as specified in RFC 9106.
Algorithm: Argon2id
Memory: 65,536 KiB (64 MB)
Iterations: 3
Parallelism: 1
Output: 32 bytes (256-bit master key)
Input: UTF-8 encoding of the 12 BIP-39 seed words joined by single spaces
Salt: 16-byte CSPRNG value from header field Salt
Argon2id is memory-hard and resistant to GPU/ASIC brute-force attacks. Implementation: BouncyCastle org.bouncycastle:bcprov-jdk18on.
iOS — PBKDF2-SHA512 Flags = 0x01
iOS does not expose Argon2id in its system cryptography libraries. iOS vaults use PBKDF2 with a high iteration count to approximate equivalent resistance.
Algorithm: PBKDF2-HMAC-SHA512
Iterations: 600,000
Output: 32 bytes (256-bit master key)
Input: UTF-8 encoding of the 12 BIP-39 seed words joined by single spaces
Salt: 16-byte CSPRNG value from header field Salt
Implementation: CommonCrypto CCKeyDerivationPBKDF with kCCPRFHmacAlgSHA512.
Recovery Kit — PBKDF2-SHA512
RESCATE_OFFLINE.html uses the same PBKDF2-SHA512 parameters as iOS (600,000 iterations via the WebCrypto API), making it compatible with both Android vaults and iOS vaults through a single JS implementation. When opening an Android vault (Flags = 0x00), the recovery tool automatically runs PBKDF2 in its place; this is an intentional trade-off for browser compatibility.
Plaintext Structure
After successful decryption and authentication, the payload is parsed as UTF-8 JSON conforming to the Vault schema:
{
"entries": [
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"title": "My Bank",
"category": "BANKS",
"fields": [
{ "key": "bankEntity", "value": "Example Bank", "type": "TEXT" },
{ "key": "bankPassword", "value": "s3cr3t", "type": "SECRET_TEXT" }
],
"notes": null,
"createdAt": 1717200000000,
"updatedAt": 1717200000000,
"expiresAt": null,
"strength": 42
}
]
}
- id — UUID v4
- category — one of: CRYPTO, BANKS, SOCIAL, SUBSCRIPTIONS, CARDS, PERSONAL, OTHER
- fields — ordered list of DynamicField with key, value, and type
- createdAt / updatedAt — Unix epoch in milliseconds
- strength — password strength score 0–100 derived from the first SECRET_TEXT or SECRET_MONOSPACE field
Byte-Level Example
The following shows a minimal valid .sv file for illustration. All values are hexadecimal.
Offset Bytes Description
────── ────────────────────────────────────── ─────────────────────────────
00 53 45 4C 56 Magic "SELV"
04 00 01 Version 1.0
06 00 Flags: Argon2id
07 a1 b2 c3 d4 e5 f6 07 08 Salt (16 bytes, random)
09 0a 0b 0c 0d 0e 0f 10
23 11 22 33 44 55 66 77 88 Nonce (12 bytes, random)
99 aa bb cc
35 00 00 01 2C PayloadLength = 300 bytes
39 [300 bytes of AES-256-GCM ciphertext] Last 16 bytes are the GCM tag
Total file size for this example: 39 + 300 = 339 bytes.
Validation Rules
When reading a .sv file, the following checks are performed in order:
- File size ≥ 55 bytes (header + minimum 16-byte tag).
- Bytes [0..3] equal 53 45 4C 56.
- Bytes [4..5] equal 00 01 (unsupported version → friendly upgrade message).
- PayloadLength ≥ 16 and ≤ 10,485,760.
- PayloadLength ≤ file_size − 39.
- GCM decryption succeeds (tag mismatch → wrong passphrase error).
- Decrypted bytes are valid UTF-8 JSON.
A file failing any check except step 3 is reported as corrupted.
Compatibility
| Platform | Writes (flags) | Reads (flags) |
|---|---|---|
| Android | 0x00 (Argon2id) | 0x00, 0x01 |
| iOS | 0x01 (PBKDF2) | 0x00, 0x01 |
| Recovery Kit | — (read only) | 0x00, 0x01 |
Cross-platform compatibility is verified by an automated test suite that encrypts a known vault on Android and decrypts it on iOS and vice versa before each release.