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

iOS explicitly reads this byte before decrypting and rejects the file outright if it isn't 0x01, since iOS has no native Argon2id. Android never reads this byte at all: its importer instead 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 Recovery Kit's boveda.sv. 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 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 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 boveda.sv — the same re-encryption step that makes an exported Android vault importable on iOS.

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
    }
  ]
}

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)0x01 only (unconditional 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 boveda.sv (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)