Skip to content

[BUG] Vault AES-GCM Nonce Collision — Catastrophic Authentication Key Recovery #2057

Description

@ionfwsrijan

Description

Each encrypt() call in VaultCrypto generates a random 12-byte nonce via os.urandom(12). AES-GCM is catastrophically broken when a nonce is reused under the same key — the GHASH authentication key can be recovered, enabling universal ciphertext forgeries. With random 12-byte (96-bit) nonces, the birthday bound is ~2^48 encryptions. More critically, there is zero nonce tracking, collision detection, or counter-based generation. If the vault encrypts credentials across multiple deployments or long-lived instances, the risk of collision grows silently. A single collision silently corrupts the entire security model.

Affected Files

  • backend/secuscan/vault.py (line 51 — nonce generation)

Expected Behaviour

Nonce generation must guarantee uniqueness under a given key, e.g., via a monotonic counter or XChaCha20-Poly1305 (24-byte random nonce, no birthday problem at practical volumes). A collision should be detectable and preventable.

Actual Behaviour

Random nonces with no collision tracking or counter mechanism. A single collision silently corrupts the security model — the attacker can recover the GHASH authentication key and forge arbitrary ciphertexts, bypassing all encryption protections for stored credentials.

Proposed Fix

# vault.py — Replace random nonces with a monotonic counter + random padding

import struct
import threading

class VaultCrypto:
    _NONCE_LEN = 12

    def __init__(self, key: bytes):
        # ... existing key setup ...
        self._counter = 0
        self._counter_lock = threading.Lock()

    def _next_nonce(self) -> bytes:
        """Generate a unique nonce: 8-byte big-endian counter || 4-byte random."""
        with self._counter_lock:
            self._counter += 1
            ctr_part = struct.pack(">Q", self._counter)
        random_part = os.urandom(4)
        return ctr_part + random_part

    def encrypt(self, plaintext: str) -> str:
        nonce = self._next_nonce()
        ciphertext = self._aesgcm.encrypt(
            nonce, plaintext.encode("utf-8"), None
        )
        blob = nonce + ciphertext
        return base64.urlsafe_b64encode(blob).decode("ascii")

Alternatively, migrate to XChaCha20-Poly1305 which has a 24-byte nonce, making random nonce collisions practically impossible:

# vault.py — Alternative: use XChaCha20-Poly1305

from cryptography.hazmat.primitives.ciphers.aead import XChaCha20Poly1305

class VaultCrypto:
    def __init__(self, key: bytes):
        self._xchacha = XChaCha20Poly1305(key)

    def encrypt(self, plaintext: str) -> str:
        nonce = os.urandom(24)  # 192-bit nonce — no birthday problem
        ciphertext = self._xchacha.encrypt(
            nonce, plaintext.encode("utf-8"), None
        )
        blob = nonce + ciphertext
        return base64.urlsafe_b64encode(blob).decode("ascii")

    def decrypt(self, token: str) -> str:
        blob = base64.urlsafe_b64decode(token)
        nonce = blob[:24]
        ciphertext = blob[24:]
        plaintext = self._xchacha.decrypt(nonce, ciphertext, None)
        return plaintext.decode("utf-8")

Metadata

Metadata

Assignees

No one assigned

    Labels

    priority:mediumImportant issue with normal urgencytype:bugBug fix work category bonus label

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions