TRENDING
A vintage glass syringe with a metal plunger and orange measurement marks lying on a plain white background
November 9, 2026
How to Stop Command Injection in Python subprocess Calls, Including Git Option Injection and the Windows .cmd Trap
A green valet key and a black ignition key with a remote fob on a white background, the brand emblems blurred
November 9, 2026
Cloudflare’s Trusted Agent Delegation Essay Names 14 Site Problems, but the Identity Standard It Prefers Defines No Delegation, and Its First Shipped Fix Sees Agents Only at Login and Sign-Up
Sepia 1900 postcard photograph of two riders on horses galloping neck and neck at the front of a field on a Hannover racecourse, with the rest of the pack behind them
November 9, 2026
CloudLinux Says an Exploit Hit a CloudLinux 8 Server on November 4 Through Linux Kernel Bug CVE-2026-98276, Fixed Upstream Since September, and Red Hat Still Lists Every Supported RHEL Kernel as Affected
Two yellow commuter trains stand side by side on separate tracks next to a platform in a railway yard, with more tracks running in parallel on both sides and trees in the background
November 9, 2026
How to Run Parallel AI Coding Agents With git worktree and Check Their Branches for Conflicts Before You Merge
Car speedometer dial with a red needle pointing at about 60 miles per hour, or about 100 kilometers per hour on the inner scale
November 9, 2026
METR’s Anthropic Report Turns AI-Led Acceleration Into a 1.9x Slope Ratio That Reproduces From Its Charts, but the Ratio Runs From 1.1x to 2.9x With the Fit Function, and a Second METR Report Says Anthropic’s Public Risk Report Understated the Pace
09 Nov 2026
SXZ.io SXZ.io
  • Home
Search the Site
Popular Searches:
Technology Amazon AI
Recent Posts
Tall orange and white container cranes rising against a clear blue sky at a container terminal
How to Fix Python ModuleNotFoundError and Relative Import Errors, Break Circular Imports, and Stop Local Files From Shadowing the Standard Library
November 8, 2026
The Alcatraz lighthouse at night with beams of light sweeping across the sky above the ruined warden’s house
WIRED’s “Free Chinese AI Models Are Getting Scary Good at Hacking” Rests on Two Cyber Ranges: DeepSeek Is Level With the Leader on One, the GLM It Names Finished Last of Seven, and Three Claude Runs Stopped by Refusals Appear Only in the Chart Data
November 8, 2026
Stepped concrete headquarters building of Crédit Mutuel Arkéa at Le Relecq-Kerhuon in Brittany, seen across a lawn with bare trees and flowering shrubs
Crédit Mutuel Arkéa Says Attackers Reached Its Own Account Through a Service Provider’s Platform; the On-Chain Trail Attributed to the Theft Shows $126 Million in USDC Reaching 17 Wallets in 28 Hours, Most of It Moving On Within Minutes
November 8, 2026
SXZ.io SXZ.io
  • Home

Categories

Articles 290 Posts
News 294 Posts
Learning Hub 263 Posts
Home/Articles/Cloudflare’s Post-Quantum Privacy Plan Turns Anonymous Credentials Into 4.3 to 12 KB Proofs, but the Smallest Needs the Hash Taken Out of the Proof, Each Anchor Key Is 46.6 KB, and the Two Larger Rows Would Not Fit the Draft’s Header at Default 8 KB Limits
Articles

Cloudflare’s Post-Quantum Privacy Plan Turns Anonymous Credentials Into 4.3 to 12 KB Proofs, but the Smallest Needs the Hash Taken Out of the Proof, Each Anchor Key Is 46.6 KB, and the Two Larger Rows Would Not Fit the Draft’s Header at Default 8 KB Limits

Cloudflare’s post-quantum anonymous-credential sizes of 4.3, 6.5 and 12.0 KB rest on a hash-free design that the PoMFRIT paper ties to a non-standard assumption, on 46,591-byte anchor keys, and on...

November 6, 2026 25 Min Read
28

On Friday, November 6, Cloudflare published “The post-quantum future of web privacy”, by Christopher Patton, Tianyu Zhang and Bas Westerbaan. It asks how anonymous credentials, the kind of token Privacy Pass issues so that a site can rate-limit a client without identifying it, could survive a quantum computer. The building block is a blind signature made from the VOLE-in-the-Head proof system and the UOV multivariate signature, and the target is a protocol family the post calls MOLE, “Moderation Of unLinkable Endorsements.” The post ends with a table of sizes (4.3, 6.5 and 12.0 KB) and a conclusion: “the transition to PQ anonymous credentials for the web can now shift from cryptography design to protocol engineering.”

Table Of Content

  • What the post proposes
  • Where the 4.3 KB comes from
  • A proof-size budget that explains the table
  • Keys: 46,591 bytes each, and “comparable overhead”
  • The anchor set: the number the table never states
  • What the draft’s HTTP carriage does to those sizes
  • The RSA forgery sentence
  • Scorecard
  • Questions to put to Cloudflare before relying on the table
  • Sources and method

I read the post against the paper behind its headline number, the Round 3 specifications for UOV and FAEST, the three IETF drafts that define MoLE (the drafts spell it that way), the RFCs for Privacy Pass and RSA blind signatures, and the RSA-forgery preprint it cites. Then I rebuilt the sizes in code and ran a blind-RSA issuer from RFC 9474. Six things came out of that.

  • The smallest figure rests on a weaker assumption. The paper’s 6.7 KB blind signature is its “One-More-MAYO” row, which the authors describe as using “a non-standard assumption for MAYO.” The rows that keep a hash inside the proof are 16.5 and 24.3 KB. Cloudflare’s 4.3 KB swaps UOV into the hash-free design. The post says the optimization “changes our security posture,” but its table does not say which rows use it.
  • FAEST’s own size formula pins down the smallest row. Its 4.3 KB is what a witness of the UOV signature, the issuer’s salt and a message-length mask costs, with no room left for a hash state. The other two rows are 2.2 and 7.7 KB larger, and the post does not say how those bytes are spent.
  • “Comparable overhead” holds for bytes per presentation and fails for keys. Moving from RSA-2048 to PoMFRIT with UOV multiplies the signature or proof by 16.8, while ECDSA to ML-DSA-44 multiplies it by 37.8. The public key grows 158.5 times, against 20.2 times for ML-DSA-44.
  • The July MoLE drafts carry challenges, redemptions and presentations in HTTP headers. The 6.5 and 12.0 KB rows become 8,667 and 16,000 base64url characters, over the 8,192-byte and 8,190-byte per-field defaults of nginx and Apache. A single UOV key in a challenge would be 62,122 characters.
  • No row says how many anchors it assumes. The architecture draft says a Moderator with one anchor “provides no Anchor-hiding,” and every extra anchor adds a 46,591-byte key for clients and verifiers to hold.
  • The sentence about the RSA forgery does not match the abstract it links. The post says the researchers used “a Privacy Pass issuer as a signing oracle.” The abstract says the oracle was a hardware security module, and that blind RSA schemes “also provide such a signing oracle.”

