Skip to main content

Overview

AES-GCM decryption reverses the encryption process while verifying the authentication tag. This ensures that:
  1. Ciphertext hasn’t been tampered with (integrity)
  2. Correct key and nonce were used (authentication)
  3. AAD matches encryption (if used)
Decryption fails completely if authentication fails - no partial plaintext is returned.

Decryption Operation

Basic Decryption

import * as AesGcm from '@tevm/voltaire/crypto/aesgcm';

// Decrypt data (using same key and nonce from encryption)
try {
  const decrypted = await AesGcm.decrypt(ciphertext, key, nonce);
  const message = new TextDecoder().decode(decrypted);
  console.log('Decrypted:', message);
} catch (error) {
  console.error('Decryption failed:', error);
  // Could be: wrong key, wrong nonce, tampered data, or corrupted ciphertext
}

How It Works

AES-GCM decryption involves three main steps:
  1. Separate Components
    • Extract authentication tag (last 16 bytes)
    • Extract ciphertext (remaining bytes)
  2. Verify Authentication Tag
    • Recompute tag using ciphertext, AAD, nonce, and key
    • Compare computed tag with provided tag (constant-time)
    • Fail immediately if tags don’t match
  3. Decrypt Ciphertext (only if authentication passes)
    • Generate keystream using counter mode
    • XOR keystream with ciphertext to produce plaintext
    • Return plaintext
Critical: Authentication is verified BEFORE decryption to prevent timing attacks and ensure tampered data is never returned.

Parameters

Ciphertext (Required)

The encrypted data including the 16-byte authentication tag:
// Minimum length: 16 bytes (just the tag, for empty plaintext)
const ciphertext = new Uint8Array([
  /* encrypted data */,
  /* 16-byte tag */
]);

// Decrypt
const plaintext = await AesGcm.decrypt(ciphertext, key, nonce);
Format:
┌─────────────────┬──────────────────┐
│   Ciphertext    │  Auth Tag (16B)  │
└─────────────────┴──────────────────┘
Error if too short:
const tooShort = new Uint8Array(15); // Less than 16 bytes

try {
  await AesGcm.decrypt(tooShort, key, nonce);
} catch (error) {
  console.error(error); // "Ciphertext too short to contain authentication tag"
}

Key (Required)

Must be the same key used for encryption:
const key = await AesGcm.generateKey(256);

// Encrypt
const ciphertext = await AesGcm.encrypt(plaintext, key, nonce);

// Decrypt with SAME key
const decrypted = await AesGcm.decrypt(ciphertext, key, nonce);

// WRONG: Different key
const wrongKey = await AesGcm.generateKey(256);
await AesGcm.decrypt(ciphertext, wrongKey, nonce); // Throws DecryptionError

Nonce (Required)

Must be the same nonce used for encryption:
// Encrypt
const nonce = AesGcm.generateNonce();
const ciphertext = await AesGcm.encrypt(plaintext, key, nonce);

// Store nonce with ciphertext
const stored = new Uint8Array(nonce.length + ciphertext.length);
stored.set(nonce, 0);
stored.set(ciphertext, nonce.length);

// Later: Extract nonce and decrypt
const extractedNonce = stored.slice(0, AesGcm.NONCE_SIZE);
const extractedCiphertext = stored.slice(AesGcm.NONCE_SIZE);
const decrypted = await AesGcm.decrypt(extractedCiphertext, key, extractedNonce);
CRITICAL: Nonce must be exactly 12 bytes (96 bits)
const wrongNonce = Bytes16(); // Wrong size!

try {
  await AesGcm.decrypt(ciphertext, key, wrongNonce);
} catch (error) {
  console.error(error); // "Nonce must be 12 bytes, got 16"
}

Additional Authenticated Data (Optional)

If AAD was used during encryption, the exact same AAD must be provided for decryption:
// Encrypt with AAD
const aad = new TextEncoder().encode('metadata');
const ciphertext = await AesGcm.encrypt(plaintext, key, nonce, aad);

