Skip to content

About

lowlevel set of cryptographic primitives for haskell

Resources

Stars

51 stars

Watchers

1 watching

Forks

 
 

Repository files navigation

GitHub Actions status

crypton

crypton is a fork from cryptonite with the original author's permission.

crypton is a low-level cryptography library. To achieve high performance, it utilizes C and assembly language to define FFI bindings, structuring them in a way that makes them easy to use.

Side channels

AES

AES is where this matters most, and which implementation runs is decided at runtime from what the processor has.

On x86-64 with AES-NI and carry-less multiply, on AArch64 and on 32-bit ARM with the ARMv8 cryptographic extension, and on ppc64le with the vector instructions PowerISA 2.07 brought to POWER8, AES and GHASH are instructions rather than software. crypton's AES and AES-GCM then make no branch and no memory access that depends on the key or on the data: the secrets stay in vector registers and never reach one a branch can test. Every x86-64 part since about 2010 and every AArch64 part in ordinary use has these, and the ppc64le path is little-endian only.

Where none of them is there, crypton computes AES and GHASH without a table. The portable AES is bitsliced -- the S-box is boolean algebra over four blocks held across eight 64-bit words -- and the GF(2^128) multiply is built from shifts, masks and integer multiplies. Neither looks anything up, so neither derives an address from a secret, and the portable path is constant time as well. The code is BearSSL's, under MIT, in cbits/bearssl.

The constant-time harness in cbits/tests/ct runs the same driver against the portable implementation and against each architecture's instructions, and every one of them is required to report nothing at all.

So the answer does not depend on the machine any more, and what is left to ask is whether AES is fast on it. hasAESAcceleration and hasGHASHAcceleration answer that without naming an architecture. processorOptions says what was found, in the processor's own names -- AESNI and PCLMUL on x86, ARMAES and ARMPMULL on AArch64 and 32-bit ARM, PPCAES and PPCVPMSUM on ppc64le -- along with everything else it was asked about, which is unrelated to this.

ghci> import Crypto.System.CPU
ghci> hasAESAcceleration
True
ghci> processorOptions           -- on an x86-64 machine
[AESNI,PCLMUL,SSSE3,AVX,AVX2,SHANI,MOVBE,ADX,VAES]
ghci> processorOptions           -- and on an AArch64 one
[NEON,ARMAES,ARMPMULL,ARMSHA1,ARMSHA2,ARMSHA512]

RSA

RSA is the other place to know about, and there the choice is the caller's. The private key operations in Crypto.PubKey.RSA.PKCS15, .OAEP and .PSS take a Maybe Blinder, and Nothing is no harder to write than the safe form:

decrypt     :: Maybe Blinder -> PrivateKey -> ByteString -> ...
decryptSafer :: MonadRandom m => PrivateKey -> ByteString -> m ...

The exponent itself is not what is at risk. expSafe keeps the value of an exponent out of the work it does, so the private exponent does not leak through the exponentiation. What a blinder covers is the other side: without one, the operation runs on the ciphertext the caller was handed, so how long it takes depends on a number an attacker may have chosen and can vary. That is what a remote timing attack on RSA needs. With a blinder the input is multiplied by a random value first and the result divided out afterwards, so the timing carries nothing an attacker can steer.

decryptSafer and signSafer generate the blinder themselves and are the ones to reach for. Pass Nothing only where the input is not attacker controlled and you have decided that it is not.

The RSA rows in the tables below are the unblinded path. A fresh blinder costs one more exponentiation, by the public exponent, which is the cheap direction: measured on the M4, signing goes from about 316 to about 335 microseconds, about six per cent. signSafer and decryptSafer pay less than that from 2.2.4: they keep the blinder of a key they have seen and square it for the next use, as OpenSSL does, drawing a fresh one every 32 uses. Crypto.PubKey.RSA.Blinded gives a caller the same thing to hold, a BlindedKey passed along from one operation to the next.

From 2.2.5 every private-key operation also raises its result back to the public exponent before letting it out, as OpenSSL does. A fault in one half of a CRT computation would otherwise give an answer that reveals a prime of the key; that answer is now computed again without CRT instead. It costs a few per cent of a signature, and the tables below include it.

