Skip to main content

deny-sh: Python SDK

Deniable encryption for Python. Part of the Encrypt pillar of deny.sh, the deniability infrastructure. Apache 2.0, zero copyleft, free for any use.

Same algorithm as the TypeScript reference implementation, the Rust SDK, and the Go SDK. Ciphertext is byte-for-byte compatible across all four languages.

Install

pip install deny-sh

Quick Start

from deny_sh import encrypt, decrypt, generate_deniable_control

# Encrypt a secret message
ciphertext, control = encrypt(b"seed phrase: abandon ability ...", "pw1", "pw2")

# Decrypt with the real control data
message = decrypt(ciphertext, "pw1", "pw2", control)
# b"seed phrase: abandon ability ..."

# Generate deniable control data; same ciphertext decrypts to a decoy
fake_control = generate_deniable_control(ciphertext, "pw1", "pw2", b"nothing here")
decoy = decrypt(ciphertext, "pw1", "pw2", fake_control)
# b"nothing here"

Multiple decoys from one ciphertext

There is no per-ciphertext cap on the number of decoys. Derive a fresh control file for each cover story:

stories = [
    b"meeting moved to wednesday",
    b"taxi receipts october 2026",
    b"vegetable risotto recipe",
]

for story in stories:
    control = generate_deniable_control(ciphertext, "pw1", "pw2", story)
    recovered = decrypt(ciphertext, "pw1", "pw2", control)
    assert recovered == story

The practical upper bound on plaintext length per decoy is the inner-payload envelope (ciphertext length minus the 48-byte header, minus the 4-byte length prefix). Each decoy can be shorter, never longer.

How It Works

  1. Dual-password key derivation: SHA-256(pw1) || SHA-256(pw2) → Argon2id → AES-256 key
  2. XOR deniability layer: plaintext is XOR'd with control data before encryption
  3. AES-256-CTR encryption: intentionally unauthenticated (an AEAD tag would defeat the deniability property)
  4. Length prefix inside encrypted zone: no metadata leaks the real message size

The control data is what makes it deniable. Different control data + same ciphertext + same passwords = different plaintext. Both the real and fake control data look like random bytes; there's no way to tell which is "real".

API

encrypt(plaintext, password1, password2, control_data=None)

Encrypt plaintext with dual passwords and optional control data.

  • plaintext (bytes): data to encrypt
  • password1 (str): first password
  • password2 (str): second password
  • control_data (bytes | None): control file data; auto-generated if None

Returns (ciphertext: bytes, control_data: bytes).

decrypt(ciphertext, password1, password2, control_data)

Decrypt ciphertext with dual passwords and control data.

Returns bytes: the decrypted plaintext.

generate_deniable_control(ciphertext, password1, password2, desired_plaintext)

Generate new control data that makes existing ciphertext decrypt to a different message.

Returns bytes: new control data.

generate_control_data(size)

Generate cryptographically secure random control data.

Returns bytes.

derive_key(password1, password2, salt)

Derive the AES-256 key from two passwords and a salt. Exposed for cross-implementation testing.

Returns bytes (32 bytes).

Algorithm Compatibility

The ciphertext format is identical across all deny-sh SDKs:

salt (32 bytes) | iv (16 bytes) | AES-256-CTR(key, payload XOR control_data)

Where payload = length_prefix (4 bytes LE) + plaintext.

KDF parameters: Argon2id v0x13 (t=3, m=65536 KiB, p=1, hash_len=32).

A file encrypted with the Python SDK can be decrypted with the TypeScript, Go, or Rust SDK, and vice versa. Full wire format and KAT vectors: deny.sh/sdks.

Threat model

deny.sh defends against passive ciphertext leak: an adversary gets the encrypted artefact (lost laptop, cloud breach, prompt-injected agent) and tries to read it. The construction guarantees that whatever the adversary decrypts is indistinguishable from any other decryption.

It is not designed to resist an adaptive adversary who can compel you to perform multiple decryptions, demand additional passwords iteratively, or run forensic side-channel analysis on the host hardware. Full threat model: deny.sh/threat-model. Cryptographic argument: deny.sh/whitepaper §5.

The primitive is intentionally unauthenticated. Wrong passwords return garbage, not an error. If you need decryption to fail loudly on wrong inputs, add a caller-side integrity check (magic bytes + SHA-256 fingerprint) on the plaintext.

Dependencies

  • pycryptodome: AES-256-CTR encryption
  • argon2-cffi: Argon2id KDF
  • hashlib (stdlib): SHA-256 password hashing before KDF input
  • os (stdlib): urandom for secure random bytes

License

Apache License 2.0. See LICENSE. Free for commercial and proprietary use. See deny.sh/licensing.

Reporting vulnerabilities

Found a bug in the crypto or the SDK? Email security@deny.sh (PGP fingerprint and disclosure policy at deny.sh/disclosure). Please give us a reasonable window before public disclosure.

Managed-vault HTTP redirects

The client in deny_sh.client follows redirects only within the same origin (scheme, hostname and effective port). Hostname case and explicit default ports are equivalent. Cross-origin redirects, including HTTPS to HTTP downgrades, are refused before a follow-up request is made and surface as VaultError(code="vault_http_error", status=<redirect status>). Point DENY_API_URL or base_url directly at the intended API origin when moving hosts. Same-origin redirects retain authentication and urllib's redirect-loop limit. The client uses a private opener to enforce this policy, so global urllib.request.install_opener() customizations no longer apply; default environment-proxy handling is retained.

Metadata

Release files for deny-sh 2.3.2

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for deny-sh 2.3.2
File Size Uploaded
deny_sh-2.3.2.tar.gz 42.6 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for deny-sh 2.3.2
File Interpreter ABI Platform
deny_sh-2.3.2-py3-none-any.whl Python 3 none any Details

Total release size: 76.8 kB

Release files / deny_sh-2.3.2.tar.gz

Download URL deny_sh-2.3.2.tar.gz
Size 42.6 kB
Tags Source
SHA-256 checksum
How to use checksums
ddc5ba13cfd5a39d18356a12efe1d3882fc5080970d5162095040d8bb12ae9ff
BLAKE2b-256 checksum
How to use checksums
905e93e3664b5bee292331be34d317b13ba4e0997273d3d889dce0547be1e14b
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/6.2.0 CPython/3.12.3

Release files / deny_sh-2.3.2-py3-none-any.whl

Download URL deny_sh-2.3.2-py3-none-any.whl
Size 34.1 kB
Tags Python 3
SHA-256 checksum
How to use checksums
651d8272b9fa2fb1b63e89c8dab2499cb2489d87652989458fa6325dcc86fcdf
BLAKE2b-256 checksum
How to use checksums
271ae299c22e1b8dff5013e68ce34e99e6457eba58d7da97641fb2e9181a6199
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/6.2.0 CPython/3.12.3

Release history Release notifications | RSS feed

This release

2.3.2 This release

2 release files

2.3.1

2 release files

2.3.0

2 release files

2.2.2

2 release files

2.2.0

2 release files

2.1.2

2 release files

2.1.1

2 release files

2.1.0

2 release files

2.0.6

2 release files

2.0.3

2 release files

2.0.0

2 release files

1.1.0

2 release files

1.0.0

2 release files

0.0.1

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page