Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
210 changes: 210 additions & 0 deletions libp2p_mix/lioness.nim
Original file line number Diff line number Diff line change
@@ -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
4 changes: 3 additions & 1 deletion libp2p_mix/mix_protocol.nim
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
112 changes: 70 additions & 42 deletions libp2p_mix/sphinx.nim
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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],
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
Loading