Performance

The algorithms a TLS connection uses, measured against the last release before the rewrite and against OpenSSL on the same machine. Throughput is over 16 KiB messages; the public key operations are one operation each; every figure is the best of several runs, and crypton and OpenSSL are run alternately so that neither gets the quieter machine.

Bulk encryption and hashing are measured through crypton's C layer, as openssl speed measures OpenSSL's. The public key operations are measured through crypton's Haskell API, since that is where ECDSA and RSA live and it is what a program actually calls; the Haskell layer adds well under a microsecond, which the X25519 and ECDH P-256 rows confirm by agreeing with a C-level measurement to within a percent. Both releases of crypton are built the same way -- -optc-O3, which is what each asks for -- and by cabal build, since a copy of the sources compiled by hand does not measure what a program linking the library gets, and leaves out whole implementations without saying so. Each column of a table comes from one run on the machine named above it.

x86-64

An AMD EPYC 7763, which has AES-NI, PCLMULQDQ, AVX2, ADX, VAES, VPCLMULQDQ and the SHA extensions, against OpenSSL 4.0.3.

Throughput in MB/s, higher is better:

crypton 1.1.5 crypton 2.2.5 OpenSSL 2.2.5 / OpenSSL
AES-128-GCM 1335 6044 4046 1.49
AES-256-GCM 1093 5494 3764 1.46
ChaCha20-Poly1305 399 2197 2212 0.99
SHA-1 729 1663 1673 0.99
SHA-256 289 1585 1571 1.01
SHA-512 450 770 749 1.03
SHA3-256 109 421 425 0.99

Time per operation in microseconds, lower is better:

crypton 1.1.5 crypton 2.2.5 OpenSSL OpenSSL / 2.2.5
X25519 44.31 28.56 36.51 1.28
ECDH P-256 164.3 50.63 51.53 1.02
ECDH P-384 2241 164.4 834.7 5.08
Ed25519 sign 29.33 18.57 33.68 1.81
Ed25519 verify 48.48 47.59 110.7 2.33
ECDSA P-256 sign 81.70 18.08 21.88 1.21
ECDSA P-256 verify 231.8 69.63 67.60 0.97
ECDSA P-384 sign 2273 303.9 877.6 2.89
ECDSA P-384 verify 2673 474.3 722.8 1.52
RSA-2048 sign/decrypt 759.5 633.9 659.4 1.04
RSA-2048 verify/encrypt 33.24 22.70 18.80 0.83
RSA-3072 sign/decrypt 2500 2232 2010 0.90
RSA-3072 verify/encrypt 65.79 61.73 40.09 0.65

AArch64

An Apple M4, which has the AES, PMULL, SHA-1, SHA-2, SHA-512 and SHA-3 instructions, against OpenSSL 4.0.3.

Throughput in MB/s, higher is better:

crypton 1.1.5 crypton 2.2.5 OpenSSL 2.2.5 / OpenSSL
AES-128-GCM 134 12850 11215 1.15
AES-256-GCM 100 10356 9405 1.10
ChaCha20-Poly1305 804 2425 2302 1.05
SHA-1 1286 3389 3363 1.01
SHA-256 500 3397 3363 1.01
SHA-512 762 1881 1888 1.00
SHA3-256 564 1125 1104 1.02

Time per operation in microseconds, lower is better:

crypton 1.1.5 crypton 2.2.5 OpenSSL OpenSSL / 2.2.5
X25519 17.43 11.10 15.29 1.38
ECDH P-256 63.98 19.70 24.14 1.23
ECDH P-384 3181 68.57 371.6 5.42
Ed25519 sign 12.57 7.217 13.24 1.83
Ed25519 verify 17.62 17.75 34.72 1.96
ECDSA P-256 sign 30.53 6.333 10.46 1.65
ECDSA P-256 verify 91.95 26.52 31.97 1.21
ECDSA P-384 sign 3158 123.5 393.2 3.18
ECDSA P-384 verify 3790 200.5 327.0 1.63
RSA-2048 sign/decrypt 448.6 323.6 320.5 0.99
RSA-2048 verify/encrypt 18.12 9.683 8.366 0.86
RSA-3072 sign/decrypt 1468 952.9 925.6 0.97
RSA-3072 verify/encrypt 48.07 20.47 17.79 0.87

