blog.erincandescent.net

Tag: cryptography

Please stop hashing the public key first (or: ML-DSA's µ annoys me)

A goal one might have when designing a signing algorithm is to prevent signing a message one has not seen.

Signing a message one hasn't seen might sound difficult at first, but most classical signature algorithms, as commonly used, sign a hash of the message, rather than the message itself. This kind of arrangement is necessitated by the fact that the underlying algorithms are only capable of signing messages of a limited size. You might not have seen the message, but you might have seen the hash of it, or another signature which encodes said hash. In many protocols, this lets you produce a valid signature on a message you've never seen. That's rarely a problem in practice, but it can be surprising, and it's a pitfall for the unsuspecting protocol designer.

More recent algorithms take steps to prevent this:

  • Ed25519 signs SHA-512(R || PubK || M) where R is functionally a random curve point, PubK is the public key, M is the message, and || is the concatenation operator
  • ML-DSA signs SHAKE-256(SHAKE-256(PubK, 64) || 0x00 || len(ctx) || ctx || m, 64) (where ctx is a 0 to 255 byte context string)

This can complicate your signing code, particularly when dealing with HSMs or smart cards, but it doesn't require majorly reworking your signing process. Unfortunately, on the verification side things are less rosy.

The challenge

As a general rule of thumb, we should avoid touching the body of a signed message until we've verified the signature. Also as a general rule of thumb, cryptographic protocols where there are multiple signers put the signer's identity within said body (X.509, most JWT and CWT usages, SAML, I'm looking at all of you, but you're not the only ones).

This means the verification process ends up being

  1. Gingerly parse the message
  2. Dig through as much message as necessary to identify the signing key
  3. Actually verify the message
  4. Actually consume the message

Often (JWT, CWT and SAML again), there's either no key identifier present in the signed message, or it's permitted for multiple signing keys to share a key identifier†. This means that, if the issuer has multiple keys, you may have to do multiple trial-verifications to work out if the message is actually valid.

One nice property that all of these protocols have (except XML Digital Signatures, but nobody thinks XML Digital Signatures were a good idea) is that they carry the signing method in a header. So, with a traditional signing algorithm, you have the option of

  1. Parsing the header
  2. Identify algorithm and hence the hash algorithm
  3. Hash the message
  4. Gingerly parse the message
  5. Dig through as much message as necessary to identify the signing key
  6. Actually verify the message
  7. Actually consume the message

This comes with two advantages:

  1. After step 3, you can throw away the raw message, reducing memory usage
  2. In step 6, you pay for the cost of verifying a signature for every attempted key rather than also the cost of re-hashing the message. In most cases messages are small and this cost is also small, but every little helps.

Unfortunately, it has the slight flaw that it doesn't work with either Ed25519 or ML-DSA as defined.

For ML-DSA, it would have been quite easy to re-arrange matters. If we instead computed SHAKE-256(0x00 || len(ctx) || ctx || m || SHAKE-256(PubK, 64), 64) (i.e. we put the public key hash in at the end), then we can hold our SHAKE-256 context around and copy it for each trial verification. We'd end up keeping just over 200 bytes of SHAKE-256 context around (instead of 64 bytes of output), but that's not too bad. Unfortunately, that's not the choice that was made and we'll just have to deal with it.

Ed25519 is slightly more challenging. If we reversed the hash computation to be SHA-512(M || R || PubK) instead, it would be vulnerable to length extension attacks (for any SHA-512(X) you can extend it into SHA-512(X || some predictable garbage || Y) for any arbitrary Y).

Fixing that when Ed25519 was published in 2011 would be somewhat tricky, though the easiest option without truncating the hash is just to sign SHA-512(SHA-512("ed25519 domain separation" || M || R || PubK)).

Since we don't have a time machine, it's all a bit academic. In 2026 you can just avoid the problem by using SHA-3, BLAKE2, a truncated SHA-2 variant, or any other modern hash function which isn't vulnerable to length extension attacks.

In the meantime I'll just sigh and hope we can reduce the number of signatures we pass around, given they're really becoming rather large and expensive.


† You may be thinking it somewhat defeats the purpose of having a key identifier if they aren't unique. I don't disagree!

[3 comments]

On finding bugs in Ceph(X)

On the 28th August 2026, the Ceph project released fixes for CVE-2025-30156 and CVE-2026-39944. I first reported these to the project on the 20th December 2024; they were independently rediscovered in the meantime.

These are high risk bugs that are also extremely difficult to remediate, and require coordination across a lot of projects (not just Ceph itself, but the Linux kernel, Rook, and other related projects). They also required implementing methods of doing cluster key rotations. On the one hand, I understand why it took so long to fix these; on the other hand, it is still disappointing that the resolution timeline took over 18 months. Nontheless, my thanks to the Ceph team for their handling of this.

These were human findings. Today, I expect you could throw an LLM at the Ceph codebase and these sorts of bugs would fall out quite quickly, but that wasn't the case in 2024.

Background

Ceph RGW has functionality which integrates with OpenStacks KeyStone, and also emulates the AWS STS API. I was investigating how these worked, looking at the possibility of doing an integration. As I was doing this, I noticed that the same CryptoKeyHandler class was used for to hold keys for both HMAC-SHA256 MACs and AES-CBC encryption.

This piqued my curiosity; mixing keys with different algorithms like this is a code smell. In the end, the same keys are never reused with both, so while this is suboptimal design it isn't a security problem as written. However, this lead me to look at the AES-CBC encryption code, where I discovered that fixed keys were reused with hardcoded nonces.

AES-CBC is not an authenticated-encryption mode (meaning it does not protect against message alteration, though it does hinder it), and also degrades in security significantly when nonces are reused. As I discovered, this use of AES-CBC was the only protection applied to messages protected with CephX, and to RGW session tokens.

As the CLYSO researchers who also found this bug demonstrated, it is possible to elevate privileges on vulnerable RGW installs with simple bitflips.

This is sadly not a complicated issue to find; any junior cryptographic engineer should have several klaxons going off in their head at the sight of a hardcoded nonce without good justification. I was not searching through the Ceph codebase for a critical security vulnerability; I just happened to stumble upon it.

Takeaways

Ceph is an old project; the design of CephX likely predates the wide dissemination of the AEAD construct. We should pay attention to other codebases of similar vintage which also sometimes had to hand-roll their cryptographic protocols out of necessity.

Permutations: Replacing the block cipher?

History

For the past few decades, the block cipher has been the primary symmetric cryptographic primitive. Aside from the odd stream cipher (primarily RC-4), pretty much every common cryptographic primitive from before 2010 can be seen as built from a block cipher.

Quick note: How do we define a block cipher? A block cipher is a function with the form c = f(p, k) where c, p are an N-bit ciphertext and plaintext respectively, and k is an M-bit key.

That this is the case with ciphers like DES and AES is obvious - these are firmly classified as block ciphers. But with the other major family of cryptographic functions - hash functions - are built in the same way is less so. This involve looking at how these are built, and in the case of the prolific MD4 family (which comprises MD5, SHA-1, RIPEMD-160, and the SHA-2 hash functions), they are all built upon the Merkle–Damgård construction

A round of MD4, the father of most hash functions

The MD construction takes a compression function and iterates it once per input block of data (additionally, because the compression function takes a fixed amount of input on each iteration, it is necessary to pad the last block. The way this is done varies between hash functions, but in general most deterministic types of block cipher padding work).

What's a compression function? Its a function which takes the form s2 = f(s1, b) where s1 is an N-bit input state, s2 is an N-bit output state, and b is B-bit input. You may notice that this looks a lot like a block cipher, and you'd be right: An MD hash is an odd use of a block cipher where you feed the data in through the key input.

This isn't merely an observation though: There are two notable sets of algorithms which are based upon this result:

  • MDC-2 isn't quite a Merkle–Damgård construction, in part becuase it's being used to turn an N-bit blockcipher into a 2N-bit hash function, but it too produces a one-way compression function out of a block cipher by feeding the input in as the "key"
  • SHACAL SHACAL extracts the block cipher out of SHA-1 and SHA-2 as distinct algorithms (in the case of SHACAL-1, this produces a cipher with a decidedly odd 160-bit block size)

Side note: MDC-2 is a simple design with a strong proof of security. If you ever find yourself writing code for a low performance embedded platform with an AES accelerator, and need a hash function, using MD-2 to produce a 256-bit hash function out of AES would not be a bad call

The Merkle–Damgård construction has stood the test of time, and the SHA-2 family is handling the test of time well. However, it does have one major flaw in the form of length extension attacks: Most MD hashes - and indeed all of the ones commonly used here - use the final state (after inputting the last block of data and padding) as the hash value with no modification. A consequence of this is that if you give me the hash of a message M H(m), I can initialise the internal state of my hash function to that value and compute the hash of any extended message H(m || Pad(m) || n) (where Pad(m) is the hash function's internal padding for message m, and || is its useful sense in cryptography-related texts: conatenation)

This is why HMAC exists: If you compute H(k || m), any attacker can compute H(k || m || Pad(k || m) || n) and impersonate you. New hash functions - like BLAKE and SHA-3 are not vulnerable to such attacks.

Its relatively easy to avoid such attacks with MD construction though, via either of two methods:

  • You can add a non-invertible finalisation step, so that the output doesn't directly match the internal state, or
  • You can drop a some of the state from the output data, so the attacker doesn't have the entirety of it. NIST defined SHA-512/256 and SHA-512/224 as official truncated versions of SHA-512 (with different initialization values, so a SHA-512 hash directly truncated doesn't produce the same values) to defend against this in 2012. SHA-384 and SHA-224, being similarly truncated versions of SHA-512 and SHA-256 also offer some defense against this, albeit with lower margins (128-bit in the case of SHA-384, and only 32-bit in the case of SHA-224)

However, block ciphers in general are going out of style, and in their place permutations are becoming the common building block

Permutations

A permutation is a function o = f(i), where o and i are N-Bit output and inputs. A permutation might have an inverse function, but it's not strictly a requirement (in the same way as it's not strictly a requirement of the compression function used inside an MD hash funciton either).

