Knowledge Base
Encryption Details

Encryption Details

A deep dive into how vaultctl encrypts data, derives keys, and structures ciphertext.

Blob Wire Format

Every encrypted value in vaultctl follows a compact binary format before being Base64-encoded:

┌──────────┬──────────┬───────────┬────────────┬─────┐
│ version  │  alg_id  │   nonce   │ ciphertext │ tag │
│  (1 B)   │  (1 B)   │ (var len) │  (var len) │(var)│
└──────────┴──────────┴───────────┴────────────┴─────┘
ComponentSizeDescription
version1 byteWire format version (currently 0x01)
alg_id1 byteAlgorithm identifier (see table below)
nonceAlgorithm-dependentRandom nonce / IV
ciphertextVariableEncrypted payload
tagAlgorithm-dependentAuthentication tag

See the Blob Wire Format page for byte-level layout diagrams.

Algorithm Parameters

Algorithmalg_idNonceTagUse Case
AES-256-GCM0x0112 bytes16 bytesItem data, item names, folder names, private keys
RSA-OAEP-SHA2560x02nonenoneVault key wrapping for shared vaults
AES-256-KW0x03none8 bytesVault key wrapping for personal vaults

RSA-OAEP ciphertext is inherently authenticated by the OAEP padding scheme — no separate nonce or tag is needed. AES Key Wrap (RFC 3394) includes an 8-byte integrity check value appended to the ciphertext.

Name Padding

Item names and folder names are padded to 32-byte boundaries using PKCS#7 before encryption. This prevents the server from inferring name lengths from ciphertext size.

Original:    "GitHub"           → 6 bytes
Padded:      "GitHub" + 26×0x1A → 32 bytes
Encrypted:   AES-256-GCM(padded_name)

Names longer than 32 bytes are padded to the next multiple of 32 (64, 96, etc.).

Key Derivation

Client-Side (Argon2id)

The master password is processed entirely in the browser:

Master Password + Salt (16 bytes random)

        ▼ Argon2id
        │   iterations:  3
        │   memory:      64 MB
        │   parallelism: 4
        │   output:      32 bytes

    Master Key (256-bit)

        ├── HKDF-SHA256(info="auth")  → Auth Hash (32 bytes)
        │       Sent to server for authentication

        └── HKDF-SHA256(info="enc")   → Stretched Key (32 bytes)
                Encrypts RSA + Ed25519 private keys locally

Server-Side (Argon2id Re-Hash)

The server never stores the auth hash directly. It applies a second round of Argon2id before storage:

ParameterValue
Iterations2
Memory19 MB
Parallelism1

This means a database leak does not reveal auth hashes — an attacker must crack two layers of Argon2id.

AAD Binding

AES-256-GCM encryptions use Additional Authenticated Data (AAD) to bind ciphertext to its context and prevent cut-and-paste attacks:

Encrypted FieldAAD Value
Item datavaultId || itemId
Item namevaultId || itemId || "name"
Folder namevaultId || folderId || "folder"
Private key (RSA)userId || "private_key"
Private key (Ed25519)userId || "identity_private_key"

If an attacker copies ciphertext from one item to another, decryption fails because the AAD does not match.

Nonce Generation

All nonces are generated using crypto.getRandomValues() in the browser (backed by the OS CSPRNG). Each encryption operation uses a fresh random nonce.

⚠️

Nonce reuse with AES-GCM is catastrophic — it breaks both confidentiality and authenticity. vaultctl generates a new 96-bit random nonce for every encryption, giving a collision probability below 2^-32 after 2^32 encryptions per key.