RecursiveAI.OneCrypto.Primitives has Camellia (RFC 3713, 128-, 192- and 256-bit keys) and four ciphers that OneCrypto reads
but never writes: Cast5 (RFC 2144), Idea, Blowfish and Twofish. The platform has none of them.
Surface
| Camellia | CAST5 | IDEA | Blowfish | Twofish | |
|---|---|---|---|---|---|
| Key | 16, 24, 32 bytes | 5 to 16 bytes | 16 bytes | 4 to 56 bytes | 16, 24, 32 bytes |
| Block | 16 | 8 | 8 | 8 | 16 |
| Encrypt | ECB, CBC, CFB | none | none | none | none |
| Decrypt | ECB, CBC, CFB, OpenPGP CFB | ECB, CBC, CFB, OpenPGP CFB | same | same | same |
- The legacy classes have no encryption method at all. CFB decryption runs the block function forwards, so the engines can encrypt internally (the tests use that to round-trip), but nothing public reaches it.
- CBC takes
PaddingMode.PKCS7(RFC 5652 section 6.3, the default) orPaddingMode.None. Padding is checked over the last block with masks and no early exit; on failure the destination is zeroed beforeCryptographicException, so no plaintext leaves. Whether the padding was valid is visible only through the exception, as with any padded CBC, so protocols must authenticate before decrypting. - CFB uses full-block segments (CFB128 for the 16-byte ciphers, CFB64 for the 8-byte ones, as OpenSSL's
-cfbdoes) and accepts any length; the last block's keystream is truncated. DecryptOpenPgpCfb(source, destination, resync)decrypts OpenPGP symmetric data: zero IV over the random prefix and the data (RFC 9580 section 13.9). Withresync: trueit applies the older packet's variant (RFC 4880 section 13.9), reloading the feedback register with ciphertext bytes 3 to block size + 2 after the prefix. The prefix and its two check bytes are returned decrypted; the caller checks them.- Spans may be the same (in place) or must not overlap. Calls do not allocate. Key schedules live in pinned arrays that
Disposezeroes.
Constant time
Table-driven implementations index tables with secret bytes, which a co-resident observer of the cache can read. None of these ciphers does that, in the rounds or the key schedule:
- Camellia. One lookup of the F-function is a vector operation on its eight bytes (sixteen, for two blocks at once). With AES instructions (see "Camellia through the AES instruction" below) the S-box is one AES round instruction between two affine maps. Without them, each byte's low nibble indexes all sixteen 16-entry rows of SBOX1 through a byte shuffle, and the high nibble selects a row through a tree of blends. SBOX2, SBOX3 and SBOX4 are derived exactly as RFC 3713 section 2.4.1 defines them (rotate the output or the input by one bit). The P-function is six shuffles.
- CAST5 and Blowfish. Every S-box lookup compares the index with all 256 positions and keeps the match (
CtLookup.Word), vectorised over 8 (or 4) positions per step. The Blowfish key schedule performs its 521 block encryptions the same way. - Twofish. The key-dependent S-boxes are never tabulated. Each S-box byte is computed from the two fixed byte permutations, which the paper builds from 4-bit permutations, and those are 16-entry shuffles; the key bytes are XORed in as the paper's h function does. The MDS and RS multiplications use shifts and masks.
- IDEA. No tables. Multiplication modulo 65537 uses masks instead of the zero test and division, and the decryption schedule's inverses are computed as x^65535 with a fixed sequence of multiplications.
- CAST5's rotation amounts come from the key; they are applied with a rotate instruction, whose time does not depend on the amount.
The cost is speed; see the numbers below. ByteShuffle picks pshufb (SSSE3) or tbl (Arm64) from the platform's capabilities and
falls back to Vector128.Shuffle, which gives the same results where it is the only option (it is a software loop where no vector
hardware exists, with the same access pattern).
JIT output was inspected with DOTNET_JitDisasm (full opts) for CamelliaEngine.F, TwofishEngine.H, CtLookup.Word,
Cast5Engine.F, BlowfishEngine.F and IdeaEngine.Mul: the only conditional jumps are the static-initialisation check, bounds checks,
and loops over public counts (stage, round, table position); no jump, cmov or address depends on a secret.
Verification
- Camellia: RFC 3713 Appendix A (3 vectors); Wycheproof
camellia_cbc_pkcs5_test.json(216 tests, both directions, invalid padding rejected); OpenSSL 3.5.9 CBC and CFB both ways for all three key sizes. - CAST5: RFC 2144 Appendix B.1 (128-, 80- and 40-bit keys) and the B.2 maintenance test (one million iterations).
- IDEA, Blowfish, Twofish: the published known answers (IDEA key 0001..0008; the Blowfish variable-key set; the Twofish submission's 128-, 192- and 256-bit known answers); OpenSSL 3.5.9 (legacy provider) for IDEA, CAST5 and Blowfish; GnuPG for all of them.
- OpenPGP CFB: fixtures written by GnuPG 2.4 (
tools/gen_gnupg_cfb_fixtures.py) for IDEA, CAST5, Blowfish, Twofish and Camellia 128/192/256, both the protected packet and the older one with resynchronisation. - Wycheproof has no files for CAST5, IDEA, Blowfish or Twofish, and none for Camellia ECB or CFB.
Camellia through the AES instruction
Camellia's S-box is affine-equivalent to the inversion in the AES field GF(2^8) (the design papers say as much), so an AES S-box instruction can do the substitution. The affine maps are not in RFC 3713, so they were found from the RFC's SBOX1 table by black-box search; nothing was taken from any implementation or from the designers' specification.
- Find P. For every input difference a the differential
SBOX1[x] ^ SBOX1[x ^ a]takes exactly one output difference b exactly four times; set phi(a) = b. If SBOX1(x) = N(Inv(M x ^ a0)) ^ b0 with N and M linear, Inv the AES-field inversion and Inv's own differential having the same one-in-four property, then phi(a) = N(D(M a)) for the corresponding map of Inv. Inv is fixed, so M and N are constrained by phi. - Search. A depth-first search chooses the images of the eight unit vectors under M one at a time (without loss of generality
the first is 1), keeps a linear basis of the pairs (phi(v), Inv(M v)) built so far, and prunes a branch as soon as the basis is
inconsistent. It finds solutions in well under a second. One solution, with P = M, has columns
1, 25, 177, 171, 167, 147, 97, 217(column i is P applied to the unit vector with bit i set). - Solve the rest. With P fixed, the constant a0 = 8 is the only value for which
N(SBOX1[x]) ^ Inv(P x ^ a0)is constant in x, and N is the linear map that follows from the pairs; N^-1 has columns241, 187, 142, 9, 250, 215, 33, 225and the output constant is0x6E:SBOX1(x) = N^-1 (Inv(P x ^ 8)) ^ 0x6E, checked on all 256 inputs. - Use the AES instruction. One AES round with a zero key computes the AES S-box,
A Inv(y) ^ 0x63, and permutes bytes with ShiftRows. The code builds both nibble tables at start-up: before the instruction,y = P x ^ 8as two 16-entry shuffles; after it, the map from the AES S-box output to SBOX1 is read off the 256 values ofSBOX1[x]againstAES(P x ^ 8)(an affine function of the output, so a matrix and a constant) and applied as two more shuffles. SBOX2, SBOX3 and SBOX4 keep their RFC definitions (rotations on lanes). The byte shuffle of ShiftRows is folded into the P-function's index tables. - Two blocks. The vector has sixteen lanes; lanes 0 to 7 carry one block's S-box bytes and lanes 8 to 15 another's. ShiftRows maps the two groups to disjoint lanes, so ECB and CBC decryption push two blocks through each F call.
The shuffle path remains for processors without AES instructions, and a test compares the two paths, one block against two blocks, for all three key sizes in both directions.
Cost of avoiding table lookups
Indicative CBC rates on a loaded machine, ratio = ours / BouncyCastle 2.7.0 (below 1 is faster). AMD Ryzen 9 7950X3D (x64) and Apple M2 (arm64).
| Cipher | x64 ratio | arm64 ratio |
|---|---|---|
| Camellia (CBC encrypt and decrypt, 128 and 256-bit keys, 1 KiB and 64 KiB) | 1.84 to 2.21 (geomean 2.0) | 2.49 to 2.87 (geomean 2.7) |
| IDEA | 1.3 to 1.4 | 1.65 |
| Twofish | 8.0 to 8.1 | 13 to 14 |
| CAST5 | 15 | 32 to 33 |
| Blowfish | 18 to 19 | 44 to 45 |
Blowfish key setup takes about 1.2 ms (521 block encryptions), CAST5 and Twofish key setup 1 to 12 microseconds.
For reference OpenSSL 3.5.9 on the x64 machine: Camellia-128-CBC 310 MB/s, Camellia-256-CBC 225 MB/s, Blowfish 229 MB/s, CAST5 205 MB/s, IDEA 168 MB/s.
The timing tests (tests/Timing/LegacyCipherTargets.cs) run data, key and key-setup kinds plus an A/A control per cipher.
Camellia, CAST5, IDEA, Twofish: no leak in the indicative runs apart from the key-pool kind flagged under heavy load; see the report.
The pool of key objects is placed at random slots each batch, because a fixed even/odd split showed an address effect that the A/A control caught.