Open Specification

The .sv Format

Selvum Vault Format — v1.0 · Stable

Implemented in 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:

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

Header size 39 bytes
Minimum file size 55 bytes
Max payload 10 MB
Offset Size Field Description
04MagicASCII "SELV"0x53 0x45 0x4C 0x56
42Version0x00 0x01 (big-endian uint16)
61FlagsKDF selector (see Flags section)
716SaltRandom KDF salt (CSPRNG)
2312NonceAES-GCM nonce (CSPRNG, unique per save operation)
354PayloadLengthTotal bytes that follow (ciphertext + 16-byte GCM tag), big-endian uint32
39NPayloadAES-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.

ValueKDFPlatform
0x00Argon2idAndroid
0x01PBKDF2-SHA512iOS
0x02+Reserved

The parser rejects any value other than 0x00 or 0x01 on both platforms. iOS goes further: it explicitly reads this byte before decrypting and rejects the file outright if it isn't 0x01, since iOS has no native Argon2id. Android's importer doesn't use the byte to pick which KDF to try, though — it tries Argon2id first and falls back to PBKDF2 if that fails, so in practice it accepts either flag value, just by trial, not by checking Flags. Android's native vault is written with Flags = 0x00 (Argon2id). Direct cross-platform opening of a native .sv file isn't supported. What is supported is exporting from Android: the Export flow always re-encrypts the vault with PBKDF2 (Flags = 0x01) into the kit's vault file — named vault.sv, boveda.sv or volta.sv depending on the app's language when the kit was generated. Importing that file on iOS with the 12-word seed has been verified on a real device. Re-importing the same kit on Android also works, via that same try/fallback path — confirmed in source, though not separately device-tested.

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 the confidentiality and integrity of the payload. GCM is used with no additional authenticated data, so the tag covers the ciphertext alone — which does not leave the header unprotected: the salt and the nonce feed key derivation and the cipher, so altering either causes a tag mismatch, and magic, version and payload length are rejected by the parser before decryption is attempted. Either way the file is refused before a single byte of plaintext is returned. The one exception is the Flags byte, neither authenticated nor validated: changing it cannot reveal plaintext or weaken the key, but it can make iOS report a valid vault as created on the other platform.

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 always derives its key with PBKDF2-SHA512 — 600,000 iterations via the WebCrypto API — deliberately and unconditionally, regardless of which KDF the original vault used. This isn't a per-file fallback triggered by reading the Flags byte; it's the Recovery Kit's only KDF, since WebCrypto has no Argon2id support and the kit must run in any browser with no exceptions. The Export flow enforces this by always re-encrypting the vault with PBKDF2 (Flags = 0x01) before writing the kit's vault file — the same re-encryption step that makes an exported Android vault importable on iOS. Before that re-encryption, the app derives a key from the phrase you typed and checks it against the vault's actual master key. A phrase with a valid BIP-39 checksum that isn't yours is rejected, instead of producing a kit you could never open.

Plaintext Structure

After successful decryption and authentication, the payload is parsed as UTF-8 JSON conforming to the Vault schema:

{
  "metadata": {
    "version": 1,
    "createdAt": 1717200000000,
    "updatedAt": 1717200000000
  },
  "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
    }
  ]
}

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:

  1. File size ≥ 55 bytes (header + minimum 16-byte tag).
  2. Bytes [0..3] equal 53 45 4C 56.
  3. Bytes [4..5] equal 00 01 (unsupported version → friendly upgrade message).
  4. PayloadLength ≥ 16 and ≤ 10,485,760.
  5. PayloadLength ≤ file_size − 39.
  6. GCM decryption succeeds (tag mismatch → wrong passphrase error).
  7. Decrypted bytes are valid UTF-8 JSON.

A file failing any check except step 3 is reported as corrupted.

Compatibility

PlatformWrites (flags)Reads (flags)
Android0x00 (Argon2id)0x00, 0x01
iOS0x01 (PBKDF2)0x01 only
Recovery KitAlways 0x01 (re-encrypted on export)Never read — always PBKDF2

That interoperability runs through the Recovery Kit, not through swapping native .sv files directly: exporting from Android always re-encrypts the vault with PBKDF2 into the kit's vault file (Flags = 0x01). iOS checks that byte explicitly and accepts the file because it reads 0x01; Android never checks the byte, but reaches the same result by trying Argon2id first and falling back to PBKDF2 when it fails. The Android-export → iOS-import round trip has been manually verified on real devices — there is no automated cross-platform test suite in CI.

Source References

shared/src/commonMain/kotlin/com/selvum/shared/VaultSerializer.kt — serializer/deserializer
shared/src/androidMain/kotlin/com/selvum/shared/AesGcm.android.kt — AES-256-GCM Android
shared/src/iosMain/kotlin/com/selvum/shared/AesGcm.ios.kt — AES-256-GCM iOS (CryptoKit bridge)
shared/src/androidMain/kotlin/com/selvum/shared/Argon2id.android.kt — Argon2id Android
iosApp/iosApp/CryptoHelper.swift — PBKDF2-SHA512 iOS
docs/landing/RESCATE_OFFLINE.html — offline recovery tool (PBKDF2-SHA512 via WebCrypto)