Skip to content

Secrets API

features.secrets

Envelope-encrypt JSON secrets — backward-compat shim.

Implementation moved to shared/master_key.py so shared.saga no longer imports features (broke the features <-> shared import cycles).

encrypt_json

encrypt_json(data)

Encrypt JSON data. Returns nonce(24) || ciphertext.

Source code in shared/master_key.py
def encrypt_json(data: dict[str, Any] | list[Any]) -> bytes:
    """Encrypt JSON data. Returns nonce(24) || ciphertext."""
    return _encrypt_json(data, _get_master_key())

decrypt_json

decrypt_json(blob)

Decrypt blob back to JSON.

Source code in shared/master_key.py
def decrypt_json(blob: bytes) -> Any:
    """Decrypt blob back to JSON."""
    return _decrypt_json(blob, _get_master_key())

is_encrypted_blob

is_encrypted_blob(path)

Return True when the file is a readable encrypted envelope.

Reads the file and attempts decryption with this process's master key. The previous version sniffed the first byte; because the envelope begins with a random nonce, one byte in 256 looks like JSON ({, [, space, newline) and real ciphertext was reported as plain JSON in 4 cases out of 256 (measured 69/4096 = 1.68%).

Source code in shared/master_key.py
def is_encrypted_blob(path: Path) -> bool:
    """Return True when the file is a readable encrypted envelope.

    Reads the file and attempts decryption with this process's master key. The
    previous version sniffed the first byte; because the envelope begins with a
    random nonce, one byte in 256 looks like JSON (`{`, `[`, space, newline) and
    real ciphertext was reported as plain JSON in 4 cases out of 256 (measured
    69/4096 = 1.68%).
    """
    if not path.exists():
        return False
    # Path is verified to be within app data dir by caller, safe.
    return bool(_is_encrypted_blob(path.read_bytes(), _get_master_key()))

_load_master_key

_load_master_key()

Load or derive master key from keyring, .env, config, or environment.

If no key is found, auto-generates one and saves to .env for dev convenience.

Source code in shared/master_key.py
def _load_master_key() -> bytes:
    """Load or derive master key from keyring, .env, config, or environment.

    If no key is found, auto-generates one and saves to .env for dev convenience.
    """
    if not _HAS_NACL:
        raise ImportError("pynacl is required for encryption. Install with: pip install pynacl")

    # Try OS keychain first (recommended for production)
    with contextlib.suppress(Exception):
        import keyring

        stored = keyring.get_password(_KEYRING_SERVICE, _KEYRING_USERNAME)
        if stored:
            return bytes.fromhex(stored)

    # Try .env file
    _load_dotenv()

    # Try config
    with contextlib.suppress(Exception):
        from config import config

        cfg_key = config.get("crypto", "master_key_hex", default="")
        if cfg_key:
            return bytes.fromhex(cfg_key)

    # Try environment variable with argon2id KDF
    env_seed = os.environ.get(_ENV_VAR)
    if env_seed:
        # The KDF salt is a fixed constant by design: it makes keys portable
        # (same seed + same data file decrypt on any machine). This is safe
        # for high-entropy seeds — the auto-generated ones are 256-bit. It
        # does NOT protect weak user-chosen seeds from cross-install
        # precomputation, so warn loudly about those instead of pretending.
        if len(env_seed) < 32:
            logger.warning(
                "MCP_MASTER_KEY is shorter than 32 chars — a low-entropy seed is "
                "vulnerable to precomputed attacks (argon2id hardens but cannot "
                "salvage weak seeds). Prefer the auto-generated key or keyring."
            )
        res_kdf = argon2id.kdf(
            size=_MASTER_KEY_LEN,
            password=env_seed.encode("utf-8"),
            salt=_KDF_SALT,
            opslimit=argon2id.OPSLIMIT_MODERATE,
            memlimit=argon2id.MEMLIMIT_MODERATE,
        )
        return bytes(res_kdf)

    # Auto-generate key for dev convenience
    import secrets as _secrets

    auto_key = _secrets.token_hex(32)
    logger.warning(
        "No master key found. Auto-generating key and saving to %s. For production, use keyring or set MCP_MASTER_KEY explicitly.", _dotenv_path()
    )
    _save_dotenv(_ENV_VAR, auto_key)
    res_auto = argon2id.kdf(
        size=_MASTER_KEY_LEN,
        password=auto_key.encode("utf-8"),
        salt=_KDF_SALT,
        opslimit=argon2id.OPSLIMIT_MODERATE,
        memlimit=argon2id.MEMLIMIT_MODERATE,
    )
    return bytes(res_auto)

_get_master_key

_get_master_key()

Get cached master key.

Source code in shared/master_key.py
def _get_master_key() -> bytes:
    """Get cached master key."""
    key = _master_cache.get("k")
    if key is None:
        key = _load_master_key()
        _master_cache["k"] = key
    return key