// Decrypt with SAME AAD
const decrypted = await AesGcm.decrypt(ciphertext, key, nonce, aad);

// WRONG: Different AAD
const wrongAAD = new TextEncoder().encode('different');
await AesGcm.decrypt(ciphertext, key, nonce, wrongAAD); // Throws DecryptionError

// WRONG: Missing AAD
await AesGcm.decrypt(ciphertext, key, nonce); // Throws DecryptionError
AAD is part of authentication - any change (including omission) causes decryption to fail.

Error Handling

Authentication Failures

Decryption throws DecryptionError if authentication fails:
try {
  const decrypted = await AesGcm.decrypt(ciphertext, key, nonce);
  // Success - data is authentic
} catch (error) {
  if (error.name === 'DecryptionError') {
    console.error('Authentication failed:', error.message);
    // Possible causes:
    // - Wrong key
    // - Wrong nonce
    // - Wrong AAD
    // - Tampered ciphertext
    // - Corrupted data
  }
}

Common Failure Scenarios

1. Wrong key:
const key1 = await AesGcm.generateKey(256);
const key2 = await AesGcm.generateKey(256);

const ciphertext = await AesGcm.encrypt(plaintext, key1, nonce);

await AesGcm.decrypt(ciphertext, key2, nonce); // Throws DecryptionError
2. Wrong nonce:
const nonce1 = AesGcm.generateNonce();
const nonce2 = AesGcm.generateNonce();

const ciphertext = await AesGcm.encrypt(plaintext, key, nonce1);

await AesGcm.decrypt(ciphertext, key, nonce2); // Throws DecryptionError
3. Tampered ciphertext:
const ciphertext = await AesGcm.encrypt(plaintext, key, nonce);

// Modify ciphertext
const tampered = new Uint8Array(ciphertext);
tampered[0] ^= 1; // Flip one bit

await AesGcm.decrypt(tampered, key, nonce); // Throws DecryptionError
4. Tampered authentication tag:
const ciphertext = await AesGcm.encrypt(plaintext, key, nonce);

// Modify tag (last 16 bytes)
const tampered = new Uint8Array(ciphertext);
tampered[ciphertext.length - 1] ^= 1; // Flip one bit in tag

await AesGcm.decrypt(tampered, key, nonce); // Throws DecryptionError
5. Wrong AAD:
const aad1 = new TextEncoder().encode('metadata1');
const aad2 = new TextEncoder().encode('metadata2');

const ciphertext = await AesGcm.encrypt(plaintext, key, nonce, aad1);

await AesGcm.decrypt(ciphertext, key, nonce, aad2); // Throws DecryptionError

Error Messages

// Ciphertext too short
"Ciphertext too short to contain authentication tag"

// Wrong nonce length
"Nonce must be 12 bytes, got {length}"

// Authentication failed
"Decryption failed (invalid key, nonce, or corrupted data): ..."

Security Properties

Constant-Time Verification

Tag verification is performed in constant time to prevent timing attacks:
// WebCrypto API implements constant-time comparison
// Attackers cannot learn anything from execution time

const tampered1 = new Uint8Array(ciphertext);
tampered1[0] ^= 1; // First byte of ciphertext

const tampered2 = new Uint8Array(ciphertext);
tampered2[ciphertext.length - 1] ^= 1; // Last byte of tag

// Both fail in approximately the same time
await AesGcm.decrypt(tampered1, key, nonce); // Throws
await AesGcm.decrypt(tampered2, key, nonce); // Throws

All-or-Nothing Decryption

If authentication fails, no plaintext is returned - not even partial data:
const plaintext = new TextEncoder().encode('Secret message with sensitive data');
const ciphertext = await AesGcm.encrypt(plaintext, key, nonce);

// Tamper with one byte
const tampered = new Uint8Array(ciphertext);
tampered[5] ^= 1;

try {
  const decrypted = await AesGcm.decrypt(tampered, key, nonce);
  // Never reached
} catch (error) {
  // No partial plaintext available
  // Attacker learns nothing about the message
}
This prevents padding oracle attacks and ensures data integrity.

Advanced Usage

Batch Decryption

Decrypt multiple messages in parallel:
async function decryptBatch(encrypted, key) {
  const decrypted = await Promise.all(
    encrypted.map(async ({ nonce, ciphertext }) => {
      try {
        const n = new Uint8Array(nonce);
        const ct = new Uint8Array(ciphertext);
        const plaintext = await AesGcm.decrypt(ct, key, n);

        return {
          success: true,
          plaintext: new TextDecoder().decode(plaintext)
        };
      } catch (error) {
        return {
          success: false,
          error: error.message
        };
      }
    })
  );

  return decrypted;
}

// Usage
const results = await decryptBatch(encryptedMessages, key);

results.forEach((result, i) => {
  if (result.success) {
    console.log(`Message ${i}:`, result.plaintext);
  } else {
    console.error(`Message ${i} failed:`, result.error);
  }
});

Extract and Decrypt

Parse stored format and decrypt:
// Stored format: [12-byte nonce][ciphertext][16-byte tag]
async function extractAndDecrypt(stored, key) {
  // Validate minimum length
  if (stored.length < AesGcm.NONCE_SIZE + AesGcm.TAG_SIZE) {
    throw new Error('Invalid stored data: too short');
  }

  // Extract components
  const nonce = stored.slice(0, AesGcm.NONCE_SIZE);
  const ciphertext = stored.slice(AesGcm.NONCE_SIZE);

  // Decrypt
  return await AesGcm.decrypt(ciphertext, key, nonce);
}

// Usage
const stored = loadFromDatabase();
const plaintext = await extractAndDecrypt(stored, key);

Verify Without Decrypting

Check if decryption would succeed without actually decrypting:
async function verifyAuthenticity(ciphertext, key, nonce, aad) {
  try {
    await AesGcm.decrypt(ciphertext, key, nonce, aad);
    return true; // Authentic
  } catch (error) {
    return false; // Not authentic
  }
}

// Usage
const isValid = await verifyAuthenticity(ciphertext, key, nonce, aad);

if (isValid) {
  console.log('Data is authentic');
} else {
  console.log('Data has been tampered with');
}
Note: This still performs decryption internally. GCM doesn’t support tag verification without decryption.

Performance

Decryption Speed

Similar to encryption (hardware-accelerated):
  • With AES-NI: ~2-5 GB/s
  • Software-only: ~50-200 MB/s

Benchmarks

const key = await AesGcm.generateKey(256);
const plaintext = new Uint8Array(1024 * 1024); // 1 MB
crypto.getRandomValues(plaintext);

// Encrypt once
const nonce = AesGcm.generateNonce();
const ciphertext = await AesGcm.encrypt(plaintext, key, nonce);

// Benchmark decryption
const iterations = 100;
const start = performance.now();

for (let i = 0; i < iterations; i++) {
  await AesGcm.decrypt(ciphertext, key, nonce);
}

const end = performance.now();
const totalMB = (plaintext.length * iterations) / (1024 * 1024);
const seconds = (end - start) / 1000;
const throughput = totalMB / seconds;

console.log(`Decryption throughput: ${throughput.toFixed(2)} MB/s`);

Examples

Wallet Decryption

import * as AesGcm from '@tevm/voltaire/crypto/aesgcm';

