← Back to Security

Manual Decryption Guide

This guide explains how to manually decrypt your vault data using standard cryptographic tools. We provide this for transparency and to ensure you always have access to your data — even if our service is unavailable.

Data Sovereignty: Your data belongs to you. With your passphrase and the exported encrypted bundle, you can always recover your data independently using open-source tools.

Prerequisites

You will need:

  • Node.js (version 16 or later)
  • Your master passphrase (the one you use to access your vault)
  • Your exported encrypted bundle (download from the vault page)

Important: Only the master passphrase can decrypt exported bundles. Read-only and write codes cannot decrypt exported bundles because the export contains only the main passphrase's wrapped DEK, not the wrapped DEKs for shared codes.

Encryption Overview

Your vault uses a two-layer encryption scheme with optional digital signatures for write access:

  1. Key Derivation: Your passphrase is converted to a Key Encryption Key (KEK) using Argon2id, a memory-hard password hashing algorithm designed to resist brute-force attacks.
  2. Key Wrapping: A random Data Encryption Key (DEK) is encrypted ("wrapped") with the KEK using XChaCha20-Poly1305.
  3. Data Encryption: Your vault data is encrypted with the DEK using XChaCha20-Poly1305, an authenticated encryption algorithm.
  4. Write Authorization (optional): An Ed25519 signing keypair is stored inside the vault. The private key is wrapped with your KEK. Write operations must be signed with this key to be accepted by the server.

Technical Details

Encryption Algorithm: XChaCha20-Poly1305
Key Derivation: Argon2id
Key Size: 256 bits (32 bytes)
Nonce Size: 192 bits (24 bytes)
Salt Size: 128 bits (16 bytes)
Argon2id Parameters:
opslimit: 3 (MODERATE)
memlimit: 268,435,456 bytes (256 MB)
Digital Signatures: Ed25519 (for write access authorization)
Signing Key Size: 256 bits (32 bytes private, 32 bytes public)

Algorithm Compatibility: Vaults created after February 2026 use XChaCha20-Poly1305. Older vaults may use XSalsa20-Poly1305. The scripts below automatically detect and handle both algorithms — you don't need to know which one your vault uses.

Decryption Steps

Here is a complete Node.js script that decrypts your vault:

// decrypt-vault.js
// Usage: node decrypt-vault.js <encrypted-bundle.json> <passphrase>

const sodium = require('libsodium-wrappers-sumo');
const fs = require('fs');

// Decrypt using XChaCha20-Poly1305 (current algorithm)
function decryptXChaCha20(ciphertext, nonce, key) {
  return sodium.crypto_aead_xchacha20poly1305_ietf_decrypt(
    null, // nsec (unused)
    ciphertext,
    null, // additional data
    nonce,
    key
  );
}

// Decrypt using XSalsa20-Poly1305 (legacy algorithm, pre-2026)
function decryptXSalsa20(ciphertext, nonce, key) {
  return sodium.crypto_secretbox_open_easy(ciphertext, nonce, key);
}

// Try decryption with fallback for legacy vaults
function decryptWithFallback(ciphertext, nonce, key, algorithm) {
  // If algorithm is specified, use it directly
  if (algorithm === 'xchacha20-poly1305') {
    return decryptXChaCha20(ciphertext, nonce, key);
  }
  if (algorithm === 'xsalsa20-poly1305') {
    return decryptXSalsa20(ciphertext, nonce, key);
  }
  
  // No algorithm specified - try XChaCha20 first, fall back to XSalsa20
  try {
    return decryptXChaCha20(ciphertext, nonce, key);
  } catch (e) {
    return decryptXSalsa20(ciphertext, nonce, key);
  }
}