What the post proposes

MOLE has three roles. A client collects an endorsement from an anchor, a party that already vouches for it. It redeems the endorsement at a moderator, the site doing access control, and gets back a credential that the moderator uses to rate-limit the client. The post says an endorsement’s key property is that “it can be redeemed while hiding the identity of the anchor that issued it,” so a moderator cannot treat clients differently by who vouched for them. Before redemption, “the client and moderator agree on an anchor set, the public keys of the anchors trusted by the moderator.”

The cryptography rests on a recipe for blind signatures. The client hashes its message with some randomness, the issuer signs that commitment, and the client later proves in zero knowledge that it holds a valid signature on a commitment to the message. The proof is the blind signature. The post’s source for the recipe is PoMFRIT (Baum, Beckmann, Beullens, Mukherjee and Rechberger, USENIX Security 2026), whose abstract reports blind signatures of 6.7 KB with 0.45 KB of issuance traffic, built on the MAYO multivariate signature. Cloudflare says “We can easily drive the size of the signature down to 4.3KB by swapping MAYO out for UOV,” using “NIST round 3 parameters for UOV (uov-Ip) and FAEST-EM-128s.” It then describes two schemes it is “currently developing,” VOLE-IHAT for endorsements and VOLE-ACT for credentials, and prints this table:

Scheme in the post Issuance Presentation
PoMFRIT (with UOV) <1 KB 4.3 KB (redemption)
VOLE-IHAT 5.6 KB 6.5 KB (redemption)
VOLE-ACT 5.4 KB 12.0 KB (spend)

The post says all three use uov-Ip, so “the public keys are all 46.6KB.” It says the numbers are “subject to change as we analyze and develop these constructions,” and it lists what is left to do: “producing security proofs for them that show they are secure as long as UOV is secure,” and waiting for cryptanalysis of UOV to catch up. The MoLE drafts are just as early. The protocols draft (draft-jms-mole-protocols-00, July 6, 2026, by Samuel Schlesinger of Google, Dennis Jackson of Mozilla and Thibault Meunier of Cloudflare) says of its own contents: “They are not final.” It defines one endorsement type over P-256 and one built on the Longfellow zero-knowledge scheme, and the word “quantum” does not appear in it. None of the table’s three schemes has a draft type or a paper of its own that I could find.

Where the 4.3 KB comes from

The paper’s headline is one row of its Table 1, not the whole table. The authors built the blind signature several ways and report them side by side (sizes in KB and showing times in ms, each as small / fast parameter sets):

Variant in the paper Assumption column Issuance Blind signature Showing time
SHAKE256-deg16+MAYO-128 UOV/WMQ 0.486 24.3 / 41.6 178.1 / 75.6
RainHash+MAYO-128 UOV/WMQ 0.486 16.5 / 27.8 114.4 / 49.8
One-More-MAYO-128 UOV/One-More-WMQ 0.469 6.7 / 10.4 42 / 40

Table 3 of the paper also lists the unoptimized SHAKE256 version at 92.66 KB (small) and 163.15 KB (fast). The 6.7 KB in the abstract is the last row above. The paper says what it takes: “This requires a stronger, but justifiable, security assumption on MAYO,” and its introduction counts the result as reached “Using a non-standard assumption for MAYO.” The idea is to stop proving hashes inside the zero-knowledge proof. The user masks the hash of the message with randomness it has committed to, so the hash can be computed in the clear. Each Keccak evaluation left inside the proof is expensive, which is why the rows that keep a hash in the proof are 16.5 and 24.3 KB at the small setting.

Cloudflare describes the same trade. After setting out the generic recipe, which needs two Keccak evaluations inside the proof, it says PoMFRIT “achieved its size record by doing precisely the same thing,” using fewer hashes, and adds: “This strategy substantially reduces proof size, but it also changes our security posture.” It then names the cost, a “one-more-UOV assumption,” and warns that “This assumption may turn out to be overly optimistic.” The caution is in the post. What the table lacks is a label saying which rows are on which side.

The post calls its result “competitive with state-of-the-art lattice-based constructions,” and the link goes to a preprint received on August 14 by Blazy, Mallordy and Wen. Its abstract says earlier lattice blind signatures come to “at least 22 KB” and reports “a significantly more compact blind signature of size 4.7 KB, with concurrent security under the Module-SIS assumption,” at the price of sometimes needing more than one round (an expected 1.1 for honest users under its proposed parameters). A 4.7 KB signature on a standard lattice assumption is the fair comparison for a 4.3 KB estimate that needs a one-more assumption. Against it, the hash-in-proof rows are not competitive, and the hash-free design is.

A proof-size budget that explains the table

Cloudflare’s sizes come from the FAEST-EM-128s parameters, and FAEST’s specification publishes the formula for them. Section 3.1 of version 3 (the Round 3 text) gives the signature size in bits as tau*ell + n_mask*n_mult_bar + 4*lam + (d_zk - 1)*lam + T_open*lam + n_leafcom*lam*tau + lam + 128 + 32, where ell is the number of witness bits the proof carries and tau is the number of repetitions. The script below implements it, checks it against the four 128-bit sizes in Table 3.2 of the specification, and then asks what 4.3 KB can hold.

