mirror of
https://github.com/logos-messaging/logos-delivery.git
synced 2026-07-25 22:13:12 +00:00
feat(noise): add Noise Handshake State Machine and primitives (#933)
* feat(noise): adding ChaChaPoly encryption * feat(noise): add support to Noise public keys * feat(noise): added support to Waku Payload V2 * fix(noise): missing comma * feat(noise): add Noise Handshake State Machine and primitives * Fixed header * refactor(noise): add comments, restyle code * refactor(noise): address reviewer's comments * feat(noise): add Noise Handshake State Machine and primitives * refactor(noise): rebase * refactor(noise): refactor and add documentation * fix(noise): fixed trace parameter * update submodules * fix(noise): remove echo, add stdout.write in print handshake * refactor(noise): add Noise state machine overview * Revert "update submodules" This reverts commit9fc162dff8. * update submodules * fix Jenkins caching * Revert "update submodules" This reverts commit1927d9dd26. * revert nim-eth submodule update * fix(noise): missing camelCase * feat(noise): adding unit tests for Noise State machine primitives * fix(noise): address reviewers' comments * fix(noise): use expect for error handling in unit tests * fix(noise): revert to implicit nonce declaration in CipherState initialization
This commit is contained in:
@@ -3,11 +3,15 @@
|
||||
import
|
||||
testutils/unittests,
|
||||
std/random,
|
||||
std/tables,
|
||||
stew/byteutils,
|
||||
../../waku/v2/node/waku_payload,
|
||||
../../waku/v2/protocol/waku_noise/noise,
|
||||
../../waku/v2/protocol/waku_message,
|
||||
../test_helpers
|
||||
../test_helpers,
|
||||
libp2p/crypto/chacha20poly1305,
|
||||
stew/endians2
|
||||
|
||||
|
||||
procSuite "Waku Noise":
|
||||
|
||||
@@ -157,4 +161,255 @@ procSuite "Waku Noise":
|
||||
|
||||
check:
|
||||
decoded.isOk()
|
||||
payload2 == decoded.get()
|
||||
payload2 == decoded.get()
|
||||
|
||||
test "Noise State Machine: Diffie-Hellman operation":
|
||||
|
||||
#We generate random keypairs
|
||||
let
|
||||
aliceKey = genKeyPair(rng[])
|
||||
bobKey = genKeyPair(rng[])
|
||||
|
||||
# A Diffie-Hellman operation between Alice's private key and Bob's public key must be equal to
|
||||
# a Diffie-hellman operation between Alice's public key and Bob's private key
|
||||
let
|
||||
dh1 = dh(getPrivateKey(aliceKey), getPublicKey(bobKey))
|
||||
dh2 = dh(getPrivateKey(bobKey), getPublicKey(aliceKey))
|
||||
|
||||
check:
|
||||
dh1 == dh2
|
||||
|
||||
test "Noise State Machine: Cipher State primitives":
|
||||
|
||||
# We generate a random Cipher State, associated data ad and plaintext
|
||||
var
|
||||
cipherState: CipherState = randomCipherState(rng[])
|
||||
nonce: uint64 = uint64(rand(0 .. int.high))
|
||||
ad: seq[byte] = randomSeqByte(rng[], rand(1..128))
|
||||
plaintext: seq[byte] = randomSeqByte(rng[], rand(1..128))
|
||||
|
||||
# We set the random nonce generated in the cipher state
|
||||
setNonce(cipherState, nonce)
|
||||
|
||||
# We perform encryption
|
||||
var ciphertext: seq[byte] = encryptWithAd(cipherState, ad, plaintext)
|
||||
|
||||
# After any encryption/decryption operation, the Cipher State's nonce increases by 1
|
||||
check:
|
||||
getNonce(cipherState) == nonce + 1
|
||||
|
||||
# We set the nonce back to its original value for decryption
|
||||
setNonce(cipherState, nonce)
|
||||
|
||||
# We decrypt (using the original nonce)
|
||||
var decrypted: seq[byte] = decryptWithAd(cipherState, ad, ciphertext)
|
||||
|
||||
# We check if encryption and decryption are correct and that nonce correctly increased after decryption
|
||||
check:
|
||||
getNonce(cipherState) == nonce + 1
|
||||
plaintext == decrypted
|
||||
|
||||
|
||||
# If a Cipher State has no key set, encryptWithAd should return the plaintext without increasing the nonce
|
||||
setCipherStateKey(cipherState, EmptyKey)
|
||||
nonce = getNonce(cipherState)
|
||||
|
||||
plaintext = randomSeqByte(rng[], rand(1..128))
|
||||
ciphertext = encryptWithAd(cipherState, ad, plaintext)
|
||||
|
||||
check:
|
||||
ciphertext == plaintext
|
||||
getNonce(cipherState) == nonce
|
||||
|
||||
# If a Cipher State has no key set, decryptWithAd should return the ciphertext without increasing the nonce
|
||||
setCipherStateKey(cipherState, EmptyKey)
|
||||
nonce = getNonce(cipherState)
|
||||
|
||||
# Note that we set ciphertext minimum length to 16 to not trigger checks on authentication tag length
|
||||
ciphertext = randomSeqByte(rng[], rand(16..128))
|
||||
plaintext = decryptWithAd(cipherState, ad, ciphertext)
|
||||
|
||||
check:
|
||||
ciphertext == plaintext
|
||||
getNonce(cipherState) == nonce
|
||||
|
||||
# A Cipher State cannot have a nonce greater or equal 2^64-1
|
||||
# Note that NonceMax is uint64.high - 1 = 2^64-1-1 and that nonce is increased after each encryption and decryption operation
|
||||
|
||||
# We generate a test Cipher State with nonce set to MaxNonce
|
||||
cipherState = randomCipherState(rng[])
|
||||
setNonce(cipherState, NonceMax)
|
||||
plaintext = randomSeqByte(rng[], rand(1..128))
|
||||
|
||||
# We test if encryption fails with a NoiseNonceMaxError error. Any subsequent encryption call over the Cipher State should fail similarly and leave the nonce unchanged
|
||||
for _ in [1..5]:
|
||||
expect NoiseNonceMaxError:
|
||||
ciphertext = encryptWithAd(cipherState, ad, plaintext)
|
||||
|
||||
check:
|
||||
getNonce(cipherState) == NonceMax + 1
|
||||
|
||||
# We generate a test Cipher State
|
||||
# Since nonce is increased after decryption as well, we need to generate a proper ciphertext in order to test MaxNonceError error handling
|
||||
# We cannot call encryptWithAd to encrypt a plaintext using a nonce equal MaxNonce, since this will trigger a MaxNonceError.
|
||||
# To perform such test, we then need to encrypt a test plaintext using directly ChaChaPoly primitive
|
||||
cipherState = randomCipherState(rng[])
|
||||
setNonce(cipherState, NonceMax)
|
||||
plaintext = randomSeqByte(rng[], rand(1..128))
|
||||
|
||||
# We perform encryption using the Cipher State key, NonceMax and ad
|
||||
# By Noise specification the nonce is 8 bytes long out of the 12 bytes supported by ChaChaPoly, thus we copy the Little endian conversion of the nonce to a ChaChaPolyNonce
|
||||
var
|
||||
encNonce: ChaChaPolyNonce
|
||||
authorizationTag: ChaChaPolyTag
|
||||
encNonce[4..<12] = toBytesLE(NonceMax)
|
||||
ChaChaPoly.encrypt(getKey(cipherState), encNonce, authorizationTag, plaintext, ad)
|
||||
|
||||
# The output ciphertext is stored in the plaintext variable after ChaChaPoly.encrypt is called: we copy it along with the authorization tag.
|
||||
ciphertext = @[]
|
||||
ciphertext.add(plaintext)
|
||||
ciphertext.add(authorizationTag)
|
||||
|
||||
# At this point ciphertext is a proper encryption of the original plaintext obtained with nonce equal to NonceMax
|
||||
# We can now test if decryption fails with a NoiseNonceMaxError error. Any subsequent decryption call over the Cipher State should fail similarly and leave the nonce unchanged
|
||||
# Note that decryptWithAd doesn't fail in decrypting the ciphertext (otherwise a NoiseDecryptTagError would have been triggered)
|
||||
for _ in [1..5]:
|
||||
expect NoiseNonceMaxError:
|
||||
plaintext = decryptWithAd(cipherState, ad, ciphertext)
|
||||
|
||||
check:
|
||||
getNonce(cipherState) == NonceMax + 1
|
||||
|
||||
test "Noise State Machine: Symmetric State primitives":
|
||||
|
||||
# We select one supported handshake pattern and we initialize a symmetric state
|
||||
var
|
||||
hsPattern = NoiseHandshakePatterns["XX"]
|
||||
symmetricState: SymmetricState = SymmetricState.init(hsPattern)
|
||||
|
||||
# We get all the Symmetric State field
|
||||
# cs : Cipher State
|
||||
# ck : chaining key
|
||||
# h : handshake hash
|
||||
var
|
||||
cs = getCipherState(symmetricState)
|
||||
ck = getChainingKey(symmetricState)
|
||||
h = getHandshakeHash(symmetricState)
|
||||
|
||||
# When a Symmetric state is initialized, handshake hash and chaining key are (byte-wise) equal
|
||||
check:
|
||||
h.data.intoChaChaPolyKey == ck
|
||||
|
||||
########################################
|
||||
# mixHash
|
||||
########################################
|
||||
|
||||
# We generate a random byte sequence and execute a mixHash over it
|
||||
mixHash(symmetricState, randomSeqByte(rng[], rand(1..128)))
|
||||
|
||||
# mixHash changes only the handshake hash value of the Symmetric state
|
||||
check:
|
||||
cs == getCipherState(symmetricState)
|
||||
ck == getChainingKey(symmetricState)
|
||||
h != getHandshakeHash(symmetricState)
|
||||
|
||||
# We update test values
|
||||
h = getHandshakeHash(symmetricState)
|
||||
|
||||
########################################
|
||||
# mixKey
|
||||
########################################
|
||||
|
||||
# We generate random input key material and we execute mixKey
|
||||
var inputKeyMaterial = randomChaChaPolyKey(rng[])
|
||||
mixKey(symmetricState, inputKeyMaterial)
|
||||
|
||||
# mixKey changes the Symmetric State's chaining key and encryption key of the embedded Cipher State
|
||||
# It further sets to 0 the nonce of the embedded Cipher State
|
||||
check:
|
||||
getKey(cs) != getKey(getCipherState(symmetricState))
|
||||
getNonce(getCipherState(symmetricState)) == 0.uint64
|
||||
cs != getCipherState(symmetricState)
|
||||
ck != getChainingKey(symmetricState)
|
||||
h == getHandshakeHash(symmetricState)
|
||||
|
||||
# We update test values
|
||||
cs = getCipherState(symmetricState)
|
||||
ck = getChainingKey(symmetricState)
|
||||
|
||||
########################################
|
||||
# mixKeyAndHash
|
||||
########################################
|
||||
|
||||
# We generate random input key material and we execute mixKeyAndHash
|
||||
inputKeyMaterial = randomChaChaPolyKey(rng[])
|
||||
mixKeyAndHash(symmetricState, inputKeyMaterial)
|
||||
|
||||
# mixKeyAndHash executes a mixKey and a mixHash using the input key material
|
||||
# All Symmetric State's fields are updated
|
||||
check:
|
||||
cs != getCipherState(symmetricState)
|
||||
ck != getChainingKey(symmetricState)
|
||||
h != getHandshakeHash(symmetricState)
|
||||
|
||||
# We update test values
|
||||
cs = getCipherState(symmetricState)
|
||||
ck = getChainingKey(symmetricState)
|
||||
h = getHandshakeHash(symmetricState)
|
||||
|
||||
########################################
|
||||
# encryptAndHash and decryptAndHash
|
||||
########################################
|
||||
|
||||
# We store the initial symmetricState in order to correctly perform decryption
|
||||
var initialSymmetricState = symmetricState
|
||||
|
||||
# We generate random plaintext and we execute encryptAndHash
|
||||
var plaintext = randomChaChaPolyKey(rng[])
|
||||
var nonce = getNonce(getCipherState(symmetricState))
|
||||
var ciphertext = encryptAndHash(symmetricState, plaintext)
|
||||
|
||||
# encryptAndHash combines encryptWithAd and mixHash over the ciphertext (encryption increases the nonce of the embedded Cipher State but does not change its key)
|
||||
# We check if only the handshake hash value and the Symmetric State changed accordingly
|
||||
check:
|
||||
cs != getCipherState(symmetricState)
|
||||
getKey(cs) == getKey(getCipherState(symmetricState))
|
||||
getNonce(getCipherState(symmetricState)) == nonce + 1
|
||||
ck == getChainingKey(symmetricState)
|
||||
h != getHandshakeHash(symmetricState)
|
||||
|
||||
# We restore the symmetric State to its initial value to test decryption
|
||||
symmetricState = initialSymmetricState
|
||||
|
||||
# We execute decryptAndHash over the ciphertext
|
||||
var decrypted = decryptAndHash(symmetricState, ciphertext)
|
||||
|
||||
# decryptAndHash combines decryptWithAd and mixHash over the ciphertext (encryption increases the nonce of the embedded Cipher State but does not change its key)
|
||||
# We check if only the handshake hash value and the Symmetric State changed accordingly
|
||||
# We further check if decryption corresponds to the original plaintext
|
||||
check:
|
||||
cs != getCipherState(symmetricState)
|
||||
getKey(cs) == getKey(getCipherState(symmetricState))
|
||||
getNonce(getCipherState(symmetricState)) == nonce + 1
|
||||
ck == getChainingKey(symmetricState)
|
||||
h != getHandshakeHash(symmetricState)
|
||||
decrypted == plaintext
|
||||
|
||||
########################################
|
||||
# split
|
||||
########################################
|
||||
|
||||
# If at least one mixKey is executed (as above), ck is non-empty
|
||||
check:
|
||||
getChainingKey(symmetricState) != EmptyKey
|
||||
|
||||
# When a Symmetric State's ck is non-empty, we can execute split, which creates two distinct Cipher States cs1 and cs2
|
||||
# with non-empty encryption keys and nonce set to 0
|
||||
var (cs1, cs2) = split(symmetricState)
|
||||
|
||||
check:
|
||||
getKey(cs1) != EmptyKey
|
||||
getKey(cs2) != EmptyKey
|
||||
getNonce(cs1) == 0.uint64
|
||||
getNonce(cs2) == 0.uint64
|
||||
getKey(cs1) != getKey(cs2)
|
||||
|
||||
Reference in New Issue
Block a user