async function decryptVault(bundlePath, passphrase) {
  // Initialize libsodium
  await sodium.ready;
  
  // Read the encrypted bundle
  const bundle = JSON.parse(fs.readFileSync(bundlePath, 'utf8'));
  const { data, encryption } = bundle;
  
  // Check encryption algorithm (newer exports include this)
  const algorithm = encryption?.algorithm || null;
  console.log('Encryption algorithm:', algorithm || '(not specified, will auto-detect)');
  
  // Decode base64 values to Uint8Array
  // Use ORIGINAL variant (standard +/= with padding) for compatibility
  const B64 = sodium.base64_variants.ORIGINAL;
  const kdfSalt = sodium.from_base64(data.kdfSalt, B64);
  const wrappedKey = sodium.from_base64(data.wrappedKey, B64);
  const wrappedKeyNonce = sodium.from_base64(data.wrappedKeyNonce, B64);
  const ciphertext = sodium.from_base64(data.ciphertext, B64);
  const nonce = sodium.from_base64(data.nonce, B64);
  
  console.log('\nStep 1: Deriving KEK from passphrase...');
  console.log('  (This may take a few seconds due to Argon2id)');
  
  // Step 1: Derive Key Encryption Key (KEK) from passphrase
  const kek = sodium.crypto_pwhash(
    32, // key size
    passphrase,
    kdfSalt,
    encryption.kdf.opslimit,
    encryption.kdf.memlimit,
    sodium.crypto_pwhash_ALG_ARGON2ID13
  );
  
  console.log('Step 2: Unwrapping DEK...');
  
  // Step 2: Unwrap the Data Encryption Key (DEK)
  let dek;
  try {
    dek = decryptWithFallback(wrappedKey, wrappedKeyNonce, kek, algorithm);
  } catch (e) {
    console.error('ERROR: Failed to unwrap DEK.');
    console.error('This usually means the passphrase is incorrect.');
    process.exit(1);
  }
  
  console.log('Step 3: Decrypting vault data...');
  
  // Step 3: Decrypt the vault data
  let plaintext;
  try {
    plaintext = decryptWithFallback(ciphertext, nonce, dek, algorithm);
  } catch (e) {
    console.error('ERROR: Failed to decrypt vault data.');
    process.exit(1);
  }
  
  // Convert to string
  const jsonString = sodium.to_string(plaintext);
  const vaultData = JSON.parse(jsonString);
  
  console.log('\nSuccess! Vault decrypted.');
  console.log('\n--- Decrypted Vault Data ---\n');
  console.log(JSON.stringify(vaultData, null, 2));
  
  // Optionally save to file
  const outputPath = bundlePath.replace('.json', '-decrypted.json');
  fs.writeFileSync(outputPath, JSON.stringify(vaultData, null, 2));
  console.log('\nSaved to:', outputPath);
}

// Get command line arguments
const args = process.argv.slice(2);
if (args.length !== 2) {
  console.log('Usage: node decrypt-vault.js <encrypted-bundle.json> <passphrase>');
  console.log('');
  console.log('Example:');
  console.log('  node decrypt-vault.js vault-encrypted-2024-01-15.json \\');
  console.log('    "sunset-harbor-crystal-wisdom-river-falcon-7284"');
  process.exit(1);
}

decryptVault(args[0], args[1]).catch(console.error);

Running the Script

  1. Create a new directory and save the script as decrypt-vault.js
  2. Install libsodium:
    npm install libsodium-wrappers-sumo
  3. Place your exported encrypted bundle (JSON file) in the same directory
  4. Run the script:
    node decrypt-vault.js vault-encrypted-2024-01-15.json "your-passphrase-here"

Security Note: The decrypted output contains all your vault data in plain text. Handle it carefully — store it securely or delete it after use.

Alternative: Using Python

If you prefer Python, you can use the pynacl library:

# decrypt-vault.py
# pip install pynacl

import json
import sys
import base64
from nacl.pwhash import argon2id
from nacl.secret import SecretBox
from nacl import bindings

def decrypt_xchacha20(ciphertext, nonce, key):
    """Decrypt using XChaCha20-Poly1305 (current algorithm)"""
    return bindings.crypto_aead_xchacha20poly1305_ietf_decrypt(
        ciphertext, None, nonce, key
    )

def decrypt_xsalsa20(ciphertext, nonce, key):
    """Decrypt using XSalsa20-Poly1305 (legacy algorithm)"""
    box = SecretBox(key)
    return box.decrypt(ciphertext, nonce)

