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 commit 9fc162dff8.

* update submodules

* fix Jenkins caching

* Revert "update submodules"

This reverts commit 1927d9dd26.

* 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:
G
2022-05-20 15:51:36 +02:00
committed by GitHub
parent 35045d9d97
commit 710ce1315a
2 changed files with 784 additions and 12 deletions
+257 -2
View File
@@ -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)