What the numbers say

There are two changes behind the 1.1.5 column and the 2.2.5 one, not a single steady improvement.

The first, in 2.0.0, was a rewrite: the bulk algorithms moved into C, the curves other than P-256 moved out of Haskell Integer arithmetic, and everything that touches a secret was made to take the same time whatever the secret is. 1.1.5 had no AArch64 code of its own at all, which is why AES-GCM there is close to a hundred times what it was, and on x86-64 it had AES-NI and nothing else.

The second, from 2.1.0 onwards, is assembly, for the operations where C cannot reach. Which of the two a row owes its gain to is not the same everywhere: ECDSA P-384 signing took nineteenfold from the rewrite and a further fifth from the assembly, while X25519 waited for the assembly entirely and ECDH P-384 is almost all of it.

Most of that assembly is not crypton's. The prime curves, the inverse modulo a group order, X25519, and RSA's Montgomery multiplication on x86-64 go through s2n-bignum, vendored in cbits/s2n. Every routine in it carries a machine-checked proof in HOL-Light that it computes what it says, and is written in a constant-time style. It is Apache-2.0 OR ISC OR MIT-0, and crypton takes it under ISC.

That licence is why any of this was possible. The obvious assembly to reach for is OpenSSL's and BoringSSL's ecp_nistz256, and it cannot be used here: it is Apache-2.0 only, and Intel and CloudFlare hold copyright in it besides OpenSSL, so nobody is in a position to relicense it.

crypton is behind in a few rows now: RSA on both architectures, except RSA-2048 signing on x86-64, and ECDSA P-256 verification on x86-64. The AES-GCM rows were the other half of this section until 2.1.5; they are ahead on both machines now, and what the instructions do is still worth setting out.

RSA. 2.0.0 made signing slower than 1.1.5 on purpose: its modular exponentiation stopped indexing a table with the bits of the exponent, and hiding the exponent is what the difference bought. 2.2.5 has repaid that cost on both machines, by different means. On x86-64 s2n-bignum's Montgomery multiplication is twice the C's, because the C cannot form the two carry chains ADCX and ADOX give, and 2.2.5 signs in less than 1.1.5 took, at 3072 bits as well as 2048. On AArch64 s2n-bignum's routines measure no better than the C, so the gain there is the C's own: the multiplication and squaring for each key size are generated and unrolled, with the reduction folded into each column as in Koç, Acar and Kaliski's FIPS method, and signing is within a few per cent of OpenSSL, the check of the result included. The measurements are in #275.

On processors with AVX-512 IFMA, Intel Xeons from Ice Lake and AMD from Zen 4, crypton computes the two halves of the CRT together in 52-bit digits, Gueron and Krasnov's method and the one OpenSSL takes there. Neither machine above has IFMA, so neither table shows it.

Verification's exponent is 65537, seventeen bits, so there is little exponentiation to speed up and the work around it counts. crypton keeps R^2, the Montgomery constant of a modulus, for the moduli it has seen, and from 2.2.5 hands the signature to the C as bytes and builds the padding without a list. On macOS it verifies in its own C at every RSA size: the GMP that GHC builds into itself there is compiled without optimisation (ghc#28030), which is most of what the RSA-3072 verification row on AArch64 gained. Elsewhere GMP keeps the sizes other than 2048 bits, and on x86-64 it is the reason the RSA-3072 verification row is still where it was.

The wide AES instructions. VAES and VPCLMULQDQ do two blocks where AES-NI and PCLMULQDQ do one, and four in their 512-bit form. crypton uses the 256-bit form where the processor has it, which is Zen 3 and Ice Lake onwards, and the 512-bit form where that is worth having, which is Ice Lake and Zen 5 onwards. There was nothing to borrow: the wide AES-GCM in OpenSSL, BoringSSL and AWS-LC is Apache-2.0 and s2n-bignum has no GCM, so both files are crypton's own.