def decrypt_with_fallback(ciphertext, nonce, key, algorithm):
    """Try decryption with fallback for legacy vaults"""
    if algorithm == 'xchacha20-poly1305':
        return decrypt_xchacha20(ciphertext, nonce, key)
    if algorithm == 'xsalsa20-poly1305':
        return decrypt_xsalsa20(ciphertext, nonce, key)
    
    # No algorithm specified - try XChaCha20 first, fall back to XSalsa20
    try:
        return decrypt_xchacha20(ciphertext, nonce, key)
    except Exception:
        return decrypt_xsalsa20(ciphertext, nonce, key)

def decrypt_vault(bundle_path, passphrase):
    # Read the encrypted bundle
    with open(bundle_path, 'r') as f:
        bundle = json.load(f)
    
    data = bundle['data']
    encryption = bundle['encryption']
    
    # Check encryption algorithm (newer exports include this)
    algorithm = encryption.get('algorithm')
    print(f'Encryption algorithm: {algorithm or "(not specified, will auto-detect)"}')
    
    # Decode base64 values
    kdf_salt = base64.b64decode(data['kdfSalt'])
    wrapped_key = base64.b64decode(data['wrappedKey'])
    wrapped_key_nonce = base64.b64decode(data['wrappedKeyNonce'])
    ciphertext = base64.b64decode(data['ciphertext'])
    nonce = base64.b64decode(data['nonce'])
    
    print('\nStep 1: Deriving KEK from passphrase...')
    
    # Derive KEK using Argon2id
    kek = argon2id.kdf(
        32,  # key size
        passphrase.encode('utf-8'),
        kdf_salt,
        opslimit=encryption['kdf']['opslimit'],
        memlimit=encryption['kdf']['memlimit']
    )
    
    print('Step 2: Unwrapping DEK...')
    
    # Unwrap DEK
    dek = decrypt_with_fallback(wrapped_key, wrapped_key_nonce, kek, algorithm)
    
    print('Step 3: Decrypting vault data...')
    
    # Decrypt vault data
    plaintext = decrypt_with_fallback(ciphertext, nonce, dek, algorithm)
    
    vault_data = json.loads(plaintext.decode('utf-8'))
    
    print('\nSuccess! Vault decrypted.')
    print(json.dumps(vault_data, indent=2))
    
    # Save to file
    output_path = bundle_path.replace('.json', '-decrypted.json')
    with open(output_path, 'w') as f:
        json.dump(vault_data, f, indent=2)
    print(f'\nSaved to: {output_path}')

if __name__ == '__main__':
    if len(sys.argv) != 3:
        print('Usage: python decrypt-vault.py <bundle.json> <passphrase>')
        sys.exit(1)
    
    decrypt_vault(sys.argv[1], sys.argv[2])

Understanding the Decrypted Data

The decrypted vault contains your data along with some internal fields used by the application. Here's what you'll see:

{ "version": 1, "profiles": [...], // Your actual data (contacts, accounts, etc.) "readOnlyKeys": [...], // Metadata about read-only access codes "writeKeys": [...], // Metadata about write access codes "auditLog": [...], // Activity history "lastUpdated": "...", // Timestamp // Internal cryptographic fields (for write access): "signingPrivateKey": "...", // Wrapped Ed25519 signing key (base64) "signingPrivateKeyNonce": "...", // Nonce for unwrapping (base64) "signingPublicKey": "...", // Public verification key (base64) "signingKeyVersion": 1 // Key version for rotation }

The signing key fields are used to cryptographically authorize write operations. They don't affect your ability to read the data — they're only needed if you want to make changes through our service. For data recovery purposes, you can ignore these fields and focus on the profiles array which contains your actual information.

Note: The signingPrivateKey is itself encrypted (wrapped with your master KEK). Even in the decrypted output, it remains protected — an additional layer of security for write capabilities.

Troubleshooting

"Failed to unwrap DEK"

This error means the passphrase is incorrect. Double-check that you're using the exact passphrase, including hyphens and the number at the end.

Out of memory errors

Argon2id uses 256 MB of memory by design. If your system runs out of memory, try closing other applications or using a machine with more RAM.

Slow decryption

The key derivation step (Argon2id) is intentionally slow — this is a security feature that makes brute-force attacks impractical. It typically takes 1-3 seconds.