async function unlockWallet(encryptedWallet, password) {
  try {
    // Derive key from password
    const salt = new Uint8Array(encryptedWallet.salt);
    const key = await AesGcm.deriveKey(
      password,
      salt,
      encryptedWallet.pbkdf2Iterations,
      256
    );

    // Decrypt private key
    const nonce = new Uint8Array(encryptedWallet.nonce);
    const ciphertext = new Uint8Array(encryptedWallet.ciphertext);
    const privateKey = await AesGcm.decrypt(ciphertext, key, nonce);

    return {
      success: true,
      privateKey
    };
  } catch (error) {
    return {
      success: false,
      error: 'Invalid password or corrupted wallet'
    };
  }
}

// Usage
const result = await unlockWallet(encryptedWallet, userPassword);

if (result.success) {
  console.log('Wallet unlocked');
  // Use result.privateKey
} else {
  console.error(result.error);
}

Database Field Decryption

class EncryptedDatabase {
  constructor(key) {
    this.key = key;
  }

  async decryptField(encrypted) {
    try {
      const nonce = new Uint8Array(encrypted.nonce);
      const ciphertext = new Uint8Array(encrypted.ciphertext);

      const plaintext = await AesGcm.decrypt(ciphertext, this.key, nonce);
      return JSON.parse(new TextDecoder().decode(plaintext));
    } catch (error) {
      console.error('Field decryption failed:', error);
      throw new Error('Data corruption detected');
    }
  }

  async queryDecrypted(query) {
    const rows = await this.db.query(query);

    return await Promise.all(
      rows.map(async (row) => ({
        id: row.id,
        data: await this.decryptField(row.encrypted_data)
      }))
    );
  }
}

// Usage
const db = new EncryptedDatabase(key);
const results = await db.queryDecrypted('SELECT * FROM users');

results.forEach((row) => {
  console.log(`User ${row.id}:`, row.data);
});

Authenticated Message Decryption

async function receiveEncryptedMessage(encrypted, sharedSecret) {
  const key = await AesGcm.importKey(sharedSecret);
  const nonce = new Uint8Array(encrypted.nonce);
  const ciphertext = new Uint8Array(encrypted.ciphertext);
  const aad = new Uint8Array(encrypted.metadata);

  try {
    const plaintext = await AesGcm.decrypt(ciphertext, key, nonce, aad);
    const message = new TextDecoder().decode(plaintext);
    const metadata = JSON.parse(new TextDecoder().decode(aad));

    return {
      success: true,
      message,
      sender: metadata.sender,
      timestamp: metadata.timestamp
    };
  } catch (error) {
    return {
      success: false,
      error: 'Message authentication failed - possible tampering'
    };
  }
}

Common Mistakes

Not Handling Errors

// WRONG: Ignoring errors
const decrypted = await AesGcm.decrypt(ciphertext, key, nonce);
// Could throw and crash

// RIGHT: Handle errors
try {
  const decrypted = await AesGcm.decrypt(ciphertext, key, nonce);
  // Use decrypted data
} catch (error) {
  console.error('Decryption failed:', error);
  // Handle error appropriately
}

Using Wrong Parameters

// WRONG: Using different key
const key1 = await AesGcm.generateKey(256);
const key2 = await AesGcm.generateKey(256);
const ct = await AesGcm.encrypt(pt, key1, nonce);
await AesGcm.decrypt(ct, key2, nonce); // Fails

// WRONG: Using wrong nonce
const nonce1 = AesGcm.generateNonce();
const nonce2 = AesGcm.generateNonce();
const ct = await AesGcm.encrypt(pt, key, nonce1);
await AesGcm.decrypt(ct, key, nonce2); // Fails

// RIGHT: Use same key and nonce
const ct = await AesGcm.encrypt(pt, key, nonce);
const decrypted = await AesGcm.decrypt(ct, key, nonce); // Success

Partial Decryption Assumptions

// WRONG: Assuming partial data on failure
try {
  const decrypted = await AesGcm.decrypt(tamperedCiphertext, key, nonce);
} catch (error) {
  // 'decrypted' is undefined - no partial data available
  // Cannot extract any information from failed decryption
}

References