Whitepaper
The cryptographic specification behind deny.sh: XOR-composed control data, AES-256-CTR, and provable deniability.
deny.sh: Deniable Encryption via XOR-Composed Control Data
Version 1.6 - June 2026 deny.sh
Patent Pending: US Provisional Application No. 64/067,717 (filed 17 May 2026)
Abstract
deny.sh is deniability infrastructure for engineering teams that hold other people's secrets. Modern systems concentrate credentials in places that leak: AI agents whose context can be prompt-injected, multi-tenant platforms storing thousands of users' keys, and backups or repositories that escape every perimeter built to contain them. Conventional encryption protects those secrets only until the key is produced; once the bytes and a working key reach an adversary, the recovered plaintext is the real plaintext. deny.sh removes that finality. Its core primitive - XOR composition over AES-256-CTR with Argon2id-derived keys - lets a single ciphertext decrypt to arbitrarily many valid plaintexts, with no cryptographic marker distinguishing the real one from a decoy. Around that primitive it adds the operational layer production systems require: per-tenant cryptographic key isolation, shape- and checksum-correct decoys across 69 credential types, and a hash-chained audit log. This paper specifies the primitive in full, analyses its security properties, defines the threat model, and presents empirical verification results.
1. Introduction
This paper specifies the cryptographic primitive at the core of deny.sh and analyses its security properties. The primitive is the smallest part of the product. The product itself is what we call deniability infrastructure: the operational layer that makes the primitive usable on behalf of others, at multi-tenant scale, with a verifiable trust posture. This section defines both the primitive and the surrounding category claim, in that order, so that the technical sections that follow can be read either as a standalone cryptographic specification or in their wider product context.
1.1. The problem
Software increasingly concentrates other people's secrets in places that are one mistake away from disclosure. An AI agent holds API keys, wallet credentials, and tenant tokens in a context window that a single prompt injection can exfiltrate. A multi-tenant platform stores thousands of customers' secrets behind one application boundary, so one breach is everyone's breach. Backups, exported environment files, and accidentally-published repositories carry live credentials straight through every perimeter built to contain them. In each case the failure mode is identical: ciphertext, and often a key that reads it, end up in an adversary's hands.
Conventional encryption - the protection for data at rest and in transit - does nothing past that point. Once the key is produced, whether by brute force, by coercion, or by a compromised agent that simply hands it over, the plaintext it yields is the real plaintext. The existence of the encrypted blob frequently reveals as much as its contents do: that a secret exists, that it was worth protecting, and that whoever holds the key now holds the secret.
Deniable encryption removes that finality. A single ciphertext decrypts to different plaintexts under different keys or auxiliary data, with no way for an adversary - even one with complete forensic access to the leaked artefacts, even one holding a valid key - to determine which decryption produced the "real" plaintext. What leaks is, by construction, indistinguishable from a decoy. The property was formalised by Canetti et al. (1997); deny.sh's contribution is an implementation that is practical at production scale rather than confined to disk-volume containers (Section 8).
1.2. The primitive
deny.sh takes a different approach: rather than hidden volumes or deniable key exchange, it uses XOR composition with control data to produce multiple valid decryptions from a single ciphertext. This is simpler, more portable (works on arbitrary data, not just disk volumes), and produces deniability at the message level rather than the container level. The construction is given in full in Section 3 and analysed in Section 4.
1.3. The infrastructure claim
The primitive alone is sufficient for an individual encrypting their own data on their own machine. It is not sufficient for the audiences deny.sh is built for, which include AI agent platforms holding tenant secrets at scale, custody products with hot-wallet credentials, and regulated organisations whose audit trail must accommodate deniable at-rest storage. For these audiences, the operational requirements exceed what any library provides: per-tenant key isolation that is cryptographic rather than policy-based; structured audit logging with defined retention; coordinated vulnerability disclosure with a real fingerprint and a real human at the other end; reproducible build verification; and a continuity story that survives planned and unplanned outages of the hosted service.
We therefore present deny.sh as deniability infrastructure, organised around three pillars:
- Encrypt. The cryptographic primitive specified in Sections 2-4 of this paper. Shipped as an Apache 2.0 SDK in TypeScript, Rust, Go, and Python.
- Operate. The hosted runtime: per-tenant isolation, structured 365-day audit log, MCP server, named SLA, and local-mode fallback for continuity. Available as a managed service and as a private deployment under the AGPL-3.0 application layer or a commercial licence.
- Verify. The published trust posture: this paper,
the formal construction write-up at
/security, the plain-language threat model at/threat-model, the coordinated disclosure policy at/disclosure, and the reproducible-build manifest at/.well-known/integrity.json. An independent cryptographic audit is on the roadmap.
The remainder of this paper concerns Pillar 1, the cryptographic primitive, in detail sufficient for independent implementation and review. Operational and trust-posture material is referenced where relevant and lives in full on the product surfaces above.
2. Notation
| Symbol | Definition |
|---|---|
| P | Plaintext (arbitrary bytes) |
| P' | Decoy plaintext |
| K | Encryption key (32 bytes, derived from passwords) |
| S | Salt (32 bytes, random) |
| IV | Initialisation vector (16 bytes, random) |
| C | Control data (random bytes, length at least |P| + 4) |
| C' | Decoy control data |
| E | AES-256-CTR ciphertext |
| L | 4-byte little-endian length prefix |
3. Algorithm Specification
3.1 Key Derivation
Each user-supplied password is first hashed with SHA-256 to produce a fixed-length 32-byte digest. The two digests are concatenated and passed through Argon2id:
pw1_hash = SHA-256(pw1) (32 bytes)
pw2_hash = SHA-256(pw2) (32 bytes)
combined = pw1_hash || pw2_hash (64 bytes, fixed-length)
K = Argon2id(combined, S, t=3, m=64 MiB, p=1, v=0x13, dkLen=32)
The salt S is 32 bytes of cryptographically random data, generated per encryption operation and prepended to the output.
Rationale. Argon2id provides memory-hard key derivation and is OWASP's currently recommended password-based KDF. The parameters (t=3 iterations, m=64 MiB memory, p=1 parallelism, v=0x13) deliberately exceed OWASP's minimum recommendation (t=2, m=19 MiB) to push GPU/ASIC brute-force attacks well above commodity-cracking economics. Memory-hardness makes brute-force scale with RAM cost, not just compute, dramatically raising the per-guess cost for an attacker. Two passwords increase the combined entropy space.
SHA-256 is applied to each password before concatenation so the
Argon2id password input has a fixed 64-byte structure regardless of password
length. This eliminates length-extension ambiguity at the password
boundary: without the pre-hash, pairs such as (abc,
def) and (abcd, ef) would
concatenate to the identical byte string abcdef and derive
the identical key. SHA-256 is a fast cryptographic hash; the pre-hash
adds negligible cost relative to the Argon2id invocation that follows. All
four reference SDKs (TypeScript, Python, Rust, Go) implement this
binding identically and produce byte-compatible ciphertext.
3.2 Encryption
1. Generate random salt S (32 bytes) and IV (16 bytes)
2. Derive key: K = Argon2id(SHA-256(pw1) || SHA-256(pw2), S, t=3, m=64 MiB, p=1, v=0x13)
3. Encode payload: payload = LE32(|P|) || P
4. Generate random control data: C ← random(|payload|)
5. XOR: X = payload ⊕ C
6. Encrypt: E = AES-256-CTR(K, IV, X)
7. Output: ciphertext = S || IV || E
8. Output: controlData = C
The 4-byte little-endian length prefix (LE32) encodes the plaintext length, enabling correct extraction during decryption when the control data may be longer than the plaintext.
Because the length prefix lives inside the XOR-and-encrypt zone, the decryption output never reveals the real plaintext length: a 2-byte decoy and a 64-byte real message can share a single ciphertext, and which one is produced is determined entirely by the control file. This is what makes value-deniability work across differing plaintext lengths.
3.2.1 Length Bucketing (Length Privacy)
The length prefix hides the real length from the decryption output,
but the ciphertext byte-count itself is fixed at encryption
time. Without padding it equals |P| + 4, so an adversary who only
ever observes the ciphertext can read the real plaintext length directly off the
wire. For some payloads this is a meaningful leak (it distinguishes a 12-word
from a 24-word BIP-39 seed phrase; it bounds the size of a protected
message).
To close this channel, the encryptor MAY pad the inner payload with random bytes up to a coarse size band before encryption:
bands = { 64, 256, 1024, 4096, 16384 } bytes (payloads above 16384 round up to the next 16 KiB multiple)
padded_payload = LE32(|P|) || P || random(band - |P| - 4)
The padding sits after the real plaintext inside the XOR-and-encrypt zone, so it is indistinguishable from random and is trimmed losslessly on decryption via the length prefix. With bucketing enabled, the ciphertext size reveals only which band the plaintext falls into, not its exact length. A residual coarse leak remains by design (a message in the 257-1024 band could be anywhere in that range); this is the standard length-privacy trade-off and is documented honestly rather than claimed away.
Bucketing is opt-in at the primitive layer so that the four
reference SDKs remain byte-for-byte wire-compatible by default. The user-facing
surfaces that produce standalone artefacts an adversary might measure - the
protect seed-phrase wizard and the structured-record helper -
enable bucketing by default.
3.3 Decryption
1. Parse: S = ciphertext[0:32], IV = ciphertext[32:48], E = ciphertext[48:]
2. Derive key: K = Argon2id(SHA-256(pw1) || SHA-256(pw2), S, t=3, m=64 MiB, p=1, v=0x13)
3. Decrypt: X = AES-256-CTR-Decrypt(K, IV, E)
4. XOR with control data: payload = X ⊕ C
5. Extract length: len = LE32(payload[0:4])
6. Extract plaintext: P = payload[4 : 4+len]
3.4 Deniable Control Data Generation (Deny Operation)
Given a ciphertext, the original passwords, and a decoy plaintext P':
1. Parse and derive K from ciphertext (same as decryption steps 1-2)
2. Decrypt: X = AES-256-CTR-Decrypt(K, IV, E)
3. Encode decoy payload: payload' = LE32(|P'|) || P'
4. Pad payload' to |X| with random bytes if |payload'| < |X|
5. Compute decoy control data: C' = X ⊕ payload'
6. Output: C'
Critical property: C' is computed directly from the decrypted intermediate value X and the desired decoy payload. No information about the original control data C is required or revealed. Multiple decoy control files can be generated from a single ciphertext, each producing a different plaintext.
3.5 Ciphertext Format
Offset Length Field
0 32 Salt (random)
32 16 IV (random)
48 var AES-256-CTR encrypted data
Total overhead: 48 bytes. Control data is stored separately.
4. Security Analysis
4.1 Confidentiality
Confidentiality rests on AES-256-CTR, which is IND-CPA secure under standard assumptions. The Argon2id KDF provides resistance to brute-force key recovery. The XOR composition with control data does not weaken AES - it is applied before encryption, not after.
4.2 Deniability
Theorem (cryptographic core). Given a ciphertext E and two control files C and C', an adversary - even one holding the passwords - cannot determine from the cryptographic construction alone which control file was generated first (i.e., which corresponds to the "real" plaintext).
Proof sketch. Let X = AES-256-CTR-Decrypt(K, IV, E). For any valid control data C, the payload is P_C = X ⊕ C. For any desired plaintext P*, we can compute C* = X ⊕ (LE32(|P*|) || P* || pad). Since X is fixed for a given ciphertext and key, the relationship between C and P is purely algebraic (XOR). There is no cryptographic binding between a specific C and the "original" encryption operation. The XOR operation is symmetric and reversible; both control files are equally valid derivations from the intermediate value X, so the construction itself carries no marker of temporal order.
Distinguishers outside the cryptographic core. The theorem above is about the primitive. Honest deniability analysis must also account for everything an adversary can measure about the artefacts and their contents. We enumerate every distinguisher we are aware of, and the status of each:
Control-file size. If the real and decoy control files differ in length, the adversary picks the real one by file size with zero cryptanalysis. Closed: all control files for a given ciphertext are generated at exactly the ciphertext-payload length, so they are byte-for-byte equal in size (regression-tested:
realCtrl.length === decoyCtrl.lengthon both the record andprotectpaths).Ciphertext length. The ciphertext byte-count leaks
|P| + 4unless padded. Closed for the artefact-producing surfaces via length bucketing (Section 3.2.1): the size reveals only a coarse band. A residual band-level leak remains by design and is documented as a limitation, not claimed away.Deep content validity. A real secret passes its type's integrity check - a real card satisfies the Luhn checksum, a real 24-word phrase has a valid BIP-39 checksum, a real IBAN satisfies mod-97, a real Bitcoin WIF key has a valid Base58Check checksum. A decoy that matched only the surface shape but failed the checksum would be a distinguisher: the adversary keeps the candidate that validates. Closed for the structured-decoy engine: generated decoys are required to pass the same deep-validity check as a real value of that type (Section 4.4), so validity is no longer a real-vs-decoy signal.
Plaintext plausibility. Whether a decoy reads as a believable secret in context is a human judgment, not a cryptographic property. This is irreducible: a decoy that says "this is a decoy" provides no practical deniability regardless of how perfect the cryptography is. deny.sh's decoy-realism engine raises the floor here (shape- and checksum-correct values) but cannot make a contextually absurd decoy plausible.
Distinguishers (1)-(3) are mechanical and are closed by construction in the surfaces that emit standalone artefacts. Distinguisher (4) is operational and bounded, not eliminated; it is the user's responsibility to choose a plausible decoy, and it is treated as a first-class limitation in Section 4.6.
4.3 Statistical Indistinguishability
Control data C is generated from random(n) during
encryption. Decoy control data C' = X ⊕ payload' where X is AES-CTR
output (pseudorandom) and payload' is structured data. If the adversary
does not know X, C' appears random.
If the adversary DOES know X (i.e., has the passwords), they can derive any control file from any plaintext, but they cannot determine temporal ordering. The statistical properties of C and C' are identical when X is treated as a pseudorandom mask.
Empirical verification (from deny.sh/verify, 22 tests): - Chi-squared uniformity: 99.6% pass rate across 1,000 generated control files - Mean ciphertext entropy: 7.196 bits/byte (theoretical maximum: 8.0) - Kolmogorov-Smirnov statistic: D = 0.1528 (well within random bounds for n=1000) - Serial correlation coefficient: ≈ 0.0 (no detectable sequential correlation) - 500 fuzz rounds with random inputs: 0 failures - 100 distinct decoy control files generated from a single ciphertext: all valid
4.4 Decoy Realism: Shape and Deep Validity
Value-deniability is only as strong as the decoys are believable, and believability has two mechanical layers an adversary can test before any human judgment is involved:
- Shape. The decoy must match the structural pattern of its
type - the right prefix, length, and character set (a Stripe key starts
sk_live_, an AWS access key isAKIA+ 16 uppercase alphanumerics, an IBAN is two letters + two digits + the BBAN). This is enforced by the regex classifier. - Deep validity. The decoy must pass the same integrity check a real value of that type passes. A real value satisfies its checksum by definition; a decoy that fails it is trivially distinguishable. deny.sh's decoy engine enforces, per type:
| Type | Deep-validity check |
|---|---|
| Credit card | Luhn (mod-10) |
| BIP-39 seed phrase (12-24 words) | BIP-39 checksum (SHA-256 of entropy) |
| IBAN | ISO 7064 mod-97 |
| Bitcoin WIF key | Base58Check (double-SHA-256) |
| Solana key | 64-byte Base58 decode |
| Ethereum key | 32-byte hex |
| UK NHS number | mod-11 |
Generated decoys are rejected and regenerated until they pass both the shape and the deep-validity check, so a coerced decoy decryption yields a value that is indistinguishable from a real one on shape and checksum grounds. Types whose integrity is purely structural (opaque API tokens, random hex keys, free-form secrets) have no additional checksum to satisfy, so shape-correctness is already the full mechanical bar for them.
This closes distinguisher (3) from Section 4.2. It does not address distinguisher (4): a checksum-valid card number is still implausible as a decoy if the surrounding context makes a card number nonsensical. Deep validity is necessary, not sufficient, for plausibility.
4.5 Honey Mode: Synthetic Wall-Branch Output
4.5.0 The residual distinguisher
The base construction (Section 3) is value-deniable for every
well-formed control file: real and decoy plaintexts are algebraically
interchangeable (Section 4.2). But a control file derived from a wrong
password is not well-formed. Decrypting under the wrong key produces an
intermediate value X' that is uniform-pseudorandom, so XOR-ing it against the
control data yields a payload whose 4-byte length prefix is, with overwhelming
probability, band-inconsistent: it decodes to a length that does not fit the
bucket band the record was padded to. We call this the wall branch.
The accidental-well-formed-frame probability for a wrong key is
(band - 3) / 2^32 (about 1.4e-8 for the 64-byte band), so the wall
fires essentially every time.
Classic behaviour surfaces the wall branch as random noise or a decode failure. For an adversary brute-forcing a leaked artefact, that noise is itself a signal: it tells them they have not yet hit a well-formed door, so they keep guessing. The boundary between "noise" and "a real or decoy plaintext" is observable across many attempts, and it confirms that the artefact is a deny.sh record being searched rather than already-recovered. The cryptographic core does not address this, because it is a property of the failure output, not the success outputs the deniability theorem covers.
Honey Mode (opt-in per record, structured types only) closes this distinguisher by replacing the wall-branch noise with a deterministic, type-correct, plausible fake. A wrong password no longer yields detectable garbage; it yields a value that looks exactly like a successful decryption of a secret of the declared type. The attacker who tries a wrong password gets a Stripe key, a seed phrase, or an IBAN that passes its own checksum, with no way to tell it apart from a real or pre-positioned-decoy decryption.
4.5.1 Wall-branch construction
Honey decrypt does not change the envelope. It adds a verdict and a fallback around the existing decrypt. After recovering the inner payload, the host checks band-consistency of the length prefix:
isWellFormedFrame(payload, band):
if |payload| < 4: return false
length = LE32(payload[0:4])
return length <= |payload| - 4 AND length <= band - 4
decryptHoney(ciphertext, controlData, {p1,p2}, honeyType, band):
{payload, salt, wellFormed, plaintext} = decryptToPayload(ciphertext, ..., band)
if wellFormed: return utf8(plaintext) # real or decoy slot, unchanged
else: return generateHoneyDecoy(honeyType, payload, salt) # the wall branch
The well-formed branch is the classic path: it returns whatever real or
pre-positioned-decoy plaintext the control file resolves to. Only the wall branch
changes. encryptHoney always sets padToBucket, so
band is well-defined and the band-consistency check has a known
target. The record stores no per-slot authenticator, no MAC, and no slot
list (this is "Approach A"): nothing extra goes on the wire, so Honey
Mode adds no distinguisher for how many real or decoy doors a record has.
4.5.2 Seed derivation and the independence property
The fake is generated from a 32-byte seed:
HONEY_DOMAIN = "deny-sh/honey/v1" (UTF-8, 16 bytes)
seed = SHA-256( HONEY_DOMAIN || 0x00 || decryptBytes || 0x00 || salt || 0x00 || typeTag )
where decryptBytes is the wrong-password wall-branch payload,
salt is the record's public 32-byte salt, and typeTag is
the declared structured type (e.g. stripe-live-key). The three
0x00 bytes are literal domain separators. The seed depends on
these three inputs and nothing else. This single design choice carries
the security argument for Honey Mode, and it has two consequences:
Stable per wrong password (no retry tell). Because the seed is a pure function of the wrong-password decrypt bytes (and fixed public salt and type), the same wrong password always lands the same fake. A naive honeypot that synthesises a fresh random fake on each attempt is trivially detected: the attacker tries the same wrong password twice, sees two different "secrets", and concludes the output is synthetic. deny.sh's fake is deterministic, so a repeated guess yields a byte-identical result, exactly as a real decryption would.
Independent of the real secret (no leak).
decryptByteson the wall branch isAES-256-CTR-Decrypt(K', IV, E) XOR controlDataunder the wrong key K'. It is uncorrelated with the real plaintext: changing the real secret (re-encrypting with a different value under the same passwords) does not change what a given wrong password decrypts to, because the wrong key never recovers the real intermediate value. The seed therefore leaks nothing about the real secret, and the synthesised fake cannot be regressed back toward the protected value. Ports MUST NOT mix any other material into the seed, precisely to preserve this property.
4.5.3 Indistinguishability of the honey branch
The honey branch is engineered to be externally inseparable from the well-formed branch:
- Same output shape. Both branches return a typed string on the
same code path. The internal
branchtag ('real'vs'honey') is telemetry for tests only and is never surfaced to a caller or attacker. - No length oracle. The honey record is padded to a bucket band
(
padToBucket), so the ciphertext byte-count reveals only the same coarse band a real record of that class would. Critically, the generated fake's length is driven by a per-typedummyReallength hint (a fixed synthetic template per type), not by the real secret's length, so the fake's size cannot be used to back out the real value's size. - Validity floor. Each honey output passes its own type's deep-validity check (Luhn, BIP-39 checksum, ISO 7064 mod-97, Base58Check, etc.), the same bar as the curated decoy engine (Section 4.4). A synthesised Stripe key has a plausible shape; a synthesised card number satisfies Luhn; a synthesised seed phrase has a valid BIP-39 checksum. So the fake is indistinguishable from a real value on shape and checksum grounds, not merely on surface pattern.
- Coarse timing overlap. The fallback path runs a hash-DRBG and a few generator draws, work that overlaps the cost envelope of the Argon2id-dominated decrypt it follows; it introduces no branch-revealing timing cliff.
The one distinguisher Honey Mode does not claim to eliminate is contextual plausibility (distinguisher 4 of Section 4.2): a checksum-valid card number is still implausible where a card number makes no sense. Honey Mode raises the mechanical floor to its maximum; it does not make a contextually absurd fake believable.
4.5.4 Structured-types-only boundary
Honey Mode is supported for 63 of the 69 known types. Six are
refused (isHoneyEligible returns false;
encryptHoney/decryptHoney throw): the two unstructured
catch-alls, generic and freeform-secret, plus four
structured types whose record-decoy output is a JSON blob or multi-branch
connection string (jwt-token, postgres-uri,
mongodb-uri, gcp-service-account-key) and so cannot yet
be reproduced byte-identically across the Rust/Python/Go ports. This boundary is
honest, not arbitrary: unstructured data has no shape or checksum to imitate, and
the four deferred structured types would risk a real-vs-honey divergence across
SDKs. Better to refuse Honey Mode for those and leave them on the classic
real+decoy+noise model where the user supplies their own plausible decoys, than
to emit a fake that would itself be a distinguisher.
4.5.5 Determinism, cross-SDK parity, and versioning
If the same record were honeyed in TypeScript and decrypted in Rust, a divergent fake would itself be a distinguisher (the attacker who tries the same wrong password against two SDKs would see two different "secrets"). Honey output must therefore be byte-identical across all four SDKs. This is achieved by pinning every determinism surface:
- a counter-mode SHA-256 hash DRBG
(
block_i = SHA-256(seed || be32(i)), consumed as one continuous keystream); - rejection-sampled uniform integers over 4-byte little-endian reads (matching
the legacy
randIntrejection rule exactly); - a fixed per-type generator draw order (left-to-right, one draw per character, in source-statement order).
All three are frozen by a shared Known-Answer-Test vector set; no port ships
until it reproduces every vector. The
HONEY_DOMAIN = "deny-sh/honey/v1" string binds the seed to this exact
contract. Any change to the seed layout, the DRBG, or a generator's draw
order is a breaking change and requires a new domain string
(deny-sh/honey/v2), because mixed-version SDKs would
otherwise synthesise mutually-inconsistent fakes for the same record. The version
is part of the seed input precisely so that incompatibility surfaces as a
different fake rather than a silent mismatch.
4.6 Limitations
Known limitations:
Plausibility is not cryptographic. The deniability guarantee is mathematical (any control file produces a valid decryption), but its real-world effectiveness depends on the plausibility of the decoy plaintext. The decoy engine enforces shape- and checksum-correctness (Section 4.4), which removes the mechanical distinguishers, but it cannot judge contextual plausibility: a decoy that reads "this is a decoy", or a checksum-valid card number where a card number makes no sense, provides no practical deniability. Choosing a contextually believable decoy remains the user's responsibility.
Length privacy is bucketed, not exact. With length bucketing enabled (Section 3.2.1), the ciphertext size reveals only a coarse band rather than the exact plaintext length. This is a deliberate trade-off: a smaller leak in exchange for bounded ciphertext bloat. An adversary still learns which band a message falls into. Callers requiring uniform artefact sizes within a class can pin an explicit bucket size.
Rubber-hose resistance is bounded. An adversary who demands additional passwords after receiving a decoy can continue demanding until the subject runs out of prepared decoys. deny.sh does not limit the number of decoy control files, but the subject must have prepared them in advance.
Control file existence is detectable. If an adversary finds multiple control files, they know the subject used deniable encryption. deny.sh does not hide the existence of the control files themselves.
Password reuse across control files. All control files for a given ciphertext use the same password pair. An adversary who obtains the passwords can generate their own control files for arbitrary plaintexts - but this does not help them determine which existing control file is "real."
Side-channel attacks. deny.sh does not protect against timing analysis, memory forensics, or other side-channel attacks on the client device. The encryption runs in the browser's JavaScript engine, which is not designed for side-channel resistance.
Argon2id parameter choice. t=3, m=64 MiB, p=1, v=0x13 is the deny.sh interactive default. It deliberately exceeds the current OWASP minimum (t=2, m=19 MiB, p=1) while staying practical for SDK and browser use. The shipping SDKs hard-pin to these defaults for byte-compatibility; a configurable-parameter API and archival presets for high-value targets are on the post-launch roadmap.
Honey Mode is opt-in and structured-types-only. The synthetic wall-branch output described in Section 4.5 closes the residual "wrong password returns obvious noise" distinguisher, but only for the 38 structured secret types whose shape and checksum can be imitated convincingly. Generic and free-form secrets are deliberately refused (Section 4.5.4) and remain on the classic real+decoy+noise model, where the wall branch is still observable to a patient brute-forcer. Honey Mode is also a per-record opt-in, not a default, so any record encrypted without it retains the classic wall behaviour.
5. Threat Model
5.1 Adversary Capabilities
deny.sh is designed to resist the following adversary:
- Has the ciphertext. (Assumed - the file leaked from a cloud-storage breach, was recovered from a stolen or seized device, was exfiltrated from a host, was published accidentally to a public repository, or was disclosed by a compromised AI agent.)
- Has one or more control files. (Assumed - found alongside the ciphertext as part of the same leak, or - in a secondary supported case - obtained from the subject under bounded legal/operational compulsion where the subject pre-positioned a decoy.)
- Has unlimited computational resources for cryptanalysis (but not for Argon2id brute-force within practical time bounds).
- Knows the subject uses deny.sh. (Kerckhoffs's principle - the algorithm is public.)
The construction is not designed to resist an adaptive adversary who already knows the tool is in use in the subject's life and can iteratively demand additional passwords; that scenario is an operational-deniability failure that the cryptographic primitive does not address (see Section 5.3).
5.2 What deny.sh Protects Against
| Threat | Protection |
|---|---|
| Ciphertext leak with one or more control files alongside | Each control file produces a plausible plaintext; bytes resolve to the decoy under any leaked key |
| Cryptanalytic attack on ciphertext | AES-256-CTR with Argon2id KDF |
| Statistical analysis of control files | Empirically verified uniformity (Section 4.3) |
| Control-file size comparison | Real and decoy control files are byte-for-byte equal in length (Section 4.2, distinguisher 1) |
| Ciphertext-length measurement | Bucketed to a coarse band on artefact-producing surfaces (Section 3.2.1) |
| Decoy fails an integrity check (Luhn, BIP-39 checksum, mod-97, Base58Check) | Decoys are required to pass the same deep-validity check as a real value (Section 4.4) |
| Wrong-password decrypt returns obvious noise (a brute-force progress signal) | With Honey Mode enabled on a structured-type record, the wall branch returns a deterministic, type-correct, checksum-valid fake instead of noise (Section 4.5) |
| Forensic detection of "real" vs "decoy" | No cryptographic marker exists (Section 4.2) |
5.3 What deny.sh Does Not Protect Against
| Threat | Limitation |
|---|---|
| Adaptive adversary with iterative password demands | Operational-deniability failure; not addressed by the primitive (subject must have pre-positioned every decoy the adversary can request) |
| Physical compromise of the device | Side-channels, memory forensics |
| Adversary with original plaintext | Trivially computable control data |
| Contextually implausible decoy content | Shape and checksum are enforced, but contextual plausibility is human judgment, not a crypto property (Section 4.4) |
| Exact plaintext length within a bucket band | Length is bucketed, not hidden; a coarse band leak remains by design (Section 3.2.1) |
6. Operational Deniability: The Infrastructure Layer
Sections 2-5 specify a primitive that is complete for an individual encrypting their own data on their own machine. The audiences deny.sh is built for, set out in Section 1.3, operate under conditions the primitive does not by itself address: an AI agent platform holds thousands of tenants' secrets behind one process; a custody product keeps hot-wallet credentials that must survive both subpoena and breach; a regulated organisation must produce a tamper-evident account of who decrypted what, when, without that account itself becoming a confession.
For these settings the deniability property is necessary but not sufficient. A decoy that resolves perfectly under cryptanalysis is worthless if the surrounding system leaks the real plaintext through an access log, lets one tenant's compromised credential reach another tenant's key, or cannot serve the ciphertext at all when the hosted service is down and the subject needs plausible cover now. This section specifies the operational guarantees that close those gaps. They are not cryptographic claims; they are system-design claims, and we state them with the same precision so they can be audited against the running service.
6.1 Per-Tenant Cryptographic Isolation
The multi-tenant runtime never derives one tenant's key in a context
where another tenant's credential is in scope. Tenant identity throughout
the server is the SHA-256 hash of the API key
(apiKeyHash), never the raw key; the raw key exists only
transiently at the request boundary and is never persisted, logged, or
used as a map index. Rate limits, usage accounting, audit chains
(Section 6.2), and bring-your-own-key configuration (Section 6.3) are all
keyed on this hash, so a cross-tenant reference is a hash mismatch rather
than a policy decision that can be misconfigured open.
This matters most for the platforms whose own threat model includes a compromised internal component. An AI agent that has been prompt-injected, or a service account that has been phished, is an adversary already inside the tenant boundary. deny.sh's isolation is designed so that such a component, operating with one tenant's credential, cannot reach across to a second tenant's wrapped key material even though both tenants are served by the same process and, in the bring-your-own-key case, the same cloud IAM principal.
6.2 Tamper-Evident Audit Chain
Every privileged operation on a tenant's data (encrypt, decrypt, deny, key rotation, operator drill-down) appends an entry to a per-tenant, append-only, hash-chained evidence log. The chain is constructed so that any retroactive edit, deletion, or reordering is detectable by recomputation:
prev_hash(entry 1) = SHA-256(tenant_id || "GENESIS")
prev_hash(entry N>1) = entry_hash(entry N-1) (same tenant)
entry_hash(entry N) = SHA-256( prev_hash || tenant_id || op_type
|| canonical(op_payload) || created_at )
canonical(op_payload) is deterministic JSON with
recursively sorted keys and no incidental whitespace, so the hash is
reproducible across implementations and languages.
verifyAuditChain(tenant_id) walks the chain from genesis and
returns the first broken link, if any; the operator dashboard runs this
verification continuously and surfaces a tamper flag rather than a silent
pass. The payload recorded is metadata and event-occurrence only
(operation type, timestamp, key-hash prefix) and never plaintext, key
material, or decoy bytes, so the audit log itself cannot become the leak
it exists to detect.
The hash chain establishes order and integrity but not
time: a party who controls the database could in principle
recompute a self-consistent chain over backdated entries. deny.sh
therefore anchors each entry to an external clock. A background worker
sweeps for entries that lack a granted timestamp and requests an RFC 3161
timestamp token over the entry hash from independent Trusted Timestamp
Authorities (FreeTSA as primary, DigiCert as fallback), persisting the
returned tokens in a separate audit_chain_tsa table. Each
request carries a nonce; verification
(verifyTimestampToken) checks PKIStatus == granted,
that the token's message imprint matches the entry hash, that the
returned nonce matches the one sent, and that the TSA signing
certificate's chain validates and was temporally valid at the asserted
genTime. An open-source verifier re-proves both the hash
chain and the TSA tokens offline, and signed PDF receipts are available
on demand for a regulator or counterparty: this record was protected
with deniability at timestamp T, here is the proof. The chain thus
carries third-party-witnessed time without any party (deny.sh included)
being able to forge it after the fact.
The deniability interaction is deliberate. The log proves that a decrypt occurred and when, which a regulator or incident responder can require, without proving which plaintext was returned, which is precisely what the subject must be able to withhold. Accountability and deniability are usually presented as opposites; the metadata-only hash chain is how deny.sh provides both at once.
6.3 Bring-Your-Own-Key and the Confused-Deputy Guard
Tenants that require their key material to live in their own cloud account use bring-your-own-key (BYOK): the data encryption key is wrapped by a customer-controlled AWS KMS key, reached by deny.sh assuming an IAM role in the customer's account. A shared multi-tenant service that assumes roles into many customer accounts is the textbook setting for a confused-deputy attack, in which tenant A's stored role configuration is replayed to reach tenant B's resources.
deny.sh closes this with a mandatory per-tenant
ExternalId. A 128-bit cryptographically random value
(deny-sh-byok-<32 hex>) is minted per tenant at
registration, persisted against the tenant hash, and supplied on
every sts:AssumeRole call; the tenant pins the
matching value in their role trust policy's sts:ExternalId
condition. The guard is fail-closed at two layers: both the store-wrapper
and the KMS adapter refuse to issue an AssumeRole without the ExternalId,
and the value is folded into the cache key for minted STS credentials so
a session obtained under one tenant's guard can never be served to
another. The identical pattern protects the AWS Secrets Manager
integration. A tenant's role can therefore be assumed by deny.sh only
when the call carries that tenant's secret external identifier, which an
attacker replaying a different tenant's configuration does not
possess.
6.4 Federated Identity (SAML)
Operator and team access for enterprise tenants is brokered through
SAML 2.0 with just-in-time provisioning, so a customer manages deny.sh
access from their own identity provider and deprovisioning is immediate
when an employee is offboarded there. The implementation enforces the
standard assertion invariants inline: assertions must be signed
(wantAssertionsSigned); NotBefore/NotOnOrAfter
are checked with a bounded 60-second clock-skew tolerance; the assertion
Issuer is cross-checked against the tenant's configured IdP
entity ID to defeat IdP-substitution; a 24-hour replay table rejects
re-presented assertion IDs; and an email-domain allowlist gates
provisioning. First login creates a tenant-scoped account idempotently
keyed on (tenant, name_id); repeat logins reuse it. Account
creation is thereby bound to a signed assertion from the tenant's own IdP
rather than to a shared password.
6.5 Continuity
Deniability is time-critical: cover that is unavailable at the moment of compulsion is no cover at all. deny.sh's continuity story is that the cryptographic primitive never depends on the hosted service. The same byte-exact construction is shipped as an Apache-2.0 SDK in four languages (Section 7.3) and as a local-mode MCP server whose encrypt, decrypt, deny tools run with zero knowledge and require no API key (Section 7.9). A tenant can encrypt, produce a decoy, and decrypt entirely offline using only the open-source library, with the hosted runtime contributing audit, isolation, and key-management value but never gating the deniability itself. Self-hosting under the AGPL-3.0 application layer, or a commercial licence, is the supported path for organisations that require the operate layer to run inside their own perimeter.
6.6 What the Infrastructure Layer Does Not Claim
The guarantees above are operational, not cryptographic, and they inherit the primitive's limitations from Section 5.3 rather than removing them. Per-tenant isolation bounds a compromised credential; it does not defend a host whose memory is being read by an adversary with code execution. The audit chain detects tampering with the log; it does not prevent an operator with live database access from reading metadata in real time, which is why the recorded payload is metadata-only by construction. BYOK moves key custody to the tenant; it does not protect against a tenant who is themselves the adversary. None of these mechanisms address the adaptive-compulsion scenario of Section 5.3, which remains an operational-deniability problem the subject must solve by pre-positioning decoys, not one any server feature can solve for them. The infrastructure layer raises the cost and narrows the surface; it does not change what the primitive can and cannot promise.
7. Implementation
7.1 Browser Implementation
The browser tools use the Web Crypto API (AES-CTR) and a JavaScript Argon2id implementation. No data leaves the browser. The SDK primitive used by the browser tools is open source under Apache License 2.0; the application-layer browser-tool source (UI shell and vault integrations) is under AGPL-3.0.
7.2 API Implementation
The server-side API uses Node.js built-in crypto module
(AES-256-CTR) and the same Argon2id KDF parameters as the SDKs. The API receives pre-encrypted data for vault
storage and operates on plaintext only during encrypt/decrypt/deny
operations. API keys are stored as SHA-256 hashes.
7.3 SDK Implementations
SDKs are available in TypeScript, Python, Go, and Rust. All implementations produce identical ciphertext for identical inputs (verified via Known Answer Tests). Cross-language interoperability is guaranteed by the fixed format specification in Section 3.5.
7.4 Chrome Extension
A Manifest V3 Chrome extension provides encrypt/decrypt/vault operations directly in the browser toolbar. The crypto engine is adapted from the browser tools and runs entirely within the extension sandbox. Right-click context menus allow encrypting selected text on any page.
7.5 CLI
An enhanced command-line interface wraps the TypeScript SDK with
commands for encryption, decryption, vault management, environment
and environment variable protection. The CLI supports pipe
input, hidden password prompts, and manages a .deny/
directory convention with automatic gitignore management. Runtime
dependencies: node:crypto from the Node standard library,
hash-wasm for portable Argon2id, and commander
for argument parsing.
7.6 Password Manager Integration
Control data can be pushed to and pulled from 1Password (via
op CLI) and Bitwarden (via bw CLI) as Secure
Notes. This allows users to manage control files within their existing
credential workflows without exposing plaintext to the password
manager.
7.7 Cloud Backup
An encrypted backup system creates Argon2id + AES-256-CTR archives of
control data for storage on local disk, Google Drive, Dropbox, or Amazon
S3. The container envelope is DENY_BACKUP_V1; inside, the
bundle uses version byte 0x03 (Argon2id with t=3, m=64 MiB,
p=1, 32-byte salt, 16-byte IV, AES-256-CTR ciphertext) with independent
key derivation from the backup passphrase. Version 0x02
bundles (16-byte salt) remain readable for legacy compatibility.
7.8 Telegram Bot
A Telegram bot (@denyshbot) provides conversational encrypt/decrypt/vault workflows. Password messages are automatically deleted from chat history after use. Crypto operations are restricted to direct messages.
7.9 MCP Server for AI Agents
A Model Context Protocol server exposes 10 tools (3 local, 7 API) for AI agent integration. Local tools (encrypt, decrypt, deny) operate with zero knowledge and require no API key. This enables AI agents to handle deniable encryption workflows programmatically.
7.10 Agent Framework Adapters
Official adapters wrap the core SDK as a vault-entry-as-tool for LangChain
(deny-sh-langchain, Python + TypeScript), the Vercel AI SDK
(deny-sh-vercel-ai, TypeScript), and the OpenAI Agents SDK
(deny-sh-openai-agents, Python + TypeScript), over a shared
framework-agnostic core (deny-sh-integrations-core). The contract is
identical across adapters: the credential resolves and is consumed inside the
tool boundary via a use(secret, args) callback; only a narrowed DTO
returns to the model, and a fail-closed leak sweep throws
(DenyLeakError) if the raw secret appears anywhere in that DTO. The
adapters add nothing to the security model beyond the core; the deniability
boundary remains cryptographic, not policy. The OpenAI Agents adapter
additionally disables the SDK's default error self-heal
(errorFunction: null in TypeScript; a direct FunctionTool
construction in Python) so secret-scrubbed errors propagate fail-closed rather
than being summarised back into the model context.
7.11 Dependencies
The TypeScript SDK is Apache-2.0 and uses Node's crypto
module plus hash-wasm for Argon2id. Browser tools use Web
Crypto for AES-CTR and a bundled WebAssembly Argon2id implementation
for key derivation. No network calls are made during local encrypt or
decrypt operations.
8. Comparison with Existing Approaches
| Approach | Deniability Model | Limitations |
|---|---|---|
| VeraCrypt hidden volumes | Hidden OS/partition within encrypted volume | Requires disk-level access, detectable via disk analysis heuristics, limited to one hidden volume |
| OTR Messaging | Deniable authentication (not encryption) | Only applies to message authentication, not content |
| Rubberhose (defunct) | Multiple encrypted partitions with independent keys | Abandoned, complex, filesystem-level only |
| Cryptee ($3-27/mo) | Ghost folders (UI-level hiding) | UI deniability only, not cryptographic. Hidden content is still present in the encrypted store and discoverable with forensic access |
| Stegg (ste.gg) | Steganographic embedding (112 techniques) | Hides data within carrier files but does not encrypt by default. Nested layers possible but no deniable decryption |
| deny.sh | XOR-composed control data per message | Message-level, arbitrary data, unlimited decoys, no special filesystem, cryptographic deniability |
9. Verification
All claims in this paper can be independently verified:
- Source code: All four reference SDKs are Apache License 2.0 and listed at deny.sh/sdks. The Python, Rust, and Go ports are public mirrors at deny-python, deny-rs, and deny-go. The TypeScript reference SDK is published on npm as deny-sh; its source-code mirror at deny-js becomes public at the 2026-07-04 launch. All four implementations share the cross-SDK Known Answer Tests below.
- Browser verification suite: deny.sh/verify - 22 tests running live in your browser
- Known Answer Tests: Included in all 4 SDK test suites, ensuring cross-implementation consistency
- OpenAPI specification: deny.sh/openapi.json for programmatic API validation
10. Future Work
- Independent third-party cryptographic audit (on the roadmap; firm and scope to be announced)
- Formal verification of the deniability property using ProVerif or similar tools
- Hardware-accelerated Argon2id for higher parameters in browser
- Chrome Web Store publication for the browser extension
- Package registry publication for Python (PyPI), Go, and Rust (crates.io) SDKs
- Inline mode for the Telegram bot (encrypt in any chat via
@denyshbot)
References
- Canetti, R., Dwork, C., Naor, M., Ostrovsky, R. (1997). "Deniable Encryption." CRYPTO '97.
- Biryukov, A., Dinu, D., Khovratovich, D. (2021). "Argon2 Memory-Hard Function for Password Hashing and Proof-of-Work Applications." RFC 9106.
- Dworkin, M. (2001). "Recommendation for Block Cipher Modes of Operation." NIST SP 800-38A.
- Boyen, X., Haines, T., Müller, T. (2018). "A Formal Treatment of Deniable Encryption."
deny.sh - deny.sh, UK (Co. 15770209) hello@deny.sh | deny.sh