# faest_budget.py
"""A proof-size budget from FAEST v3 (NIST Round 3): the Section 3.1 size formula and Table 3.2.
size in bits = tau*ell + n_mask*n_mult_bar + 4*lam + (d_zk - 1)*lam + T_open*lam
               + n_leafcom*lam*tau + lam + 128 + 32
ell is the witness length, tau the repetitions, d_zk the degree of the check (7 for AES),
n_mask = d_zk + 3 (the specification's mask count) and n_mult_bar is n_mult rounded up to whole bytes."""
LAM = 128
# name: (ell, tau, T_open, n_leafcom, n_mult, signature bytes in Table 3.2)
SETS = {
    "FAEST-128s": (960, 11, 102, 3, 312, 4066),
    "FAEST-128f": (960, 17, 108, 3, 312, 5170),
    "FAEST-EM-128s": (640, 11, 103, 2, 312, 3466),
    "FAEST-EM-128f": (640, 17, 105, 2, 312, 4170),
}


def size_bits(ell, tau, t_open, n_leafcom, n_mult, d_zk=7):
    n_mask = d_zk + 3
    n_mult_bar = -(-n_mult // 8) * 8
    return (tau * ell + n_mask * n_mult_bar + 4 * LAM + (d_zk - 1) * LAM
            + t_open * LAM + n_leafcom * LAM * tau + LAM + 128 + 32)


print("=== the formula against Table 3.2")
for name, (ell, tau, t_open, n_leaf, n_mult, table) in SETS.items():
    got = size_bits(ell, tau, t_open, n_leaf, n_mult) // 8
    print(f"{name:14s} formula {got:5d} B | table {table:5d} B | match {got == table} | {tau} proof bits per witness bit")

_, TAU, T_OPEN, N_LEAF, N_MULT, _ = SETS["FAEST-EM-128s"]
PER_BIT = TAU / 8  # bytes per extra witness bit: tau - 1 correction bits plus one bit of d


def size_bytes(witness_bits, d_zk=2):
    """Size for the EM-128s set with a witness of this many bits; a quadratic check such as UOV uses d_zk = 2."""
    return size_bits(witness_bits, TAU, T_OPEN, N_LEAF, N_MULT, d_zk=d_zk) / 8


print(f"\n=== EM-128s model: size = {size_bytes(0):.0f} B + {PER_BIT} B x witness bits (quadratic check, d_zk = 2)")
print(f"the fixed part is {size_bytes(0, 7):.0f} B at the AES degree 7 and {size_bytes(0, 16):.0f} B at degree 16")
UOV_S, SALT = 119 * 8, 16 * 8  # the UOV signature vector and the issuer's salt, in bits
print("witness guesses for 'PoMFRIT with UOV' (the post says 4.3 KB with no hash inside the proof):")
for label, bits in [("signature vector + salt", UOV_S + SALT),
                    ("+ 128-bit commitment randomness", UOV_S + SALT + 128),
                    ("+ 360-bit mask (45-byte target)", UOV_S + SALT + 360)]:
    print(f"  {label:34s} {bits:5d} bits -> {size_bytes(bits):6.0f} B")
print(f"a 4,300 B proof holds about {(4300 - size_bytes(0)) / PER_BIT:.0f} witness bits")

print("\n=== what one committed Keccak state costs")
for state in (800, 1600):
    print(f"  {state}-bit state: {state * PER_BIT:5.0f} B")

print("\n=== the post's other two rows against the 4,300 B row")
STATE = 800 * PER_BIT
for label, post in [("VOLE-IHAT redemption", 6500), ("VOLE-ACT spend", 12000)]:
    print(f"  {label:22s} {post:6,} B = 4,300 + {post - 4300:5,} B = {(post - 4300) / STATE:3.1f} states of {STATE:.0f} B")
print(f"a degree-16 check, as in the PoMFRIT paper's Keccak technique, adds {size_bytes(0, 16) - size_bytes(0):.0f} B to the fixed part")
=== the formula against Table 3.2
FAEST-128s     formula  4066 B | table  4066 B | match True | 11 proof bits per witness bit
FAEST-128f     formula  5170 B | table  5170 B | match True | 17 proof bits per witness bit
FAEST-EM-128s  formula  3466 B | table  3466 B | match True | 11 proof bits per witness bit
FAEST-EM-128f  formula  4170 B | table  4170 B | match True | 17 proof bits per witness bit

=== EM-128s model: size = 2311 B + 1.375 B x witness bits (quadratic check, d_zk = 2)
the fixed part is 2586 B at the AES degree 7 and 3081 B at degree 16
witness guesses for 'PoMFRIT with UOV' (the post says 4.3 KB with no hash inside the proof):
  signature vector + salt             1080 bits ->   3796 B
  + 128-bit commitment randomness     1208 bits ->   3972 B
  + 360-bit mask (45-byte target)     1440 bits ->   4291 B
a 4,300 B proof holds about 1447 witness bits

=== what one committed Keccak state costs
  800-bit state:  1100 B
  1600-bit state:  2200 B

=== the post's other two rows against the 4,300 B row
  VOLE-IHAT redemption    6,500 B = 4,300 + 2,200 B = 2.0 states of 1100 B
  VOLE-ACT spend         12,000 B = 4,300 + 7,700 B = 7.0 states of 1100 B
a degree-16 check, as in the PoMFRIT paper's Keccak technique, adds 770 B to the fixed part

The formula reproduces all four sizes exactly, so I trust it for the model. Two readings follow from it.

First, the cost of a witness bit. The post says “adding an extra witness bit adds ten bits to the proof.” In the formula it is tau bits, which is eleven for the small sets: ten correction bits plus one bit of the witness commitment. The post’s ten is the corrections alone, and its “neat trick” of “only one bit of proof per bit of witness” matches the commitment bit alone. At 1.375 bytes per witness bit, one committed 800-bit Keccak state, the state size the post gives for its reduced Keccak, costs 1,100 bytes.

Second, what 4.3 KB holds. A quadratic check such as a UOV evaluation needs a lower check degree than AES, which shrinks both the check-response term and the number of masks (the specification sets n_mask to d_zk + 3), so the fixed part drops to 2,311 bytes. It would be 2,586 bytes at the AES degree. A witness made of the UOV signature vector (119 bytes), the issuer’s 16-byte salt and a mask as long as the 45-byte target, 360 bits in all, is 1,440 bits and comes to 4,291 bytes, within 0.3 percent of the post’s figure. The paper masks the message hash with randomness of the same length as the hash, so a mask of that length is the natural choice, but it is my guess about the circuit. With only 128 bits of randomness the same model gives 3,972 bytes, about 8 percent under. Either way the 4.3 KB figure holds about 1,447 witness bits, and the likely witness leaves no room for a hash state, which costs 1,100 bytes for 800 bits. The smallest row cannot have a Keccak evaluation inside its proof, which agrees with where the paper and the post put it.

The other two rows are 2.2 and 7.7 KB larger than the 4.3 KB row. At 1,100 bytes per committed 800-bit state, that is room for two and seven states, which is the right order for the “two hashes and one MQ evaluation” the post says an endorsement costs. I cannot read more into it. The post gives no breakdown, a higher check degree would add bytes (the paper’s Keccak technique uses degree 16, which adds 770 bytes to the fixed part in this formula), and the same bytes could be spent differently. What the arithmetic does support is the narrower point: only the 4.3 KB row is clearly hash-free, and the table does not say whether the other two are.

Keys: 46,591 bytes each, and “comparable overhead”

The post’s UOV figures, “135 bytes” for the signature and “46.6KB” for the public key, match Table 1 of the UOV Round 3 specification (version 3.0, August 30, 2026). For uov-Ip, which has n = 119, m = 45 and q = 256, the table lists a 135-byte signature, a 46,591-byte compact public key and a 321,300-byte expanded one. The script rebuilds the compact key as a 16-byte seed plus 45 triangles of 1,035 bytes, and rebuilds all four parameter sets from n, m and q with every size matching the specification. It also measures the RSA, ECDSA and ML-DSA sizes the post compares against and works out the growth factors.

# sizes.py
"""Byte sizes behind the Cloudflare post-quantum privacy post.
Python 3.13, cryptography 50. Measured: RSA-2048, ECDSA P-256, ML-DSA-44.
Derived from the UOV Round 3 specification (Table 1): the four uov sizes.
Copied from the post: the three PQ presentation sizes."""
from cryptography.hazmat.primitives import serialization as ser
from cryptography.hazmat.primitives.asymmetric import ec, rsa
from cryptography.hazmat.primitives.asymmetric.mldsa import MLDSA44PrivateKey
from cryptography.hazmat.primitives.asymmetric.utils import decode_dss_signature
from cryptography.hazmat.primitives import hashes

print("=== Today: RSA-2048 blind signature and the Privacy Pass token (RFC 9578, token type 2)")
rsa_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
rsa_spki = rsa_key.public_key().public_bytes(ser.Encoding.DER, ser.PublicFormat.SubjectPublicKeyInfo)
SIG_RSA = (rsa_key.key_size + 7) // 8
TOKEN = 2 + 32 + 32 + 32 + SIG_RSA  # token_type, nonce, challenge_digest, token_key_id, authenticator
print(f"RSA-2048 signature {SIG_RSA} B | public key (SPKI DER) {len(rsa_spki)} B | token {TOKEN} B")

print("\n=== The post's comparison pair: ECDSA P-256 and ML-DSA-44")
ec_key = ec.generate_private_key(ec.SECP256R1())
r, s = decode_dss_signature(ec_key.sign(b"hello", ec.ECDSA(hashes.SHA256())))
SIG_EC = len(r.to_bytes(32, "big") + s.to_bytes(32, "big"))
PK_EC = len(ec_key.public_key().public_bytes(ser.Encoding.X962, ser.PublicFormat.UncompressedPoint))
mldsa = MLDSA44PrivateKey.generate()
try:
    sig_ml = mldsa.sign(b"hello")
except TypeError:
    sig_ml = mldsa.sign(b"hello", b"")
SIG_ML, PK_ML = len(sig_ml), len(mldsa.public_key().public_bytes_raw())
print(f"ECDSA P-256 signature {SIG_EC} B (raw r and s) | public key {PK_EC} B (uncompressed point)")
print(f"ML-DSA-44 signature {SIG_ML} B | public key {PK_ML} B")


def uov(n, m, q, salt=16, seed=16):
    """UOV sizes in bytes from n, m, q: P1 and P2 come from a seed, only P3 is stored in the compact key."""
    v = n - m
    per = 1 if q == 256 else 0.5  # bytes per field element
    p1, p2, p3 = m * v * (v + 1) // 2, m * v * m, m * m * (m + 1) // 2
    return int((p1 + p2 + p3) * per), int(seed + p3 * per), int(n * per + salt)


print("\n=== UOV Round 3 (August 30, 2026), rebuilt from n, m, q; spec Table 1 values in the last column")
SPEC = {"uov-Ip": (119, 45, 256, (321300, 46591, 135)), "uov-Is": (160, 64, 16, (412160, 66576, 96)),
        "uov-III": (193, 72, 256, (1347912, 189232, 209)), "uov-V": (259, 96, 256, (3232320, 446992, 275))}
for name, (n, m, q, table) in SPEC.items():
    got = uov(n, m, q)
    print(f"{name:8s} n={n:3d} m={m:2d} q={q:3d} epk/cpk/sig = {got[0]:>9,} {got[1]:>8,} {got[2]:>4}  spec {table[0]:>9,} {table[1]:>8,} {table[2]:>4}  match {got == table}")

print("\n=== Blow-up factors (the post's 4.3 KB read as 4,300 bytes)")
PQ = 4300
print(f"signature or proof: RSA-2048 {SIG_RSA} B -> PoMFRIT with UOV {PQ:,} B = {PQ / SIG_RSA:.1f}x")
print(f"whole token if the {SIG_RSA}-byte authenticator became that proof: {TOKEN} B -> {TOKEN - SIG_RSA + PQ:,} B = {(TOKEN - SIG_RSA + PQ) / TOKEN:.1f}x")
print(f"signature: ECDSA P-256 {SIG_EC} B -> ML-DSA-44 {SIG_ML:,} B = {SIG_ML / SIG_EC:.1f}x")
print(f"public key: RSA-2048 {len(rsa_spki)} B -> uov-Ip compact {uov(119, 45, 256)[1]:,} B = {uov(119, 45, 256)[1] / len(rsa_spki):.1f}x")
print(f"public key: ECDSA P-256 {PK_EC} B -> ML-DSA-44 {PK_ML:,} B = {PK_ML / PK_EC:.1f}x")
=== Today: RSA-2048 blind signature and the Privacy Pass token (RFC 9578, token type 2)
RSA-2048 signature 256 B | public key (SPKI DER) 294 B | token 354 B

=== The post's comparison pair: ECDSA P-256 and ML-DSA-44
ECDSA P-256 signature 64 B (raw r and s) | public key 65 B (uncompressed point)
ML-DSA-44 signature 2420 B | public key 1312 B

=== UOV Round 3 (August 30, 2026), rebuilt from n, m, q; spec Table 1 values in the last column
uov-Ip   n=119 m=45 q=256 epk/cpk/sig =   321,300   46,591  135  spec   321,300   46,591  135  match True
uov-Is   n=160 m=64 q= 16 epk/cpk/sig =   412,160   66,576   96  spec   412,160   66,576   96  match True
uov-III  n=193 m=72 q=256 epk/cpk/sig = 1,347,912  189,232  209  spec 1,347,912  189,232  209  match True
uov-V    n=259 m=96 q=256 epk/cpk/sig = 3,232,320  446,992  275  spec 3,232,320  446,992  275  match True

=== Blow-up factors (the post's 4.3 KB read as 4,300 bytes)
signature or proof: RSA-2048 256 B -> PoMFRIT with UOV 4,300 B = 16.8x
whole token if the 256-byte authenticator became that proof: 354 B -> 4,398 B = 12.4x
signature: ECDSA P-256 64 B -> ML-DSA-44 2,420 B = 37.8x
public key: RSA-2048 294 B -> uov-Ip compact 46,591 B = 158.5x
public key: ECDSA P-256 65 B -> ML-DSA-44 1,312 B = 20.2x

The post says “replacing RSA blind signatures with PoMFRIT has comparable overhead to replacing ECDSA with ML-DSA.” By bytes per presentation that holds, and favorably. The 256-byte RSA-2048 signature becomes a 4,300-byte proof, a factor of 16.8, and a 354-byte Privacy Pass token whose authenticator was swapped for that proof would be about 4,398 bytes, 12.4 times larger, while ECDSA P-256 to ML-DSA-44 multiplies the signature by 37.8. By key size it does not hold: the 294-byte RSA key (DER-encoded) becomes a 46,591-byte one, 158.5 times larger, where ML-DSA-44 multiplies the 65-byte ECDSA point by 20.2. Other encodings move the factors but not the conclusion. The post is open about the trade, calling larger public keys a cost that “pays off in applications where a public key can be cached and used sufficiently often.”

The anchor set: the number the table never states

Endorsements are redeemed against an anchor set, and the table does not say how large a set its 6.5 KB assumes. That number matters twice. For privacy, the architecture draft says anchor-hiding depends on it: “A Moderator with only one Anchor in its Anchor Set provides no Anchor-hiding,” and the benefit “relies on the Moderator’s Anchor set being large and not being partitioned on something specific to the client.” The protocols draft adds that “the Client must be able to verify the number of Anchors in an accepted set, and that these Anchors are real rather than fabricated by the Moderator.” For cost, the draft’s classical IHAT has the client prove, “with a 1-of-n OR proof, that its Endorsement verifies under one of the Anchor keys the Moderator accepts,” and the moderator’s challenge carries the accepted keys as 33-byte P-256 points. Thirty-three bytes per anchor is nothing. Forty-six thousand bytes per anchor is a different problem.

# anchors.py
"""Bytes of Anchor keys in a Moderator's accepted set.
The MoLE protocols draft (-00, July 2026) sends `Point keys<V>` in the IHAT challenge, one 33-byte
compressed P-256 point per Anchor. The UOV Round 3 sizes are from Table 1 of the specification."""
P256_POINT = 33
UOV_IP_COMPACT = 46_591
UOV_IP_EXPANDED = 321_300

print(f"{'anchors':>8} {'P-256 points':>14} {'uov-Ip compact':>16} {'uov-Ip expanded':>17}")
for n in (2, 5, 10, 25, 100):
    print(f"{n:>8} {n * P256_POINT:>12,} B {n * UOV_IP_COMPACT:>14,} B {n * UOV_IP_EXPANDED:>15,} B")
print(f"one uov-Ip compact key is {UOV_IP_COMPACT / P256_POINT:,.0f} times one compressed P-256 point")
 anchors   P-256 points   uov-Ip compact   uov-Ip expanded
       2           66 B         93,182 B         642,600 B
       5          165 B        232,955 B       1,606,500 B
      10          330 B        465,910 B       3,213,000 B
      25          825 B      1,164,775 B       8,032,500 B
     100        3,300 B      4,659,100 B      32,130,000 B
one uov-Ip compact key is 1,412 times one compressed P-256 point

The post says a hidden index costs little: the anchor key’s index becomes “now part of the hidden witness,” and “With some care, we can write this as a set of constraints with roughly the same cost: two hashes and one MQ evaluation.” That can hold for the proof size, and the 6.5 KB figure may be right. But a circuit that must not reveal which key was used has to include every key in the set, so I would expect prover and verifier work to grow at least linearly with the number of anchors, at 321,300 bytes per expanded key. The post does not discuss it, and I could not test it, because there is no implementation to run. The post’s remark that a key “can be cached” also leaves a question for rotation. The protocols draft expects clients to fetch a configuration listing the accepted set “before running any flow,” and notes that “A Moderator that shows different configurations to different Clients can partition them.”

What the draft’s HTTP carriage does to those sizes

The MoLE HTTP transport draft (draft-jms-mole-http-transport-00) puts the messages in headers. The protocols draft says “Redeem & Issue carries a CredentialRequest in the Authorization header and receives the CredentialResponse in the Mole-Credential response header,” the transport draft sends the moderator’s challenge in a WWW-Authenticate: Mole header, and “All base64url values in this document are encoded without padding.” A client redeeming an endorsement therefore sends the redemption and the credential issuance request in one Authorization: Mole credential-request="..." line, and a presentation travels in Authorization: Mole presentation="...".

Servers cap the size of one header field. nginx’s documentation says “A request header field cannot exceed the size of one buffer as well, or the 400 (Bad Request) error is returned to the client,” sets the default buffer at 8K bytes, and adds that “The limits are similarly applied in HTTP/2 and HTTP/3 connections.” Apache’s LimitRequestFieldSize defaults to 8190. The script below turns the post’s sizes into base64url header lines and compares them with those two defaults.

# header_budget.py
"""How the post's sizes fit the HTTP carriage in draft-jms-mole-http-transport-00.
The draft puts a redemption or presentation in an Authorization header and a Moderator's challenge
in a WWW-Authenticate header, base64url without padding. The limits are documented defaults:
nginx large_client_header_buffers 4 8k (one header field per 8 KiB buffer) and Apache
LimitRequestFieldSize 8190."""
NGINX, APACHE = 8192, 8190


def b64url(n: int) -> int:
    """Length of unpadded base64url for n bytes."""
    return -(-4 * n // 3)


# (label, bytes, header parameter that carries it)
ROWS = [
    ("Privacy Pass token today (RFC 9578, type 2)", 354, "presentation"),
    ("PoMFRIT with UOV redemption (post)", 4300, "credential-request"),
    ("VOLE-IHAT redemption (post)", 6500, "credential-request"),
    ("VOLE-ACT spend (post)", 12000, "presentation"),
    ("100 P-256 keys in an IHAT challenge (draft)", 100 * 33, "challenge"),
    ("one uov-Ip compact key in a challenge", 46591, "challenge"),
]
print(f"{'message':45s} {'bytes':>7} {'base64url':>9} {'field line':>10}  nginx  Apache")
for label, n, param in ROWS:
    name = "WWW-Authenticate: Mole" if param == "challenge" else "Authorization: Mole"
    line = len(f'{name} {param}=""') + b64url(n)
    print(f"{label:45s} {n:>7,} {b64url(n):>9,} {line:>10,}  {'fits ' if line <= NGINX else 'FAILS'}  {'fits' if line <= APACHE else 'FAILS'}")
fixed = len('Authorization: Mole presentation=""')
print(f"\nabout {(NGINX - fixed) * 3 // 4:,} raw bytes fit one 8 KiB field line; about {(APACHE - fixed) * 3 // 4:,} fit Apache's 8,190")
message                                         bytes base64url field line  nginx  Apache
Privacy Pass token today (RFC 9578, type 2)       354       472        507  fits   fits
PoMFRIT with UOV redemption (post)              4,300     5,734      5,775  fits   fits
VOLE-IHAT redemption (post)                     6,500     8,667      8,708  FAILS  FAILS
VOLE-ACT spend (post)                          12,000    16,000     16,035  FAILS  FAILS
100 P-256 keys in an IHAT challenge (draft)     3,300     4,400      4,435  fits   fits
one uov-Ip compact key in a challenge          46,591    62,122     62,157  FAILS  FAILS

about 6,117 raw bytes fit one 8 KiB field line; about 6,116 fit Apache's 8,190

The 4.3 KB row fits. The 6.5 KB redemption becomes 8,667 characters before the rest of the request header is counted, and the 12.0 KB spend becomes 16,000, so both fail at the defaults. A single uov-Ip key would be 62,122 characters, so a post-quantum version of the draft’s key-list challenge cannot be a list of keys. It would have to refer to keys by hash and have clients fetch them. These are defaults and they can be raised, and a draft at revision 00 can change its carriage. But the post’s own plan has to answer the question: “whereas classical blind signatures might be cheap enough to redeem on every request, we’ll have to figure out how to amortize a single redemption over multiple requests.” At 6.5 and 12.0 KB, deploying means either raising header limits across the stack or changing where the bytes go.

The RSA forgery sentence

While explaining why the one-more assumption may be optimistic, the post says: “Recently, researchers at the University of California, San Diego, and INRIA managed to forge a 1024-bit RSA signature using a Privacy Pass issuer as a signing oracle. Fortunately, Privacy Pass uses 2048-bit RSA, which, in concert with other defense mechanisms, provides a pretty good security margin.” The link goes to ePrint 2026/2131, “Forging 1024-bit RSA signatures in nearly SNFS time,” by Laura Shea, Miro Haller, Adam Suhl, Nadia Heninger and Emmanuel Thomé (received September 20). Its abstract says: “We carried out our attack using a hardware security module (HSM) as the signing oracle,” and then, “Blind RSA schemes also provide such a signing oracle.” It does not name Privacy Pass. So the experiment ran against an HSM, and applying it to a Privacy Pass issuer is the paper’s extrapolation, not something the abstract reports. I could not read the PDF (ePrint serves it behind a bot challenge, and the Wayback Machine blocked this machine), so I cannot say what the body of the paper does with Privacy Pass.

The distinction does not undo the post’s argument, because an unlimited signing oracle is what a blind issuer offers, and the next script shows why. It runs the RSABSSA-SHA384-PSS-Deterministic variant of RFC 9474, which RFC 9578 uses for token type 2, against a local issuer. A stock RSA-PSS verifier accepts all 100 tokens, so the code follows the RFC. The issuer’s step is the bare private-key operation; RFC 9474 itself says “BlindSign is functionally a remote procedure call for applying the RSA private key operation.” The second test hands the issuer integers that no padding scheme would produce, and it signs each one. Nothing here touches a real issuer.

# rsabssa_oracle.py
"""RFC 9474 RSABSSA-SHA384-PSS-Deterministic issuance with a raw-RSA issuer step.
Python 3.13, cryptography 50. The issuer sees one integer and applies the bare private-key
operation, which is what RFC 9474 calls BlindSign. Nothing here touches a real issuer."""
import hashlib
import math
import os
import secrets
import time

from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives import serialization as ser
from cryptography.hazmat.primitives.asymmetric import padding, rsa

H, HLEN, SLEN = hashlib.sha384, 48, 48


def mgf1(seed: bytes, length: int) -> bytes:
    out, counter = b"", 0
    while len(out) < length:
        out += H(seed + counter.to_bytes(4, "big")).digest()
        counter += 1
    return out[:length]


def emsa_pss_encode(msg: bytes, em_bits: int) -> bytes:
    """RFC 8017 section 9.1.1 with SHA-384, MGF1 and a 48-byte salt."""
    em_len = (em_bits + 7) // 8
    salt = os.urandom(SLEN)
    h = H(b"\x00" * 8 + H(msg).digest() + salt).digest()
    db = b"\x00" * (em_len - SLEN - HLEN - 2) + b"\x01" + salt
    masked = bytearray(a ^ b for a, b in zip(db, mgf1(h, em_len - HLEN - 1)))
    masked[0] &= 0xFF >> (8 * em_len - em_bits)
    return bytes(masked) + h + b"\xbc"


class Issuer:
    def __init__(self):
        self.key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
        nums = self.key.private_numbers()
        self.n, self.e, self.d = nums.public_numbers.n, nums.public_numbers.e, nums.d
        self.k_len = (self.n.bit_length() + 7) // 8

    def blind_sign(self, blinded: bytes) -> bytes:
        """RSASP1: no padding and no check on the input beyond its range. That is all the issuer can do."""
        z = int.from_bytes(blinded, "big")
        if z >= self.n:
            raise ValueError("input out of range")
        s = pow(z, self.d, self.n)
        assert pow(s, self.e, self.n) == z
        return s.to_bytes(self.k_len, "big")


def blind(n: int, e: int, k_len: int, msg: bytes):
    m = int.from_bytes(emsa_pss_encode(msg, n.bit_length() - 1), "big")
    assert math.gcd(m, n) == 1
    while True:
        r = secrets.randbelow(n - 2) + 2
        if math.gcd(r, n) == 1:
            break
    return (m * pow(r, e, n) % n).to_bytes(k_len, "big"), pow(r, -1, n)


def finalize(n: int, k_len: int, blind_sig: bytes, r_inv: int) -> bytes:
    return (int.from_bytes(blind_sig, "big") * r_inv % n).to_bytes(k_len, "big")


issuer = Issuer()
pk = rsa.RSAPublicNumbers(issuer.e, issuer.n).public_key()
spki = pk.public_bytes(ser.Encoding.DER, ser.PublicFormat.SubjectPublicKeyInfo)
# RFC 9578 hashes an SPKI that carries the RSASSA-PSS OID; a plain SPKI changes the value, not the 32-byte length
token_key_id = hashlib.sha256(spki).digest()
PSS = padding.PSS(padding.MGF1(hashes.SHA384()), SLEN)

print("=== 1. Issue 100 Privacy Pass style tokens, verify each with a stock RSA-PSS verifier")
for _ in range(100):
    token_input = (2).to_bytes(2, "big") + os.urandom(32) + hashlib.sha256(os.urandom(16)).digest() + token_key_id
    blinded, r_inv = blind(issuer.n, issuer.e, issuer.k_len, token_input)
    auth = finalize(issuer.n, issuer.k_len, issuer.blind_sign(blinded), r_inv)
    pk.verify(auth, token_input, PSS, hashes.SHA384())
print(f"100 of 100 verified | token_input {len(token_input)} B + authenticator {len(auth)} B = token {len(token_input) + len(auth)} B")

print("\n=== 2. The issuer signs any integer below the modulus")
for label, x in [("the integer 2", 2), ("the integer n - 1", issuer.n - 1),
                 ("255 random bytes", int.from_bytes(os.urandom(255), "big"))]:
    s = issuer.blind_sign(x.to_bytes(issuer.k_len, "big"))
    print(f"{label:18s} signed: s^e mod n == x is {pow(int.from_bytes(s, 'big'), issuer.e, issuer.n) == x}")
try:
    pk.verify(s, b"any message", PSS, hashes.SHA384())
except InvalidSignature:
    print("a stock RSA-PSS verifier rejects that raw signature: InvalidSignature")

print("\n=== 3. What 2^43 issuer requests cost the issuer (OpenSSL private-key operations on one core)")
runs = 400
t0 = time.perf_counter()
for _ in range(runs):
    issuer.key.sign(b"x", padding.PKCS1v15(), hashes.SHA256())
rate = runs / (time.perf_counter() - t0)
print(f"{rate:,.0f} private-key operations a second -> 2^43 requests = {2 ** 43 / rate / (365.25 * 86400):,.0f} core-years")
=== 1. Issue 100 Privacy Pass style tokens, verify each with a stock RSA-PSS verifier
100 of 100 verified | token_input 98 B + authenticator 256 B = token 354 B

=== 2. The issuer signs any integer below the modulus
the integer 2      signed: s^e mod n == x is True
the integer n - 1  signed: s^e mod n == x is True
255 random bytes   signed: s^e mod n == x is True
a stock RSA-PSS verifier rejects that raw signature: InvalidSignature

=== 3. What 2^43 issuer requests cost the issuer (OpenSSL private-key operations on one core)
2,295 private-key operations a second -> 2^43 requests = 121 core-years

The raw signatures are not valid RSA-PSS signatures on anything, and the script attacks nothing. It shows only the property the paper builds on: the issuer cannot tell a blinded request from any other integer, so it cannot decline. Padding protects ordinary RSA signing because the signer sees the message, and a blind issuer does not. The preprint’s figures for such a system, as TechSpot reports them, are about 2^43 token requests, and an attack cost of 2^90 operations for 2,048-bit keys against 2^65 for the 1,024-bit key that was broken. TechSpot also quotes Heninger saying the request count is “on the same order of magnitude of the network traffic that Cloudflare has said publicly it handles in about a day.” On this machine one core does 2,295 private-key operations a second, so 2^43 requests are about 121 core-years of issuer work. That is a volume a large service can absorb, which fits Heninger’s remark, and it is why the attack cost and key rotation do the protecting. The paper is a preprint, and TechSpot relays Karsten Nohl’s caveat that the result is a breakthrough only “If this result holds up under peer review.”

One more precision on the same paragraph of the post, which says Privacy Pass “is unconditionally private.” RFC 9474 says Lysyanskaya proved blindness for its randomized variants, and it attaches a condition to the others: “a malicious signer can construct an invalid public key and use it to learn information about low-entropy input messages.” Privacy Pass meets the condition, because every type 2 token request hashes in a 32-byte random nonce (the lab’s token input is 98 bytes: type, nonce, challenge digest and key identifier). The post’s sentence is fair, but it is a statement about message entropy and client key handling as well as mathematics.

Scorecard

Claim in the post What I found Status
PoMFRIT: 6.7 KB blind signature, a few hundred bytes of issuance Abstract and Table 1: 6.7 KB and 0.45 KB, for the One-More-MAYO row Matches, with the assumption caveat
UOV Round 3: 135-byte signature, 46.6 KB public key Table 1 of the specification: 135 and 46,591 bytes, rebuilt from n, m and q Matches
A FAEST proof for AES is “about 4KB (NIST Round 3, 128s)” Table 3.2: FAEST-128s is 4,066 bytes Matches
4.3 KB by swapping UOV into PoMFRIT A witness of signature, salt and a 360-bit mask gives 4,291 bytes in the size formula, with no room for a hash Plausible; depends on the one-more assumption
An extra witness bit “adds ten bits” The formula charges tau bits, 11 for the small sets (10 corrections plus 1) Close
“Comparable overhead” to ECDSA to ML-DSA Presentation 16.8 times against 37.8; key 158.5 times against 20.2 True for bytes per presentation, not for keys
Forgery used “a Privacy Pass issuer as a signing oracle” The abstract says an HSM was the oracle and blind RSA “also” provides one Does not match the abstract
“A few milliseconds” per operation No code or timings published; the paper’s closest row lists 42 ms of showing time Not checked
KuMQuat signatures “as small as 2.6KB” Not rechecked Not checked

Questions to put to Cloudflare before relying on the table

  1. Which rows use the hash-free design and the one-more-UOV assumption, and which keep hashes in the proof?
  2. What anchor-set size does the 6.5 KB redemption assume, and how do proof size, prover time and verifier time grow with it?
  3. How are 46,591-byte anchor keys distributed, referenced and kept consistent, given that the draft’s IHAT challenge carries keys inline?
  4. How would 6.5 and 12.0 KB messages travel over HTTP, given the draft’s Authorization-header carriage?
  5. Which parameter set and hardware give “a few milliseconds”? The PoMFRIT paper’s closest row lists 42 and 40 ms of showing time on its own benchmark machine.
  6. Which sentence of the RSA-forgery paper describes a Privacy Pass issuer as the oracle?

Sources and method

Read in full or in the parts cited: the Cloudflare post; the PoMFRIT paper (Tables 1 and 3, and the sections on hash evaluation and masking); the UOV Round 3 specification (Table 1); the FAEST v3 specification (Section 3.1 and Tables 3.2 and 9.3); the MoLE drafts architecture, protocols and HTTP transport; RFC 9474 and RFC 9578; the abstracts of ePrint 2026/2131 and 2026/1690; TechSpot’s coverage of the forgery paper; and the nginx and Apache header-size documentation.

The five scripts ran on Python 3.13.14 with cryptography 50.0.1 on Windows 11, and every number above that is not marked as the post’s own comes from them or from a document listed here. Only the private-key rate in the last script depends on the machine. The witness guess behind the 4,291-byte figure, the count of committed hash states and the expectation that verifier work grows with the anchor set are my arithmetic and inference, not statements from Cloudflare or the drafts.

What I did not check: the full text of the RSA-forgery paper, which I could not open; the PoMFRIT paper’s lattice comparison beyond its Table 1; the lattice preprint beyond its abstract; the KuMQuat figure; Cloudflare’s timings, which have no public code or table; and anything about VOLE-IHAT and VOLE-ACT beyond the post’s description, since neither has a paper or a draft type yet.

Earlier on sxz.io: Cloudflare’s post-quantum GeoKey Manager, NIST’s final transition report, a Merkle Tree Certificate tutorial and Microsoft’s Magnetar zkVM, which covers another proof system whose headline numbers needed a second look.

Tags:

Anonymous CredentialsBlind SignaturesCloudflareIETFPost-Quantum CryptographyPrivacy PassZero-Knowledge Proofs

Share

Three pull-out drawers of a white flat-file storage cabinet standing open to show archived prints
Previous Post

DigiCert’s Incident Report Dates Every Confirmed Read of Its Customer Archive After the Day It Verified Outsiders Could Create Accounts, and It First Suspected a Data Read 102 Days After the Fix

A black rotary telephone resting on a lace cloth beside an open 1946 Svendborg telephone directory
Next Post

How to Build a DNS Resolver in Python From Scratch, Walk From the Root Servers to an Answer, and Reject Forged Replies and Planted Records

No Comment! Be the first one.

Leave a Reply Cancel reply

Your email address will not be published. Required fields are marked *

Latest
08 Nov
How to Fix Python ModuleNotFoundError and Relative Import Errors, Break Circular Imports, and Stop Local Files From Shadowing the Standard Library
08 Nov
WIRED’s “Free Chinese AI Models Are Getting Scary Good at Hacking” Rests on Two Cyber Ranges: DeepSeek Is Level With the Leader on One, the GLM It Names Finished Last of Seven, and Three Claude Runs Stopped by Refusals Appear Only in the Chart Data
Trending
November 8, 2026
How to Fix Python ModuleNotFoundError and Relative Import Errors, Break Circular Imports, and Stop Local Files From Shadowing the Standard Library
November 8, 2026
WIRED’s “Free Chinese AI Models Are Getting Scary Good at Hacking” Rests on Two Cyber Ranges: DeepSeek Is Level With the Leader on One, the GLM It Names Finished Last of Seven, and Three Claude Runs Stopped by Refusals Appear Only in the Chart Data
November 8, 2026
Crédit Mutuel Arkéa Says Attackers Reached Its Own Account Through a Service Provider’s Platform; the On-Chain Trail Attributed to the Theft Shows $126 Million in USDC Reaching 17 Wallets in 28 Hours, Most of It Moving On Within Minutes
November 8, 2026
How to Stop Command Injection in Python subprocess Calls, Including Git Option Injection and the Windows .cmd Trap
November 8, 2026
Cloudflare’s Trusted Agent Delegation Essay Names 14 Site Problems, but the Identity Standard It Prefers Defines No Delegation, and Its First Shipped Fix Sees Agents Only at Login and Sign-Up
November 8, 2026
CloudLinux Says an Exploit Hit a CloudLinux 8 Server on November 4 Through Linux Kernel Bug CVE-2026-98276, Fixed Upstream Since September, and Red Hat Still Lists Every Supported RHEL Kernel as Affected

Related Posts

Blue-lit server racks in a modern data center, illustrating the compute infrastructure behind the AI boom.
Articles

The AI Boom Is Spending Real Money Before Proving Real Returns

June 7, 2026
Technician working with a laptop beside server racks, representing enterprise AI retrieval infrastructure
Articles

Google’s Agentic RAG Push Makes Enterprise AI Less of a One-Shot Guess

June 7, 2026
A person with a laptop and smartphone, representing digital attention and AI-assisted work
Articles

AI Chatbots Are Making Attention a Design Problem

June 7, 2026
A customer-support representative wearing a headset against a dark studio background.
Articles

The Meta AI Support Hack Was a Plain Old Authorization Failure

June 7, 2026
SXZ.io SXZ.io
  • [email protected]

Categories

Articles
Learning Hub
News

All Rights Reserved by SXZ.io ©2026