Most modern cryptographic building blocks have permutations at their core:

  • Salsa20 and Chacha20 functions are both permutations, taking 64-byte inputs and producing 64-byte outputs.
  • The BLAKE family of hash functions (derived from Chacha20) are also built around a 64-byte permutation
  • SHA-3 is built around the 1600-bit Keccak permutation and the Sponge construction
  • The Gimli lightweight cryptography function is also defined as a 384-bit permutation

It's relatively easy to derive a stream cipher from a permutation: Feed it, as inputs

  • An optional constant
  • The secret key
  • The Initilaization Vector / Nonce
  • The block counter

and use the resulting output as your keystream; this is how the Salsa20/Chacha20 family of ciphers work. But it's also possible to derive other functions, some of them very interesting

One thing of note before we progress: The following constructions divide the state of an N-Bit permutation into two parts:

  • r, the rate, which controls the number of bits of input consumed and/or output produced per iteration
  • c, the capacity, which controls the security level. Roughly a c bit capacity produces a c/2 bit security level, but the details for each particular construction vary

Sponges: Hashes and "Extensible Output Functions"

Diagram of the structure of a Sponge hash

The Sponge Construction is named after its real life counterpart: During the first phase, the sponge absorbs its input; then, during the second, output is "squeezed" out of it. You initialise your state to some value (typically zero) and then, at each step, XOR N bytes of the input into it before applying the permutation.

