diff --git a/libp2p_mix/lioness.nim b/libp2p_mix/lioness.nim new file mode 100644 index 0000000..3c9b6ed --- /dev/null +++ b/libp2p_mix/lioness.nim @@ -0,0 +1,210 @@ +# SPDX-License-Identifier: Apache-2.0 OR MIT +# Copyright (c) Status Research & Development GmbH + +## LIONESS wide-block cipher (Anderson & Biham, 1996), instantiated with +## ChaCha20 (stream cipher), keyed Blake2b-256 (hash), and SHAKE128 (KDF). +## +## The block ``B = L || R`` is split into a 32-byte left half and a right half +## of size ``len(B) - 32``, then four Feistel rounds are applied: +## +## round 1: R := R XOR ChaCha20(key = L XOR K1) +## round 2: L := L XOR Blake2b_K2(R) +## round 3: R := R XOR ChaCha20(key = L XOR K3) +## round 4: L := L XOR Blake2b_K4(R) +## +## ``K1..K4`` are derived from a 32-byte master key by feeding it into SHAKE128 +## and reading 128 bytes (= 4 * 32) of output. The 12-byte ChaCha20 IV is +## supplied by the caller; ``sphinx.nim`` derives it per-hop from the shared +## secret with the same labeled-SHA-256 pattern it uses for the header +## AES-CTR keys. See ``tests/test_lioness.nim`` for vectors. +## +## LIONESS itself does not provide integrity. The Sphinx construction prepends +## ``k`` zero bytes to the plaintext before encryption and verifies them at the +## destination after decryption: tampering anywhere in the ciphertext scrambles +## the entire plaintext through the wide-block PRP, so the leading zeros are +## destroyed with overwhelming probability. See the migration design note for +## details. + +{.push raises: [].} + +import chronicles +import results +import nimcrypto/[blake2, keccak, utils] +import bearssl/abi/bearssl_block + +logScope: + topics = "libp2p mix lioness" + +const + LionessLeftLen* = 32 + ## Size of the left half ``L``. Must equal both the stream cipher key size + ## and the hash output size, since L is XORed with each. + LionessHashKeyLen* = 32 + ## Blake2b MAC key size used in the hash rounds. Sized to the master key + ## (``LionessMasterKeyLen``); a larger MAC key cannot create entropy that + ## the master key does not already provide. + LionessMasterKeyLen* = 32 + ## Size of the per-hop shared-secret master key from which round keys are + ## derived. + LionessMinBlockLen* = LionessLeftLen * 2 + ## Minimum supported block size. The construction works for any + ## ``|m| > LionessLeftLen``, but real Sphinx payloads are several KB; the + ## stricter bound rejects degenerate inputs that callers never need. + + LionessIvLen* = 12 + ## ChaCha20 nonce size. The IV is supplied by the caller; in this + ## codebase ``sphinx.nim`` derives it per-hop from the shared secret + ## using the same labeled-SHA-256 pattern it uses for the header + ## AES-CTR ``aes_key``/``iv`` (see ``deriveLionessIv``). + +type + LionessError* {.pure.} = enum + BlockTooSmall + InvalidMasterKey + InvalidIv + + RoundKeys = object + k1: array[LionessLeftLen, byte] + k2: array[LionessHashKeyLen, byte] + k3: array[LionessLeftLen, byte] + k4: array[LionessHashKeyLen, byte] + + Lioness* = object + ## Stateless LIONESS instance. Construct via + ## ``Lioness.init(masterKey, iv)`` and call ``clear`` when no longer + ## needed to wipe round keys from memory. + keys: RoundKeys + iv: array[LionessIvLen, byte] + +func clear(self: var RoundKeys) = + burnMem(self.k1) + burnMem(self.k2) + burnMem(self.k3) + burnMem(self.k4) + +func clear*(self: var Lioness) = + ## Zeroize the derived round keys and the IV. + self.keys.clear() + burnMem(self.iv) + +proc deriveRoundKeys(masterKey: openArray[byte]): RoundKeys = + # Caller (``Lioness.init``) is responsible for validating ``masterKey.len``. + # SHAKE128 supports incremental squeezing — the four sequential ``output`` + # calls below collectively produce the same byte stream as one combined + # ``output`` call of length 2*LionessLeftLen + 2*LionessHashKeyLen. + var ctx: shake128 + ctx.init() + ctx.update(masterKey) + ctx.xof() + discard ctx.output(result.k1) + discard ctx.output(result.k2) + discard ctx.output(result.k3) + discard ctx.output(result.k4) + ctx.clear() + +proc validateInitInputs(masterKeyLen, ivLen: int): Result[void, LionessError] = + # Non-generic helper so the chronicles ``error`` template can resolve its + # implicit ``activeChroniclesStream`` against this module's scope rather + # than each generic instantiation site of ``Lioness.init``. + if masterKeyLen != LionessMasterKeyLen: + error "LIONESS init: invalid master key size", + keyLen = masterKeyLen, expected = LionessMasterKeyLen + return err(LionessError.InvalidMasterKey) + if ivLen != LionessIvLen: + error "LIONESS init: invalid iv size", ivLen = ivLen, expected = LionessIvLen + return err(LionessError.InvalidIv) + ok() + +proc init*( + T: type Lioness, masterKey, iv: openArray[byte] +): Result[Lioness, LionessError] = + ## Build a LIONESS instance from a 32-byte master key and a 12-byte ChaCha20 + ## IV. Both are caller-supplied; ``sphinx.nim`` derives them per-hop with + ## the ``"delta_key"`` and ``"delta_iv"`` labels. + ?validateInitInputs(masterKey.len, iv.len) + + var lioness: Lioness + lioness.keys = deriveRoundKeys(masterKey) + for i in 0 ..< LionessIvLen: + lioness.iv[i] = iv[i] + ok(lioness) + +# --------------------------------------------------------------------------- +# Round helpers — operate on the whole block in place; the split point is +# always ``LionessLeftLen``. +# --------------------------------------------------------------------------- + +proc streamRound( + blk: var openArray[byte], subkey: openArray[byte], iv: openArray[byte] +) = + ## ``R ^= ChaCha20(key = L XOR subkey, iv, counter = 0)``. Length of + ## ``subkey``, ``iv`` and ``blk`` are guaranteed by the public ``encrypt`` / + ## ``decrypt`` callers and the fixed-size fields on ``Lioness``. + var roundKey: array[LionessLeftLen, byte] + for i in 0 ..< LionessLeftLen: + roundKey[i] = blk[i] xor subkey[i] + + let rightLen = blk.len - LionessLeftLen + discard chacha20CtRun( + addr roundKey[0], + unsafeAddr iv[0], + 0'u32, + addr blk[LionessLeftLen], + csize_t(rightLen), + ) + + burnMem(roundKey) + +proc hashRound(blk: var openArray[byte], subkey: openArray[byte]) = + ## ``L ^= Blake2b_{subkey}(R)`` (32-byte digest). Length of ``subkey`` and + ## ``blk`` are guaranteed by the public ``encrypt`` / ``decrypt`` callers + ## and the fixed-size fields on ``Lioness``. + var ctx: Blake2bContext[256] + ctx.init(subkey) + ctx.update(blk.toOpenArray(LionessLeftLen, blk.high)) + let digest = ctx.finish() + ctx.clear() + + for i in 0 ..< LionessLeftLen: + blk[i] = blk[i] xor digest.data[i] + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + +proc encrypt*(self: Lioness, blk: var openArray[byte]): Result[void, LionessError] = + ## Encrypt one wide block in place. + if blk.len < LionessMinBlockLen: + error "LIONESS encrypt: block below minimum size", + blkLen = blk.len, minLen = LionessMinBlockLen + return err(LionessError.BlockTooSmall) + + streamRound(blk, self.keys.k1, self.iv) + hashRound(blk, self.keys.k2) + streamRound(blk, self.keys.k3, self.iv) + hashRound(blk, self.keys.k4) + ok() + +proc decrypt*(self: Lioness, blk: var openArray[byte]): Result[void, LionessError] = + ## Decrypt one wide block in place. The destination hop should additionally + ## verify the leading-zeros tag with ``hasLeadingZeros`` to detect tampering. + if blk.len < LionessMinBlockLen: + error "LIONESS decrypt: block below minimum size", + blkLen = blk.len, minLen = LionessMinBlockLen + return err(LionessError.BlockTooSmall) + + hashRound(blk, self.keys.k4) + streamRound(blk, self.keys.k3, self.iv) + hashRound(blk, self.keys.k2) + streamRound(blk, self.keys.k1, self.iv) + ok() + +func hasLeadingZeros*(blk: openArray[byte], k: int): bool = + ## True iff the first ``k`` bytes of ``blk`` are zero. Used by the destination + ## hop after decryption to verify the integrity tag prepended by the sender. + if blk.len < k or k < 0: + return false + var acc: byte = 0 + for i in 0 ..< k: + acc = acc or blk[i] + acc == 0 diff --git a/libp2p_mix/mix_protocol.nim b/libp2p_mix/mix_protocol.nim index a42a914..45e2b13 100644 --- a/libp2p_mix/mix_protocol.nim +++ b/libp2p_mix/mix_protocol.nim @@ -872,7 +872,9 @@ proc reply( error "could not build reply message", err = error return - let sphinxPacket = useSURB(surb, message) + let sphinxPacket = useSURB(surb, message).valueOr: + error "could not build SURB sphinx packet", err = error + return let sendRes = await mixProto.sendPacket( peerId, multiAddr, sphinxPacket, SendPacketLogConfig(logType: Reply) diff --git a/libp2p_mix/sphinx.nim b/libp2p_mix/sphinx.nim index 21b656e..d7f357a 100644 --- a/libp2p_mix/sphinx.nim +++ b/libp2p_mix/sphinx.nim @@ -2,7 +2,7 @@ # Copyright (c) Status Research & Development GmbH import results, sequtils -import ./[crypto, curve25519, delay, serialization, tag_manager] +import ./[crypto, curve25519, delay, lioness, serialization, tag_manager] import libp2p/crypto/crypto import libp2p/utils/sequninit @@ -68,6 +68,20 @@ proc computeAlpha( proc deriveKeyMaterial(keyName: string, s: seq[byte]): seq[byte] = @(keyName.toOpenArrayByte(0, keyName.high)) & s +proc deriveLionessKey(secret: seq[byte]): array[LionessMasterKeyLen, byte] = + ## 32-byte LIONESS master derived from a per-hop shared secret. + ## Domain-separated from header/MAC keys via the ``"delta_key"`` label. + sha256_hash(deriveKeyMaterial("delta_key", secret)) + +proc deriveLionessIv(secret: seq[byte]): array[LionessIvLen, byte] = + ## 12-byte ChaCha20 IV for the LIONESS payload cipher, derived per-hop with + ## the ``"delta_iv"`` label — same labeled-SHA-256 pattern used above for + ## header ``aes_key``/``iv``/``mac_key``. Truncated from a full SHA-256 + ## digest. + let full = sha256_hash(deriveKeyMaterial("delta_iv", secret)) + for i in 0 ..< LionessIvLen: + result[i] = full[i] + proc computeFillerStrings(s: seq[seq[byte]]): Result[seq[byte], string] = var filler: seq[byte] = @[] # Start with an empty filler string @@ -137,23 +151,22 @@ proc computeBetaGamma( return ok((beta: beta, gamma: gamma)) proc computeDelta(s: seq[seq[byte]], msg: Message): Result[seq[byte], string] = - let sLen = s.len - var delta: seq[byte] - - for i in countdown(sLen - 1, 0): - # Derive AES key and IV - let - delta_aes_key = deriveKeyMaterial("delta_aes_key", s[i]).kdf() - delta_iv = deriveKeyMaterial("delta_iv", s[i]).kdf() - - # Compute Delta - if i == sLen - 1: - let serializedMsg = msg.serialize() - delta = aes_ctr(delta_aes_key, delta_iv, serializedMsg) - else: - delta = aes_ctr(delta_aes_key, delta_iv, delta) - - return ok(delta) + ## Encrypt the payload with one LIONESS layer per hop, innermost first. The + ## serialized message already carries the k leading-zero integrity tag (see + ## ``Message.serialize``); LIONESS's wide-block PRP property propagates any + ## tampering across the whole block, so the destination's zero-prefix check + ## detects it. + var delta = msg.serialize() + + for i in countdown(s.len - 1, 0): + var cipher = Lioness.init(deriveLionessKey(s[i]), deriveLionessIv(s[i])).valueOr: + return err("LIONESS init failed: " & $error) + defer: + cipher.clear() + if cipher.encrypt(delta).isErr: + return err("LIONESS encrypt failed: payload below minimum block size") + + ok(delta) proc createSURB*( publicKeys: openArray[FieldElement], @@ -189,33 +202,44 @@ proc createSURB*( ) ) -proc useSURB*(surb: SURB, msg: Message): SphinxPacket = - # Derive AES key and IV - let - delta_aes_key = deriveKeyMaterial("delta_aes_key", surb.key).kdf() - delta_iv = deriveKeyMaterial("delta_iv", surb.key).kdf() - - # Compute Delta - let serializedMsg = msg.serialize() - let delta = aes_ctr(delta_aes_key, delta_iv, serializedMsg) +proc useSURB*(surb: SURB, msg: Message): Result[SphinxPacket, string] = + var delta = msg.serialize() + var cipher = Lioness.init(deriveLionessKey(surb.key), deriveLionessIv(surb.key)).valueOr: + return err("LIONESS init failed: " & $error) + defer: + cipher.clear() + if cipher.encrypt(delta).isErr: + return err("LIONESS encrypt failed: payload below minimum block size") - return SphinxPacket.init(surb.header, delta) + ok(SphinxPacket.init(surb.header, delta)) proc processReply*( key: seq[byte], s: seq[seq[byte]], delta_prime: seq[byte] ): Result[seq[byte], string] = + ## Recover the reply plaintext from ``delta_prime``. Each mix hop along the + ## SURB path applied one ``LIONESS_decrypt`` (via ``processSphinxPacket``); + ## ``useSURB`` applied one ``LIONESS_encrypt`` with ``surb.key``. To invert, + ## reverse the per-hop decrypts (apply ``encrypt`` in reverse path order), + ## then decrypt the SURB-key layer. var delta = delta_prime[0 ..^ 1] - var key_prime = key - for i in 0 .. s.len: - if i != 0: - key_prime = s[i - 1] + for i in countdown(s.len - 1, 0): + var cipher = Lioness.init(deriveLionessKey(s[i]), deriveLionessIv(s[i])).valueOr: + return err("LIONESS init failed: " & $error) + defer: + cipher.clear() + if cipher.encrypt(delta).isErr: + return err("LIONESS encrypt failed: payload below minimum block size") - let - delta_aes_key = deriveKeyMaterial("delta_aes_key", key_prime).kdf() - delta_iv = deriveKeyMaterial("delta_iv", key_prime).kdf() + var cipher = Lioness.init(deriveLionessKey(key), deriveLionessIv(key)).valueOr: + return err("LIONESS init failed: " & $error) + defer: + cipher.clear() + if cipher.decrypt(delta).isErr: + return err("LIONESS decrypt failed: payload below minimum block size") - delta = aes_ctr(delta_aes_key, delta_iv, delta) + if not delta.hasLeadingZeros(k): + return err("delta_prime should be all zeros") let deserializeMsg = Message.deserialize(delta).valueOr: return err("Message deserialization error: " & error) @@ -343,16 +367,20 @@ proc processSphinxPacket*( if sharedSecret.isNone: tm.addTag(tag) - # Derive AES key and IV + # Derive AES key and IV (header keystream stays AES-CTR; only the payload + # uses LIONESS). let beta_aes_key = deriveKeyMaterial("aes_key", sBytes).kdf() beta_iv = deriveKeyMaterial("iv", sBytes).kdf() - delta_aes_key = deriveKeyMaterial("delta_aes_key", sBytes).kdf() - delta_iv = deriveKeyMaterial("delta_iv", sBytes).kdf() - - # Compute delta - let delta_prime = aes_ctr(delta_aes_key, delta_iv, payload) + # Decrypt one LIONESS layer of the payload + var delta_prime = payload[0 ..^ 1] + var cipher = Lioness.init(deriveLionessKey(sBytes), deriveLionessIv(sBytes)).valueOr: + return err("LIONESS init failed: " & $error) + defer: + cipher.clear() + if cipher.decrypt(delta_prime).isErr: + return err("LIONESS decrypt failed: payload below minimum block size") # Compute B let zeroPadding = newSeq[byte]((t + 1) * k) diff --git a/tests/test_lioness.nim b/tests/test_lioness.nim new file mode 100644 index 0000000..472da2b --- /dev/null +++ b/tests/test_lioness.nim @@ -0,0 +1,257 @@ +# SPDX-License-Identifier: Apache-2.0 OR MIT +# Copyright (c) Status Research & Development GmbH + +{.used.} + +import results +import nimcrypto/[blake2, keccak, utils] +import bearssl/[abi/bearssl_block, rand] +import libp2p_mix/lioness +import ./tools/[unittest, crypto] + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +func pat(seed: byte, n: int): seq[byte] = + ## Deterministic byte pattern: ``i * 31 + seed`` mod 256. + var bytes = newSeq[byte](n) + for i in 0 ..< n: + bytes[i] = byte(((uint32(i) * 31'u32 + uint32(seed)) and 0xff'u32)) + bytes + +func incrementing32(): array[32, byte] = + var bytes: array[32, byte] + for i in 0 ..< 32: + bytes[i] = byte(i) + bytes + +const TestIv: array[12, byte] = + [byte 0x63, 0x68, 0x61, 0x63, 0x68, 0x61, 0x32, 0x30, 0x5f, 0x69, 0x76, 0x00] + ## Fixed 12-byte ChaCha20 nonce ("chacha20_iv\0") used to make LIONESS test + ## vectors deterministic. Production code derives a per-hop IV via + ## ``deriveLionessIv`` in ``sphinx.nim``. + +# --------------------------------------------------------------------------- +# Sub-primitive vectors (lock the underlying libraries to known outputs; +# LIONESS correctness is verified separately below). +# --------------------------------------------------------------------------- + +suite "lioness_subprimitive_vectors": + test "shake128_kdf_128_bytes": + let masterKey = incrementing32() + + var + ctx: shake128 + material: array[128, byte] + ctx.init() + ctx.update(masterKey) + ctx.xof() + discard ctx.output(addr material[0], uint(material.len)) + ctx.clear() + + # First 128 bytes of SHAKE128(0x00..0x1f). Derived from prior 192-byte + # vector by truncation; nimcrypto SHAKE128 is FIPS 202-conformant. + let expected = fromHex( + "066a361dc675f856cecdc02b25218a10cec0cecf79859ec0fec3d409e5847a92" & + "ba9d4e33d16a3a44cc39b1bdd205b41ba54309172b81078a46b4100571f22208" & + "6fd89eb089deaf90bf6fbc7d22b3457789f97d11218a0edcfe8d1319a3e6b458" & + "dfc55e49af14d2ea120935e76e56c7cf6b13929967b9df8e62ff11dc05a3fafc" + ) + + check @material == expected + + test "blake2b_keyed_256_patterned_32byte_key": + var key: array[32, byte] + for i in 0 ..< 32: + key[i] = byte(((uint32(i) * 7'u32 + 3'u32) and 0xff'u32)) + let msg = pat(0xAB'u8, 200) + + var ctx: Blake2bContext[256] + ctx.init(key) + ctx.update(msg) + let digest = ctx.finish() + ctx.clear() + + let expected = + fromHex("8c13e8acdcf64a752d384360e72f6ec7fe0810ae2308269899065f094711bb5c") + check @(digest.data) == expected + + test "blake2b_keyed_256_empty_message_32byte_key": + var key: array[32, byte] + for i in 0 ..< 32: + key[i] = 0x42'u8 + + var ctx: Blake2bContext[256] + ctx.init(key) + let digest = ctx.finish() + ctx.clear() + + let expected = + fromHex("488104f5152c94cb119e67bb1c28fb1600493614d1620f870ce9d7f197aac926") + check @(digest.data) == expected + + test "chacha20_keystream_128_bytes": + var key: array[32, byte] + for i in 0 ..< 32: + key[i] = byte(((uint32(i) * 13'u32 + 1'u32) and 0xff'u32)) + + var data: array[128, byte] # all zeros -> output is the keystream + discard chacha20CtRun( + addr key[0], unsafeAddr TestIv[0], 0'u32, addr data[0], csize_t(data.len) + ) + + let expected = fromHex( + "fd8e4a87e5ffdfd8e95be1c56cd8efaa4e0ad150b04f831052b740b1a3dc4413" & + "36e6e18043f3356685e9dc85bce88c53cea52e79937ed78853aa9cd5acb574de" & + "83ab0e5ef4d3ecd249cf6fa762de6f69b2fd7b9e54f4d5e668c5b81b14c98a22" & + "5cc9e717335ae020b507a1cae83b70702bb9a1e1d484bdca94d03af0b9e6947b" + ) + check @data == expected + +# --------------------------------------------------------------------------- +# End-to-end LIONESS reference vectors. Any mismatch here is a bug — +# investigate before changing the vector. +# --------------------------------------------------------------------------- + +suite "lioness_reference_vectors": + test "encrypt_64_byte_block_constant_84": + let masterKey = incrementing32() + var blk = newSeq[byte](64) + for i in 0 ..< blk.len: + blk[i] = 0x84'u8 + + let cipher = Lioness.init(masterKey, TestIv).expect("init should succeed") + cipher.encrypt(blk).expect("encrypt should succeed") + + let expected = fromHex( + "c17d7f97f640feb6c7dcb4cf22d53c5e265e822ac3f9a51a422bc28ca4ebc72e" & + "ecb90c265a8c4a92a0aabb1d08ce90e3a2ce1f0a6a49d5a7bf9f8bdb2a9ec0f2" + ) + check blk == expected + + test "encrypt_minimum_64_byte_patterned_block": + let masterKey = incrementing32() + var blk = pat(0x11'u8, LionessMinBlockLen) + + let cipher = Lioness.init(masterKey, TestIv).expect("init should succeed") + cipher.encrypt(blk).expect("encrypt should succeed") + + let expected = fromHex( + "c73dfd045a98e5f3d074a8d96c3fe366dd4c1fe10b04912b155887630a614fd1" & + "c97bbe176845817080fffc623de63e5406448be30ec8c62a6e2eb9556929a9bc" + ) + check blk == expected + + test "encrypt_64_byte_block_alternate_master_key": + var masterKey: array[32, byte] + for i in 0 ..< masterKey.len: + masterKey[i] = 0xFE'u8 + var blk = newSeq[byte](64) + for i in 0 ..< blk.len: + blk[i] = 0x84'u8 + + let cipher = Lioness.init(masterKey, TestIv).expect("init should succeed") + cipher.encrypt(blk).expect("encrypt should succeed") + + let expected = fromHex( + "f885d2c8958b1110025b3385fc7d6f1c23aa99fb1e2f3bff96a2332328224fbf" & + "2284fbf12201c4a0a86a1754efff4cf9b4a91d16dafc5dbae9944079488b4ea4" + ) + check blk == expected + +# --------------------------------------------------------------------------- +# Behavioural tests +# --------------------------------------------------------------------------- + +suite "lioness_behaviour": + test "round_trip_recovers_plaintext": + var masterKey: array[32, byte] + rng[].generate(masterKey) + let cipher = Lioness.init(masterKey, TestIv).expect("init should succeed") + + for size in [LionessMinBlockLen, 128, 1024, 4096]: + var blk = newSeq[byte](size) + rng[].generate(blk) + let original = blk[0 .. blk.high] # explicit slice forces a fresh buffer + + cipher.encrypt(blk).expect("encrypt should succeed") + check blk != original + cipher.decrypt(blk).expect("decrypt should succeed") + check blk == original + + test "blocks_below_min_size_are_rejected": + let cipher = Lioness.init(incrementing32(), TestIv).expect("init should succeed") + var tooSmall = newSeq[byte](LionessLeftLen) # exactly L, no R + + check cipher.encrypt(tooSmall).error == LionessError.BlockTooSmall + check cipher.decrypt(tooSmall).error == LionessError.BlockTooSmall + + test "init_rejects_invalid_key_or_iv_sizes": + let + shortKey = newSeq[byte](LionessMasterKeyLen - 1) + goodKey = newSeq[byte](LionessMasterKeyLen) + shortIv = newSeq[byte](LionessIvLen - 1) + goodIv = @TestIv + + check Lioness.init(shortKey, goodIv).error == LionessError.InvalidMasterKey + check Lioness.init(goodKey, shortIv).error == LionessError.InvalidIv + check Lioness.init(goodKey, goodIv).isOk + + test "different_master_keys_produce_different_ciphertexts": + var key1, key2: array[32, byte] + for i in 0 ..< 32: + key1[i] = byte(i) + key2[i] = byte(i) xor 0xff'u8 + + var blk1 = newSeq[byte](256) + rng[].generate(blk1) + var blk2 = blk1 + + Lioness.init(key1, TestIv).expect("init should succeed").encrypt(blk1).expect( + "encrypt should succeed" + ) + Lioness.init(key2, TestIv).expect("init should succeed").encrypt(blk2).expect( + "encrypt should succeed" + ) + check blk1 != blk2 + + test "tampering_detected_by_leading_zeros_check": + # Sphinx-style: prepend k=16 zero bytes, encrypt, flip a byte deep in the + # ciphertext (well beyond the first k bytes — the scenario from PR #2233), + # decrypt, and check the integrity tag. + const k = 16 + let masterKey = incrementing32() + let cipher = Lioness.init(masterKey, TestIv).expect("init should succeed") + + var blk = newSeq[byte](512) + rng[].generate(blk) + for i in 0 ..< k: + blk[i] = 0 + let tagged = blk[0 .. blk.high] + + cipher.encrypt(blk).expect("encrypt should succeed") + + # Round-trip without tampering passes the integrity check. + var clean = blk[0 .. blk.high] + cipher.decrypt(clean).expect("decrypt should succeed") + check hasLeadingZeros(clean, k) + check clean == tagged + + # Flip a single bit far beyond the leading-zeros window. + blk[256] = blk[256] xor 0x01'u8 + cipher.decrypt(blk).expect("decrypt should succeed") + check not hasLeadingZeros(blk, k) + + test "has_leading_zeros_edge_cases": + var allZero = newSeq[byte](32) + check hasLeadingZeros(allZero, 16) + check hasLeadingZeros(allZero, 32) + check not hasLeadingZeros(allZero, 33) # k > len + + var oneNonZero = newSeq[byte](32) + oneNonZero[10] = 0x01'u8 + check hasLeadingZeros(oneNonZero, 10) + check not hasLeadingZeros(oneNonZero, 11) + + check hasLeadingZeros(allZero, 0) # k = 0 trivially holds diff --git a/tests/test_sphinx.nim b/tests/test_sphinx.nim index 4c0b837..7dfc7f9 100644 --- a/tests/test_sphinx.nim +++ b/tests/test_sphinx.nim @@ -191,6 +191,45 @@ suite "Sphinx Tests": exitResult.isErr() exitResult.error() == "delta_prime should be all zeros" + test "Delta integrity test": + # From vacp2p/nim-libp2p#2233: tampering past the first k bytes of Delta + # was undetectable under AES-CTR (malleable XOR). LIONESS's wide-block PRP + # property means any single-byte flip diffuses across the whole block at + # the exit hop's decrypt, destroying the leading-zeros tag. + let (message, privateKeys, publicKeys, delay, hops, dest) = createDummyData() + let sp = wrapInSphinxPacket(message, publicKeys, delay, hops, dest).expect( + "sphinx wrap error" + ) + var packetBytes = sp.serialize() + + # Tamper a byte in the encrypted message region of Delta, past the leading + # k-byte zero prefix that processSphinxPacket checks at the exit hop. + let tamperedOffset = HeaderSize + k + 7 + packetBytes[tamperedOffset] = packetBytes[tamperedOffset] xor 0x01 + + let tamperedPacket = + SphinxPacket.deserialize(packetBytes).expect("deserialize error") + + let processedSP1 = + processSphinxPacket(tamperedPacket, privateKeys[0], tm).expect("processing error") + check processedSP1.status == Intermediate + + let packet2 = SphinxPacket.deserialize(processedSP1.serializedSphinxPacket).expect( + "deserialize error" + ) + let processedSP2 = + processSphinxPacket(packet2, privateKeys[1], tm).expect("processing error") + check processedSP2.status == Intermediate + + let packet3 = SphinxPacket.deserialize(processedSP2.serializedSphinxPacket).expect( + "deserialize error" + ) + let exitResult = processSphinxPacket(packet3, privateKeys[2], tm) + + check: + exitResult.isErr() + exitResult.error() == "delta_prime should be all zeros" + test "sphinx process duplicate tag": let (message, privateKeys, publicKeys, delay, hops, dest) = createDummyData() @@ -268,7 +307,8 @@ suite "Sphinx Tests": let surb = createSURB(publicKeys, delay, hops, randomI(), rng()).expect("Create SURB error") - let packetBytes = useSURB(surb, message).serialize() + let packetBytes = + useSURB(surb, message).expect("useSURB should succeed").serialize() check packetBytes.len == PacketSize @@ -333,7 +373,8 @@ suite "Sphinx Tests": let surb = createSURB(publicKeys, delay, hops, randomI(), rng()).expect("Create SURB error") - let packetBytes = useSURB(surb, message).serialize() + let packetBytes = + useSURB(surb, message).expect("useSURB should succeed").serialize() check packetBytes.len == PacketSize @@ -356,7 +397,8 @@ suite "Sphinx Tests": let surb = createSURB(publicKeys, delay, hops, randomI(), rng()).expect("Create SURB error") - let packetBytes = useSURB(surb, message).serialize() + let packetBytes = + useSURB(surb, message).expect("useSURB should succeed").serialize() check packetBytes.len == PacketSize @@ -387,7 +429,9 @@ suite "Sphinx Tests": "Create SURB error" ) - let packetBytes = useSURB(surb, Message(paddedMessage)).serialize() + let packetBytes = useSURB(surb, Message(paddedMessage)) + .expect("useSURB should succeed") + .serialize() check packetBytes.len == PacketSize