- Zig 97.7%
- Nix 2.3%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
RFC 8017 §3.2's other form of a private key -- the two primes and the three values derived from them -- was parsed and dropped. It is kept now, and signing uses it: two exponentiations modulo numbers half the width of `n` instead of one modulo `n`, which is a quarter of the work because the cost goes as the cube of the size. Measured in ReleaseFast, including the check below in both columns: key sign sign with whole d 2048-bit 3.9 ms 13 ms 4096-bit 27 ms 101 ms The doc comment said this needed a modular inverse and so could not be built on `std.crypto.ff`. That was wrong about which inverse: recombining needs `qinv`, and the key already carries it. Blinding is the one that needs an inversion computed, and is still not done. **Every signature is verified before it is released.** With the CRT that is not belt and braces but the thing that makes it safe: a signer that gets one half wrong -- a fault in the hardware, or a key whose components disagree -- emits a signature from which `gcd(s^e - m, n)` is one of the primes, which is the whole private key from a single bad signature. Recomputing `s^e mod n` costs about a fiftieth of the signature; a mismatch returns `error.SigningFailed` with the output buffer wiped, so a caller that ignores the error cannot publish the thing that must not be published. That check is also why the components are not validated at parse time. A key whose primes are not the factors of its modulus fails on its first use, through machinery this has to have anyway. Three tests: that the two representations produce identical signatures on the same key, that a corrupted `qinv` yields `SigningFailed` and a zeroed buffer, and -- already there, and the reason this could be written with any confidence -- that the signatures still match OpenSSL byte for byte. `rsa.zig` now uses this library's `ff` rather than `std.crypto.ff`. Neither of the two things fixed there is reachable from this code, which is why they were found by measuring something else, but a library carrying the fix should be the first to use it. One trap, found by a crash rather than by thinking: `Modulus.reduce` only narrows. It takes a value wider than the modulus and brings it down, and handed a narrower one it runs off the bottom of its own index. Carrying `q`, `h` and `m2` up into `n`'s field goes through bytes and `Fe.fromBytes`, which accepts exactly what is already reduced. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_0141M41NZ9P4WQsMW41VYZGs |
||
| .forgejo/workflows | ||
| LICENSES | ||
| src | ||
| tests | ||
| tools | ||
| .gitignore | ||
| build.zig | ||
| build.zig.zon | ||
| flake.lock | ||
| flake.nix | ||
| package.nix | ||
| README.md | ||
| REUSE.toml | ||
zig-std-crypto-ext
The ciphers, modes and signatures std.crypto leaves out: DES, Triple
DES, AES-192, CBC, CFB and ECB generic over any block cipher, and
RSA signing.
Named for what it is rather than for its first occupant — it started as
zig-des, and DES is now the smaller half of it.
The API documentation is generated from the doc comments, which carry most of the explanation, and is published at https://jeff.jcollie.page/zig-std-crypto-ext/.
$ git clone https://git.jcollie.dev/jeff/zig-std-crypto-ext.git
$ cd zig-std-crypto-ext
$ nix develop -c zig build test
Where this lives
Four homes, with the same history in each.
-
Forgejo, at https://git.jcollie.dev/jeff/zig-std-crypto-ext, which is where the workflow runs and where the documentation is published from.
-
GitHub, at https://github.com/jcollie/zig-std-crypto-ext.
-
Tangled, at https://tangled.org/jcollie.dev/zig-std-crypto-ext.
-
Radicle, as
rad:z2UqY7wXCc4xUHRfeaDepK33Kch1Z. A Radicle repository is only findable by its ID, so that string is the whole address:$ rad clone rad:z2UqY7wXCc4xUHRfeaDepK33Kch1Z
Why this exists
⚠️ Everything here is obsolete, and that is the point. Single DES has a 56-bit key and has been brute-forceable since 1998 [8]. Two-key Triple DES has a meet-in-the-middle attack [9], and the 64-bit block gives a birthday bound around 32 GiB under one key however long the key is [10]. CBC and CFB authenticate nothing and CBC has a long history of padding oracles. Nothing new should choose any of it.
What keeps it alive is equipment that already exists. SNMPv3's
usmDESPrivProtocol (RFC 3414 [6]) is DES-CBC and is still the default privacy
protocol on a great deal of network hardware, so a manager that cannot speak it
cannot talk to those devices at all. Kerberos 4, PKCS#12, MS-CHAP and a good
deal of banking hardware are in the same position. std.crypto quite reasonably
declines to ship any of this; this library is where it goes instead, clearly
labelled.
The modes are here for a second reason: std.crypto.modes has only counter
mode, and RFC 3826 [7] specifies AES-128 in full-block CFB for SNMPv3. So one
library has to supply a cipher std omits and a mode std omits, for two
different ciphers — which is why modes is generic over the cipher rather than
tied to DES.
RSA is here for a different reason, and it is not obsolete. Zig 0.16 does
ship RSA, but only half of it and only as an implementation detail of
something else: std.crypto.Certificate.rsa has a public key and a verifier
because checking a certificate chain needs them. There is no private key type
anywhere in the standard library and nothing that can produce a signature. A
protocol that has to sign — DKIM, JWS RS256, a certificate request — has
nowhere to go. So this library supplies the missing half, and the PKCS#1 and
PKCS#8 key parsing that has to come with it, since a private key arrives as
DER inside PEM and std will not decode that either.
What it does promise
Obsolete is not the same as careless, and two things are deliberately true of this code.
The cipher is constant-time in the key and the data. The tables are only
ever read at constant indices, every loop runs a fixed number of times, and
the S-boxes — the one place a DES implementation normally reads memory at a
key-dependent address, which is the cache-timing attack of Tsunoo et al. [11] — are
evaluated with masks and a shift rather than a lookup. The key helpers are
written the same way, since the key is what they are given. That rests on
integer compare, mask and variable shift being constant-time, which they are
on x86-64 and AArch64, and on the compiler not turning a mask back into a
branch, which nothing forbids, so like every such claim made in a language
without constant-time semantics it is best-effort. It is not bitsliced.
Because it is best-effort it is also measured: zig build timing runs the
test from dudect [12] against the release build on the machine at hand, and the
section on tests below says how to read it.
AES-192 has exactly the timing std's own AES has. It is not written
out in software: the rounds are std.crypto.core.aes.Block's encrypt and
encryptLast, which are single instructions on x86-64 with AES-NI and on
AArch64 with the crypto extension, and the key schedule's S-box goes through
the same instruction. Only the schedule's bookkeeping is this library's. So it
is constant-time wherever std.crypto.core.aes.has_hardware_support is true
and falls back to std's software AES wherever it is not — which is decided
by the CPU the build was given, not the one it runs on, and -Dcpu=baseline
on x86-64 has no AES-NI. An earlier version indexed a 256-byte S-box by the
state, the cache-timing leak of Bernstein and of Osvik, Shamir and Tromer,
next door to a DES that goes to some lengths to avoid exactly that.
The modes' length checks are assertions. dst.len >= src.len, and a whole
number of blocks for CBC and ECB, are checked in a Debug or ReleaseSafe build
and not at all in ReleaseFast or ReleaseSmall, where a violation reads past
src and writes past dst. A ciphertext length that came off the wire is
checked by the caller before it gets here, which is how std.crypto's own
modes behave too. The doc comment on modes spells out the rest: dst is the
same slice as src or does not overlap it, and the mode's temporaries are
zeroed on return while the contexts, being the caller's, are not.
RSA's private exponentiation is constant-time and uses the CRT; it is not
blinded. The arithmetic is ff's Montgomery arithmetic — the same std
verifies certificates with — which is constant time in both the base and the
exponent, the defence that matters against a remote timing attack. When the
key carries the second representation, which every PKCS#1 and PKCS#8 key
does, signing is two exponentiations modulo numbers half the width of n
rather than one modulo n: 3.9 ms for a 2048-bit signature where the direct
route takes 13, and 27 ms against 101 at 4096 bits.
That is only safe with the check that comes with it. Every signature is
verified before it is released, because a CRT signer that gets one half
wrong — a fault in the hardware, or a key whose components disagree — emits a
signature from which gcd(s^e - m, n) is one of the primes, which is the
whole private key from one bad signature. The check costs about a fiftieth of
the signature and a mismatch returns error.SigningFailed with the buffer
wiped.
It still does not blind the input, because blinding needs a modular inverse
and ff exposes none; the CRT needs no inversion, because the key carries
qinv already. It is appropriate for signing with a key on a machine you
trust, and it is not a replacement for an HSM. The doc comment on rsa gives
the numbers and the reasoning.
What is here
Des |
Single DES. initEnc/initDec return contexts with a block_length and encrypt/decrypt over one block. |
Des3 |
Triple DES in EDE order, three-key and two-key (initEnc2). Three equal keys make it identical to single DES, which is what the middle decryption is for. |
modes.cbcEncrypt, cbcDecrypt |
Cipher Block Chaining. Whole blocks only; choosing a padding is the caller's business, because the padding belongs to whatever specification sent them here. |
modes.cfbEncrypt, cfbDecrypt |
Cipher Feedback with full-block feedback — "CFB128" for a 128-bit cipher. A stream mode, so any length, and it runs the cipher forwards in both directions, so both take an encryption context. |
modes.ecbEncrypt, ecbDecrypt |
Each block alone. Leaks which plaintext blocks are equal; present because key-wrapping constructions and test vectors are stated in terms of it. |
Aes192 |
AES-192, the key size std.crypto omits — it ships Aes128 and Aes256 and nothing between. Built from std's own hardware rounds, so it has std's timing; only the key schedule is written here. Encryption only, because CFB and CTR never run a cipher backwards; initDec is deliberately absent, so asking for it is a compile error rather than a surprise. |
weak_keys, isWeak |
The four keys for which DES is an involution. A password-derived key can be one by accident, and usmDESPrivProtocol derives its key from a password. |
hasOddParity, setOddParity |
The parity convention DES keys are distributed under. The cipher ignores the parity bits entirely — that is what "56-bit key" means. |
rsa.SecretKey |
An RSA private key, read from PKCS#1 or PKCS#8 DER or from the PEM around either — fromPem tells the two apart by looking. Held by value, so the DER it came from can be wiped. |
rsa.PublicKey |
An RSA public key, from a SubjectPublicKeyInfo or a bare PKCS#1 RSAPublicKey, DER or PEM. Both shapes are accepted because formats that carry one are inconsistent about which they mean. |
rsa.pkcs1v1_5.Signer(Hash) |
RFC 8017 RSASSA-PKCS1-v1_5, over SHA-1, SHA-224, SHA-256, SHA-384 or SHA-512. sign/verify over a message, signConcat/verifyConcat over its pieces, and signDigest/verifyDigest for a protocol that hashes something which never exists as contiguous bytes. |
rsa.SecretKey.Crt |
RFC 8017 §3.2's second representation — the two primes and the three values derived from them — kept when the key carried them, which every PKCS#1 and PKCS#8 key does. Signing then costs a quarter of what it otherwise would, and every signature is verified before release. |
ff |
std.crypto.ff with one function put right — the only thing here that corrects the standard library rather than adding to it. See below. |
The carried patch
ff is not an addition. It is std.crypto.ff vendored from Zig 0.16.0 with
two functions changed, and the intent is that both go upstream and the file
then goes away.
A secret exponent taking the branchy path.
powWithEncodedExponentInternal chooses between a constant-time walk over a
precomputation table and a short-exponent loop that branches on the
exponent's bits, and the test that chooses reads public and e.len < 3 or (e.len == 3 and ...). Since and binds tighter than or, that means
(public and short) or (three bytes and small) — and the second half never
asks whether the exponent is public. A three-byte secret exponent with a
small top nibble goes down the path that branches on the secret.
zig build timing measures it rather than asserting it, by the same dudect
method as everything else in that harness. Welch's t between a fixed and a
random three-byte secret exponent:
| t | |
|---|---|
| upstream | ~2400 |
| here | ~1 |
Ten is the threshold at which the harness calls a leak. The means go from 530,000 cycles against 710,000 — the classes plainly doing different amounts of work — to the same number either way.
The fixed class in that measurement is an ordinary three-byte exponent rather
than zeros, and that is not a detail. Zeros make it the exponent 1, which is
degenerate: nearly every step of the ladder is then multiplying by one, and
the test ends up comparing a trivial exponentiation with a normal one rather
than one secret with another. Written that way it reports single digits at a
hundred thousand samples and a leak at a million and a half, which is the
test's doing and not the code's. Whether it is reachable depends on
the caller: an RSA key with a three-byte private exponent is broken for other
reasons, but ff is general, and a protocol using short secret exponents on
purpose would leak them.
Half the squarings, spent on leading zeros. Modulus.pow serializes the secret exponent before handing it to the
exponentiation ladder, and it sized that buffer by Fe.encoded_bytes — the
type's maximum width — rather than by the modulus's own. The ladder spends
four squarings on every nibble it is given, so the difference between the two
is squarings of leading zeros, and the cost is linear in how far the type
overshoots the key. An RSA implementation that supports 4096-bit keys
instantiates Modulus(4096) for all of them, so every 2048-bit key paid
twice. One private exponentiation, ReleaseFast:
std.crypto.ff |
ff here |
|
|---|---|---|
Modulus(4096), 2048-bit modulus |
26.1 ms | 13.1 ms |
Modulus(2048), 2048-bit modulus |
12.8 ms | 12.8 ms |
Modulus(1024), 1024-bit modulus |
1.8 ms | 1.8 ms |
Constant time is unaffected: the new length depends on the modulus, which is
public, and not on the exponent, which is not. powPublic beside it already
trims this way and goes further, stripping leading zero bits — which the
secret path must not do, because that length would depend on the secret.
rsa here does not benefit, because it never calls pow: it serializes the
exponent to exactly the modulus length itself and calls
powWithEncodedExponent, which was the right thing to do and is the reason
the bug was invisible from inside this library. What found it was a TLS
implementation elsewhere that does call pow, and spends 26 ms of a
handshake on it.
tests/ff.zig is differential rather than exemplary: the same exponentiations
through both implementations, at the widths where the patch changes the work
and at the widths where it must not. The second set is not ceremony — sizing
the exponent by the limb count rather than the bit length asks for more bytes
than the buffer has when the modulus fills the type, and that is how the first
version of the patch was caught. The timing change is checked by
zig build timing, which is the only way to check it: both versions compute
the same answer, and the difference between them is a clock.
Using it
$ zig fetch --save git+https://git.jcollie.dev/jeff/zig-std-crypto-ext.git
const des = @import("std_crypto_ext");
// DES-CBC, as SNMPv3 privacy uses it.
var ciphertext: [24]u8 = undefined;
des.modes.cbcEncrypt(
des.Des.EncryptCtx, des.Des.initEnc(key), &ciphertext, plaintext, iv,
);
// And the same mode machinery over AES, because it is generic over the
// cipher rather than tied to this library's.
const aes = std.crypto.core.aes;
des.modes.cfbEncrypt(
aes.AesEncryptCtx(aes.Aes128), aes.Aes128.initEnc(aes_key),
&out, plaintext, aes_iv,
);
const rsa = @import("std_crypto_ext").rsa;
const Sha256 = std.crypto.hash.sha2.Sha256;
var der: [rsa.max_secret_key_der]u8 = undefined;
const sk = try rsa.SecretKey.fromPem(&der, pem_text);
var buf: [rsa.max_modulus_len]u8 = undefined;
const sig = try rsa.pkcs1v1_5.Signer(Sha256).sign(&buf, message, sk);
try rsa.pkcs1v1_5.Signer(Sha256).verify(sig, message, sk.publicKey());
The contexts are shaped like std.crypto.core.aes's so that they read the same
way as the cipher next door. That is a familiarity argument and not an
interoperability one: std.crypto.modes.ctr reaches into
BlockCipher.block.parallel.optimal_parallel_blocks to batch blocks, which only
AES defines, so it cannot take a cipher from here. The modes in this library
depend on nothing but block_length and encrypt/decrypt, which is what
makes them genuinely generic.
Tests
$ nix develop -c zig build test --summary all
$ nix develop -c zig build fuzz-run -- --seconds 60
$ nix develop -c zig build timing
Every vector was cross-checked against OpenSSL's legacy provider [13], not transcribed and trusted:
$ openssl enc -provider legacy -provider default -des-ecb -nopad -K <key> -in pt.bin
That is not belt-and-braces. A DES that is self-consistent and wrong is easy to write — index the S-boxes with the raw six input bits rather than the published row and column and it still round-trips perfectly — and that is precisely the bug that occurred here. A round-trip test proves almost nothing, so the known-answer tests are the real ones: FIPS 46-3 [1], the NBS samples [2], Rivest's sixteen-step cycle [3], NIST SP 800-67's Appendix B for Triple DES [4], and NIST SP 800-38A F.3.13 for AES-128-CFB [5], with the DES-CFB and two-key Triple DES answers taken from OpenSSL. One of the four NBS values was wrong when first written, and OpenSSL is what settled which of us was. Rivest's cycle is the one that earns its place: sixteen encryptions and decryptions chained through each other, which he showed detects every single-fault error in an implementation with one comparison at the end.
The RSA vectors were made by OpenSSL too, and for the same reason. PKCS#1
v1.5 is deterministic, so "byte for byte identical to what OpenSSL signed" is
a test that can actually be written — with PSS it could not be — and the suite
asserts exactly that for 1024- and 2048-bit keys under SHA-1 and SHA-256, in
both directions: OpenSSL's signatures verify here, and this library's
signatures are bit-identical to OpenSSL's. Alongside those are the tests that
a vector cannot reach: that flipping any single bit of a signature is
rejected, which is what catches a comparison that stops early or only compares
part of the buffer; that truncating the key DER at any offset is an error
rather than a key missing its tail; that a key too small, an exponent that
is even or 1, and a modulus too short for the hash are each refused rather
than used; and that a modulus too large is refused too, which matters more
than it sounds: std.crypto.ff's field is sized in 63-bit limbs and so
quietly accepts up to 4158 bits, and a key in the gap above 4096 used to parse
and then index the 512-byte signature buffers past their end. A 4096-bit key,
the top of the range, is in the suite alongside the 1024- and 2048-bit ones,
because that is the size at which fromPem used to run out of the buffer
whose size it documents.
The fuzz targets are round-trip properties over the modes, where there is real room to be wrong — an off-by-one on a final partial block, a chaining value read after being overwritten by an in-place operation, a keystream that wrongly depends on how much plaintext follows. They also assert the properties no single vector can: that the parity bits never change the ciphertext, that three equal keys make 3DES into DES, and that a weak key is an involution, for every key and block rather than for one. The key parsers get targets of their own, since a DER reader is the one thing here that reads bytes somebody else wrote: the property is the weak one — return a key or an error, but stay inside the buffer and terminate — seeded with a real key so that mutations reach past the first tag. The verifier gets the strong one: every input is a forgery, and every one has to be refused.
zig build timing measures the constant-time claim instead of trusting it.
It is the test from dudect [12]: DES, Triple DES, AES-192, the key helpers,
and DES-CBC and AES-192-CFB over a few blocks are each timed on a fixed input
and on random ones, hundreds of thousands of times in a random order, and
Welch's t-test asks whether the two timing distributions can be told apart. A |t| above 10 is a leak, and at these sample counts that is a
difference of about one cycle held consistently. A function whose running time
is its input runs first, and the run fails if that control is not detected,
so that a clean result means something. It reads the machine it runs on and
the compiler that built it, which is exactly what the claim depends on and
exactly what a disassembly read once cannot keep checking. Before the S-boxes
were rewritten it reported the table lookup as a leak at |t| of 20; it now
reports every function within a few units of zero.
The API documentation
$ nix develop -c zig build docs # into zig-out/docs
$ nix develop -c zig build docs-serve # http://127.0.0.1:8000
References cited
- National Institute of Standards and Technology, Data Encryption Standard (DES), FIPS PUB 46-3, 25 October 1999; withdrawn 19 May 2005. https://csrc.nist.gov/pubs/fips/46-3/final. The tables, and the first known answer.
- Jason Gait, Validating the Correctness of Hardware Implementations of the NBS Data Encryption Standard, NBS Special Publication 500-20, National Bureau of Standards, 1977, revised 1980. https://nvlpubs.nist.gov/nistpubs/Legacy/SP/nbsspecialpublication500-20e1980.pdf. The NBS sample vectors.
- Ronald L. Rivest, Testing Implementations of DES, 23 February 1985.
https://people.csail.mit.edu/rivest/pubs/Riv85.txt. The sixteen-step
cycle from
9474B8E8C73BCA7Dto1B1A2DDB4C642438. - Elaine Barker and Nicky Mouha, Recommendation for the Triple Data Encryption Algorithm (TDEA) Block Cipher, NIST Special Publication 800-67 Revision 2, November 2017. https://doi.org/10.6028/NIST.SP.800-67r2. Appendix B, the Triple DES known answer.
- Morris Dworkin, Recommendation for Block Cipher Modes of Operation: Methods and Techniques, NIST Special Publication 800-38A, December 2001. https://doi.org/10.6028/NIST.SP.800-38A. CBC, CFB and ECB as specified, and the AES-128-CFB128 vector in F.3.13.
- Uri Blumenthal and Bert Wijnen, User-based Security Model (USM) for version 3 of the Simple Network Management Protocol (SNMPv3), RFC 3414, December 2002. https://www.rfc-editor.org/rfc/rfc3414.
- Uri Blumenthal, Fabio Maino and Keith McCloghrie, The Advanced Encryption Standard (AES) Cipher Algorithm in the SNMP User-based Security Model, RFC 3826, June 2004. https://www.rfc-editor.org/rfc/rfc3826.
- Electronic Frontier Foundation, Cracking DES: Secrets of Encryption Research, Wiretap Politics & Chip Design, O'Reilly, May 1998, ISBN 1-56592-520-3. The machine that made 56 bits a matter of days.
- Paul C. van Oorschot and Michael J. Wiener, "A Known-Plaintext Attack on Two-Key Triple Encryption", Advances in Cryptology — EUROCRYPT '90, Lecture Notes in Computer Science 473, pp. 318–325, 1991. https://doi.org/10.1007/3-540-46877-3_29.
- Karthikeyan Bhargavan and Gaëtan Leurent, "On the Practical (In-)Security of 64-bit Block Ciphers", Proceedings of the 2016 ACM SIGSAC Conference on Computer and Communications Security, pp. 456–467, October 2016. https://doi.org/10.1145/2976749.2978423. The birthday bound, known as Sweet32.
- Yukiyasu Tsunoo, Teruo Saito, Tomoyasu Suzaki, Maki Shigeri and Hiroshi Miyauchi, "Cryptanalysis of DES Implemented on Computers with Cache", Cryptographic Hardware and Embedded Systems — CHES 2003, Lecture Notes in Computer Science 2779, pp. 62–76, 2003. https://doi.org/10.1007/978-3-540-45238-6_6. Key recovery from the S-box lookups' cache behaviour, which is what the constant-time S-boxes are for.
- Oscar Reparaz, Josep Balasch and Ingrid Verbauwhede, "Dude, is my code
constant time?", Design, Automation & Test in Europe (DATE) 2017; IACR
Cryptology ePrint Archive, Report 2016/1123.
https://eprint.iacr.org/2016/1123. The test that
zig build timingruns. - The OpenSSL Project, OpenSSL 3.6.3,
openssl encwith the legacy provider. https://www.openssl.org/. The independent implementation every vector was checked against.
Licence
MIT. See LICENSES/MIT.txt.