Ciphers

AES (XTS)

The disk mode that replaced LRW. Every block masked by its sector and its position, a short last block steals from the one before, and nothing is padded. UTF-8 text in, hex out.
create
create("aes-xts")
family
Substitution-permutation
options
--key --tweak?
self-inverse
no
keyspace
2^256 or 2^512 keys (two AES keys)
try it
ciphers aes-xts "ATTACK AT DAWN FROM THE NORTH" --key 2718281828459045235360287471352631415926535897932384626433832795

XTS is what IEEE went with when it dropped LRW from the final P1619. It became IEEE 1619-2007, and NIST approves it in SP 800-38E for data on storage devices. It's not a museum piece. cryptsetup formats LUKS with aes-xts-plain64 unless you ask for something else.

The idea is still a mask around every block, only cheaper. LRW multiplies a secret key by the block index for every single block. XTS does one AES instead. The tweak key encrypts the data unit number, a sector on a disk, and that gives the first mask T. Block 1 is E(P ⊕ T) ⊕ T under the data key. Then T gets multiplied by x in GF(2^128), which is a shift and maybe one XOR with 0x87, and that's the mask for block 2. And so on.

The key is two AES keys in one hex string. The data key first, the tweak key second, so 64 hex digits for AES-128 and 128 for AES-256. No AES-192, the standard only has XTS-AES-128 and XTS-AES-256. tweak is the data unit number, up to 32 hex digits, default 0.

const xts = create("aes-xts");
const key = "2718281828459045235360287471352631415926535897932384626433832795";
xts.encode("ATTACK AT DAWN FROM THE NORTH", { key }).text;
// "22c74dbe484d5edba8907fa4eefece6d92beab33360246d4558612bf56"
xts.encode("ATTACK AT DAWN FROM THE NORTH", { key, tweak: "5" }).text;
// "1595ed5d615365828e75081606ce0e5c05844b6b4656b7594ebd1f24e6"
xts.decode("22c74dbe484d5edba8907fa4eefece6d92beab33360246d4558612bf56", { key }).text;
// "ATTACK AT DAWN FROM THE NORTH"

openssl enc refuses XTS, so the reference is Node's createCipheriv("aes-128-xts"), OpenSSL underneath. OpenSSL wants the tweak as a 16-byte IV, and the number goes in little-endian: sector 5 is 05000000000000000000000000000000. Same bytes both ways. The mode is tested against the IEEE 1619-2007 vectors 1 to 6 and 10 to 18, as OpenSSL's own test data carries them.

One difference. OpenSSL won't encrypt with two equal halves of the key, it says xts duplicated keys. Here that works, because IEEE vector 1 is exactly that, all zeros twice. Don't do it with a key you care about.

Stealing the last block

XTS pads nothing, the ciphertext is exactly as long as the text. ATTACK AT DAWN FROM THE NORTH is 29 bytes, one full block and 13 left over. So the mode steals. The full block gets encrypted as usual. Its first 13 bytes become the short tail of the ciphertext. The 3 bytes left over get glued to the 13-byte leftover, that makes a whole block again, and it's encrypted with the next mask. That block goes where the first one was.

The catch is the lower limit. Stealing needs a block to steal from, so the text must be at least 16 bytes of UTF-8. ATTACK AT DAWN is 14 and it's a CipherError, not some padded workaround. On a disk that never matters, a sector is 512 bytes at least. The top is capped too. NIST allows at most 2^20 blocks in one data unit, so anything over 16 MiB is a CipherError as well.

Sectors, not messages

Thirty-two As, two equal blocks. In ECB they come out equal:

xts.encode("A".repeat(32), { key }).text;
// 553b7ea31ee5c66988f4c64b648b916a b6e36231df232d2904af9a73289419fb

Spaces added to show the blocks. Different masks, nothing repeats. But the same text in the same sector under the same key is the same ciphertext, every time. That's the point for a disk, there's nowhere to put a fresh IV. It also means somebody watching the disk sees which sectors changed and which didn't.

A flipped bit scrambles its whole 16-byte block on decode and leaves the other blocks alone. Except at a stolen end: there the tail and the full block before it are tied together, a flip in either scrambles that full block, and a flip in the full one takes the tail down too. No integrity check notices. And the old sector content can be written back and decrypts just fine.

A key that isn't 64 or 128 hex digits is an InvalidOptionError, so is a tweak that isn't 1 to 32 hex digits. A missing key is a MissingOptionError. On decode, an odd number of hex digits, fewer than 16 bytes or more than 16 MiB is a CipherError. No padding means only the UTF-8 check catches a wrong key or a wrong sector, and a short message can still slip through as garbage.

Plain TypeScript, not constant time. For puzzles and for seeing how a disk hides repeated blocks and still shows you which sectors changed. Don't protect anything real with it.

@agntn/ciphers·MIT license· Classical ciphers, for lessons and puzzles. Not for protecting anything, ever.