Having the 256-bit one is where the 1.49 in the x86-64 table comes from, and it is narrower than it sounds. The EPYC 7763 is Zen 3: VAES and VPCLMULQDQ, no AVX-512. OpenSSL's x86-64 AES-GCM is aesni-gcm-x86_64.pl, which is 128-bit -- its vaesencs are the VEX encoding of AESENC on xmm, and there is not one ymm in the file -- or aes-gcm-avx512.pl, which wants AVX512VAES. There is no rung between them, so on this processor OpenSSL takes a block at a time where crypton takes two. The same idea as theirs, one step further down the feature ladder; not a better one.

The 512-bit path arrived after 2.1.2, so it is in the 2.2.5 column -- but neither machine in the tables above has AVX-512, so neither column shows it. On the runners that do, measured over 16 KiB in MB/s: an EPYC 9V45 (Zen 5) goes from 9616 to 14268 with it, a Xeon 6973P-C from 8095 to 9848, a Xeon 8573C from 6983 to 8447. OpenSSL on those machines is ahead still -- 25760 on the first of them -- because it interleaves the GHASH with the AES where crypton does them in turn. Zen 4 keeps the 256-bit path: its 512-bit instructions are two passes through a 256-bit datapath, so the wider encoding buys nothing there and costs a little.

AArch64 has no counterpart to any of these: one AES block and one GHASH multiplication at a time is all the instruction set offers. Its AES-GCM rows were 0.85 and 0.87 until 2.1.5, for that reason. What closed it was not width but the GHASH's representation -- H is twisted once at key setup so that GCM's bit reflection is already undone, which turns a reduction of some twenty-five shifts and XORs into two PMULL and six EOR and makes Karatsuba worth taking. The scheme is ARM's, from the BSD-3-Clause part of AArch64cryptolib, written out in crypton's own intrinsics. The AES there is ahead of OpenSSL's and always was; it was the GHASH beside it that was behind.

One row wants a word of its own: crypton's Ed25519.sign derives the public key from the secret key every time it signs, so that a caller who passes a public key that does not match cannot be made to leak the private one. That costs a second scalar multiplication, which OpenSSL's signing does not pay -- and the row is still 1.83 on AArch64 and 1.81 on x86-64, so the safety is had for nothing here rather than paid for.

Ed25519 verification wanted one too, for an unhappier reason, and 2.2.1 settles it. On x86-64 the row was about eight per cent slower from 2.1.8 onwards: 2.1.5, 2.1.6 and 2.1.7 all measure near 48 microseconds there, 2.1.8, 2.1.10 and 2.2.0 all measure near 52, and 2.2.1 is near 48 again. Nothing in Ed25519 ever changed -- no commit touches cbits/ed25519 across any of it, and AArch64 never moved, the compiled ed25519.o there being byte for byte the same object either side of 2.1.8. What moved was a line in this package's cabal file. The ML-KEM and ML-DSA backends that 2.1.8 vendored want -mavx2 -mbmi2, and they were given as cc-options inside an if arch(x86_64) block -- but cc-options in a conditional belong to the component and not to the block, so every C file in the library was compiled with AVX2 allowed, cbits/ed25519 among them, and what the compiler made of it that way is slower on this machine than what it replaced. Signing, which is the same file and a different routine, is where it always was. 2.2.1 narrowed the flags to the assembly that asked for them, which is also what stopped a binary built on one x86-64 machine from dying on another, and the row came back.

SHA-1 is in the tables because a number of protocols and file formats still ask for it, not because it is a good choice for anything new. The algorithms that nothing should ask for any more -- MD5, 3DES, RC4, CBC mode -- are left out.

ML-KEM and ML-DSA are left out too, though 2.1.8 brought both and they are fast -- mlkem-native and mldsa-native, from the PQ Code Package. No comparison is reported here: the implementations to measure against are still developing.

About

lowlevel set of cryptographic primitives for haskell

Resources

Stars

51 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages