Browse OpenSSL punycode specs
Proofs / OpenSSL punycode
OpenSSL's punycode decoder
- specs
- 19
- OpenSSL 3.6.5
- c8bd5a57
- lines of Rocq
- ~36,000
- qp verify
- PASS
On this page
We proved 19 C specs of crypto/punycode.c in OpenSSL 3.6.5: the functions ossl_punycode_decode and
ossl_a2ulabel, the code of CVE-2022-3602 and CVE-2022-3786. Each spec states a property, and each has a
theorem in Rocq (formerly Coq). Sixteen are short specs; most of them use no model and compare OpenSSL with itself, with the RFC's own
examples, or with simple facts about the input. Three compare both functions with a reference model written from
the RFCs. AI agents wrote the proof, about 36,000 lines of Rocq, and Rocq checks it. The specs, the proof and every
change to the code are in
genproof/openssl-punycode-auto-proofs-public.
The code
Domain names with non-ASCII letters travel in ASCII: each such label is written as xn-- followed by its Punycode
form, so "bücher.example" becomes "xn--bcher-kva.example". RFC 3492 defines Punycode.
ossl_punycode_decode(in, len, out, &n)decodes one Punycode string into an array ofncode points. It returns 1 and setsnto the number of code points, or 0.ossl_a2ulabel(name, out, outlen)converts a whole host name: every label that starts withxn--is decoded and written in UTF-8, every other label is copied, and a NUL ends the output. It returns 1, 0 (the output buffer is too small) or -1. OpenSSL uses it when it checks name constraints on internationalized e-mail addresses.
In CVE-2022-3602 ossl_punycode_decode wrote one code point past the end of its array, and in CVE-2022-3786
ossl_a2ulabel overflowed its output with the . between labels.
The specs
Each spec links to its page, which shows the spec whole, as it is in the repository, with its theorem. Thirteen
short specs are 77 to 98 lines with the 52-line shared header harness/laws_common.h, and three are 124 to 143
lines: the RFC's samples, a UTF-8 writer and an RFC 3492 encoder. In a spec, nondet_*, upto(max),
any_bytes(n) and any_uints(n) stand for any value. Every buffer has exactly the size the spec gives it, so a
single byte read or written past its end would break the theorem. The first comment of each file states the
property in full; it starts with Law, the older word for a spec.
Any input
Safe on any input, with only the documented results. 3 specs
Any bytes of any length and any capacity:
ossl_punycode_decodereturns 1 (with a length at most the capacity) or 0 (length unchanged), as its documentation says, and leaves the input as it was.Input with a byte that RFC 3492 6.2 says to fail on is refused (0, length unchanged): a non-ASCII byte before the last
-, or a byte after it that is neither a letter nor a digit.Any string and any output size, NULL output included:
ossl_a2ulabelreturns 1, 0 or -1 (as documented; -1 for a NULL or empty output), writes only inside its buffer (CVE-2022-3786 overflowed it with the.between labels), leaves the string as it was and frees what it allocates.
The standard
Decoding is RFC 3492's, and labels are converted as IDNA asks. 6 specs
The 19 sample strings of RFC 3492 7.1 decode to exactly the code points the RFC lists.
If any byte before the last
-is 0x80 or above, decoding fails. On success, the code points below 0x80 are exactly the bytes before the last-, in order (RFC 3492 3.1, 6.2).Changing the case of any letters among the deltas changes nothing: the same result, length and code points (RFC 3492 5: "A decoder MUST recognize the letters in both uppercase and lowercase forms").
Two inputs that both decode (1) to the same code points, into arrays of the same capacity, are the same bytes up to the case of the delta letters (RFC 3492 6.2: "only one encoded string ... can represent a given sequence of integers").
ossl_a2ulabelconverts a name that starts withxn--,Xn--,xN--orXN--the same way: the same result and the same buffer (RFC 3490 5: the ACE prefix is "xn--" or any capitalization thereof), when the allocation fails in neither call or in both.For s without
.or NUL and an output of at least one byte:xn--+ s becomes exactly the UTF-8 form (RFC 3629) of the code pointsossl_punycode_decodegives for s into 512 elements, and a NUL. The result is -1 when that decoding fails, when it gives a code point above U+10FFFF, or when the allocation fails; it is 0 when the output does not fit.
The interface
Capacities, lengths, sizes and the NUL are exactly as documented. 6 specs
Nothing is written past the reported length, the reported length is exactly the capacity needed, and one element fewer is refused (CVE-2022-3602 was a write one element past the end).
The result depends only on the bytes given: the same bytes elsewhere, followed by anything, decode the same way into two arrays of the same capacity.
With an output of at least one byte, a name with no label starting with
xn--(in any case) is copied as it is, with its NUL, when it fits ("If it does not start with xn--, it becomes U-label as is",crypto/punycode.c); else the result is 0. When the allocation fails, the result is -1.On success (1) the output holds a NUL, and nothing after the first NUL was touched ("The output buffer is NUL-terminated").
For a name that converts (1) into some buffer: any buffer of at least that output's size gives 1 and the same output, and any smaller buffer of at least one byte gives 0 (when the allocation fails in no call).
Labels are converted one by one. When s1 and s2 each convert (1), the name s1
.s2 converts, into a buffer as large as their two buffers together, to the output of s1, a., and the output of s2. When either gives -1, so does the name (when the allocation fails in no call).
Round trip
What an RFC 3492 encoder writes decodes back. 1 spec
Any number of any 32-bit code points, encoded by an RFC 3492 encoder written in the spec, when the encoder succeeds and the encoding is shorter than 2^32 - 1 bytes (OpenSSL's input limit): OpenSSL decodes them back whenever (largest code point - 127) × count ≤ 2^32. Beyond that bound it decodes them back or refuses them (0), never to other code points.
Earlier, longer properties
These three specs, from our first proof of this code, compare both functions with a reference model written from
the RFCs and OpenSSL's documentation (harness/puny_ref.h).
ossl_punycode_decodeon any input and capacity gives exactly what the model's RFC 3492 decoder gives: the result, the length and the whole array.ossl_a2ulabelon any string and output size, out NULL or not, the allocation failing or not, gives exactly what the model's conversion gives: the result and the whole buffer.Up to 512 Unicode code points whose encoding by the model fits in 16 × count + 1 bytes:
ossl_punycode_decodegives them back, and when no code point is.or NUL,ossl_a2ulabelgives their UTF-8 form.
Limits
Each theorem says that for every value the spec draws, no run has undefined behaviour in CompCert's semantics of C and every check holds, so it also rules out any read or write outside an object. That reading rests on VST's adequacy theorem, which is not yet instantiated in Rocq for QuickProof's environment.
- Termination is not proved. A theorem says what holds if a call returns.
- The theorems are about the C code as CompCert defines it on x86_64 (LP64), in a release build (
-DNDEBUG), compiled against a small header shim, in one thread. A binary built by GCC or Clang is outside them. Running out of stack is not covered. mallocmay fail at any call. Each spec then either states the result (-1) or checks nothing more for that run.- Only
crypto/punycode.c, the ten functions ofcrypto/packet.cit reaches and the shim unit are verified. The certificate code that callsossl_a2ulabelis not. - IDNA validity is not checked: any
xn--label is decoded. - When the output does not fit, the buffer of
ossl_a2ulabelneed not hold a prefix of the result and may have no NUL. Its only caller treats 0 as a failure. rt_roundtripis bounded by a property of RFC 3492 itself. It states the round trip for every sequence of up to 3855 Unicode code points; beyond that, OpenSSL gives the code points back or refuses the string, never other code points.- The theorems are about the code with the edits listed below. Nothing is proved about the unmodified file.
- Each theorem rests on 14 axioms: eight standard axioms of Rocq and six declarations of VST's
malloclibrary. The result also trusts Rocq, VST, CompCert's semantics of C,clightgen, QuickProof's translation scripts andqp verify(What a proof means).
How to check it
You need 64-bit x86 Linux with Coq 8.20.1, CompCert 3.15 and VST 2.15 (Install), or QuickProof's Docker image. Check the proof with a QuickProof checkout of your own at v0.1.0, the release the repository pins:
git clone --branch v0.1.0 https://github.com/genproof/quickproof-public.git quickproof
git clone --recursive https://github.com/genproof/openssl-punycode-auto-proofs-public.git
quickproof/tools/qp verify openssl-punycode-auto-proofs-public --base contractThe tag contract holds the specs, the code and the unproved theorems. qp verify rebuilds every proof, checks
that each statement is the one of that tag and that nothing is admitted, and lists the axioms each theorem uses.
A run takes about 17 minutes with 8 build jobs on 16 CPUs.
The code under test
The code is OpenSSL 3.6.5 (commit c8bd5a571085, tag openssl-3.6.5). crypto/punycode.c is identical in
openssl-4.0.3 and in master of 2026-10-12. The repository's patch/PATCH.md lists every edit, and each changed
line keeps the original in a comment. Two edits are for VST: two goto end become break, and a memmove over an
unsigned int array becomes an element-by-element loop. A differential test checks that they change nothing. The
third, D1, fixes a bug: ossl_a2ulabel decodes a label only when its ACE prefix is xn-- in lowercase, although
RFC 3490 and RFC 5890 make the prefix case-insensitive. We reported it to the OpenSSL security team, which does
not consider it a security issue;
rfc_ace_any_case and x_a2ulabel need the fix. The edits are tested, not proved.
Maintained by Vladimir Kukharenko.