At the end, instead of adding N bytes to the state, you extract N bytes out of it in each cycle before applying the permutation to mix the state around.

This comes with some advantages: Because only some of the state is extracted at each output step, length extension attacks are impossible. Additionally, you can keep squeezing ad-infinitum, which gives you an extensible output function. These don't give you output with any more collision resistance than the hash operated normally, but can be used when you need a specific number of bits of output.

This means that from the permutation, using the sponge construction we can already derive

  • A hash function: Insert the input data, then extract N bits of output
  • A MAC: Trivially by just computing H(K || M), though there are better and only slightly more complicated methods
  • A key material "extractor" (in the same sense as HKDF-Extract: A function which takes as input a variable amount of input, and produces a fixed amount of output suitable for use as a key)
  • A key material "expander" (also in the same sense as HKDF-Expand: A function which takes a fixed input key and a domain separation label, and produces a variable amount of output)
  • A stream cipher: As well as the method of construction used by Chacha20

But this isn't the only thing a Sponge can do

AEADs: Duplexing the permutation

Diagram of the Duplex construction

What if

  • Before encrypting any data, we inserted our secret key into the state in the same way as we did for the Sponge function above,
  • We XOR in an IV in a similar way, and
  • After we XOR each input block into the state (but before we run the permutation function), we output that as ciphertext?

In doing so, we've produced something that looks a bit like the CFB (ciphertext feedback) block cipher mode; the recipient can XOR a block of ciphertext against the state of the sponge to recover the plaintext. However, a replacement for CFB mode is fairly uininteresting: even with a block cipher, there's little reason to use it instead of counter mode.

But if we added an additional step:

  • Once we've inserted all the data into the state and run the permutation function, we extract N-bits of data as a MAC

People have tried doing similar things with block ciphers; for example, encrypt a block of zeroes at the end of the data in CBC mode, verify its still zero on decryption, but these are doomed to failure (modifying a block of ciphertext in CBC mode only affects that block and the following one); however, this isn't the case when we're using a permutation as our primitive because there's a whole bunch of state in our permutation which an attacker never sees.

So if we're building off of a permutation, we can produce ourselves an AEAD without needing a separate primitive for the authentication part (such as Poly1305 or GCM's GHash)

Note: In common with most traditional AEAD constructions, this construction isn't nonce-misuse resistant. If you wish nonce-misuse resistance, you will likely need to use a two-pass construction such as constructing a synthetic IV from a hash of the IV and the message

Building on top of the Duplex

We can build other things on top of this primitive as well; one of the examples given in the Sponge paper is a reseedable pseudorandom number generator. Indeed, the NetBSD random number generator is now built upon a Keccak sponge

Indeed, both duplex and sponge constructions are very flexible: there's no reason to limit them to a single pass of data input or output. It would be entirely reasonable for a stream protocol (such as TLS, though not DTLS) to initialise a cipher state and then keep reusing that same state to encrypt and authenticate each message sent.

The Future?

Will the block cipher die? I doubt it - if only because of inertia. Additionally, there are a few applications (mostly where there are very small amounts of constant size data to encrypt) where a block cipher in ECB mode is the best primitive.

But it definitely seems like we've passed a turning point, and permutations are the way we're going to be doing cryptography into the future.