The Noise Protocol Framework
Noise is a framework for building secure channels from Diffie-Hellman exchanges. A protocol is a short pattern of tokens and a name. Learn the three state objects, the 38 handshake patterns, pre-shared keys, and the security properties, with a Python engine that reproduces 1,352 official test vectors.
Pashalis Laoutaris 34 min read
Try it
Interactive Noise Protocol Visualizer
🔐 Noise Protocol
The Noise Protocol Framework
Introduction
Most secure channel protocols are written as long documents with many options. TLS has cipher suites, extensions, and versions. The Noise framework starts from the opposite end. It says that a handshake is a short list of messages, and that each message is built from a few kinds of steps. A protocol is then just a name, such as Noise_XX_25519_ChaChaPoly_BLAKE2s, that tells you which pattern and which algorithms to use.
Noise is a framework for protocols built on Diffie-Hellman key agreement. It can describe a single message sent to a recipient whose key is known, and it can describe a conversation in which both sides prove who they are. This guide builds the framework from the specification, revision 34. It reproduces 1,352 test vectors from two public collections, and it shows with real runs what the handshake hides and what it does not. The protocol WireGuard is built on one of the Noise patterns.
Table of Contents
- History and Standards
- The Idea: a Handshake Made of Tokens
- The Algorithms
- The Three State Objects
- Reading a Pattern: XX
- The 38 Patterns and Their Names
- Pre-Shared Keys
- What the Handshake Guarantees
- Interactive Visualizer
- A Worked Example
- Python Implementation
- Where It Stands
- Limitations
- Security Status
- FAQ
- References
History and Standards
The framework is the work of Trevor Perrin. The specification is revision 34, dated 11 July 2018, and its status line says “official/unstable”. Its change log explains that “unstable” refers only to the deferred patterns that revision 34 added. The rest of the document is considered stable. It is not an RFC. It is a document that is maintained on the website of the project, and it has a change log.
The specification describes itself as a framework for crypto protocols based on Diffie-Hellman key agreement. It defines the processing rules, the notation for handshake patterns, 38 patterns, a naming scheme, the modifier for pre-shared keys, and concrete algorithms: two Diffie-Hellman functions, two ciphers, and four hash functions. It leaves several decisions to the application. These include the format of the payloads, the length fields, and the question of which static keys to trust.
The Idea: a Handshake Made of Tokens
Two parties, the initiator and the responder, exchange handshake messages. Each message is a list of tokens. A token is one step of the handshake:
esends a fresh ephemeral public key.ssends the static public key of the sender, encrypted if a key is already available.ee,es,se, andsseach perform one Diffie-Hellman operation and mix the result into the shared key. The first letter belongs to the initiator and the second to the responder, soescombines the ephemeral key of the initiator with the static key of the responder.pskmixes in a pre-shared key, as explained below.
After the tokens, the message carries a payload, which is encrypted as soon as a key exists. A pattern lists the messages with arrows. This is the pattern called XX:
XX:
-> e
<- e, ee, s, es
-> s, se
The initiator sends its ephemeral key. The responder answers with its own ephemeral key, performs ee, sends its static key encrypted, and performs es. The initiator then sends its static key encrypted and performs se. After three messages both sides know each other’s static key, and both hold the same secret.
Some patterns have pre-messages, which are public keys that the parties exchanged before the handshake. They are listed above a line of three dots. The pattern NK says that the initiator knows the static key of the responder in advance:
NK:
<- s
...
-> e, es
<- e, ee
The Algorithms
A Noise protocol needs three families of functions. The specification defines the names of the concrete choices.
- Diffie-Hellman.
25519is X25519 with keys of 32 bytes, and448is X448 with keys of 56 bytes. An invalid public key gives an output of all zeros, and the specification allows an implementation to signal an error instead. - Cipher.
ChaChaPolyis ChaCha20-Poly1305 from RFC 8439. The nonce is four zero bytes followed by the 64-bit counter in little-endian order.AESGCMis AES-256 in GCM mode with a tag of 128 bits, and its nonce is four zero bytes followed by the counter in big-endian order. - Hash.
SHA256,SHA512,BLAKE2s, andBLAKE2b, with outputs of 32 or 64 bytes.
From the hash function the framework builds HMAC and then a version of HKDF with the chaining key as salt and an empty info string. HKDF(ck, input, 2) returns two blocks of the hash length, and HKDF(ck, input, 3) returns three:
temp_key = HMAC(ck, input)
out1 = HMAC(temp_key, 0x01)
out2 = HMAC(temp_key, out1 || 0x02)
out3 = HMAC(temp_key, out2 || 0x03)
The specification recommends the 25519 functions for typical uses, and it recommends a 512-bit hash with the 448 functions.
The Three State Objects
The processing rules use three nested objects. Each one is small.
CipherState holds a key k, which may be empty, and a 64-bit counter n. When k is empty, encryption returns the plaintext unchanged. Otherwise it encrypts with k and the nonce n, and then it increments n. A failed decryption does not increment n. This detail lets a transport receiver survive a damaged message.
SymmetricState holds a CipherState, a chaining key ck, and a hash h. It starts with the protocol name: if the name has at most as many bytes as the hash, h is the name padded with zeros, and otherwise h is its hash. The chaining key ck starts equal to h. It has these operations.
MixHash(data)updates the hash with the new data, ash = HASH(h || data).MixKey(input)computesck, k = HKDF(ck, input, 2)and starts a new key, withn = 0. A hash of 64 bytes gives a key that is cut to 32 bytes.MixKeyAndHash(input)callsHKDF(ck, input, 3)with three outputs. It takes the second output intohand the third as the new key. This is the operation for pre-shared keys.EncryptAndHash(plaintext)encrypts withhas associated data and then hashes the ciphertext intoh.Split()computesHKDF(ck, empty, 2)and returns two new CipherStates for the transport phase.
HandshakeState holds the SymmetricState, the key pairs s and e, the remote keys rs and re, and the remaining message patterns. Its WriteMessage and ReadMessage go through the tokens of the next message. After the last message they return the two CipherStates.
The point of this design is that h depends on every byte that was sent, and that every encryption authenticates h as associated data. A change anywhere in the handshake makes the next decryption fail.
Reading a Pattern: XX
Walk through the three messages of XX with the code.
- Message 1, initiator to responder. The initiator generates
e, writes its public key, and callsMixHash(e). The payload is encrypted withEncryptAndHash, but no key exists yet, so the payload goes out as it is. The message has 32 bytes of key and the payload. - Message 2, responder to initiator. The responder writes
eand hashes it. It performsee, which gives the first key. Then it sends its static key encrypted, which adds 16 bytes for the tag, and it hashes the result. It performses, which gives a new key, and it encrypts the payload. - Message 3, initiator to responder. The initiator sends its static key encrypted and performs
se. Then it encrypts the payload and callsSplit().
The responder splits after reading the last message. The initiator uses the first CipherState to send and the second to receive, and the responder uses them the other way around. For a payload of 16 bytes, message 1 has 48 bytes. This is why the first payload of a Noise handshake is not confidential: it has no key.
The 38 Patterns and Their Names
The specification names the patterns with letters.
- One-way patterns have one letter for the status of the static key of the sender:
Nfor none,Kfor known to the recipient, andXfor transmitted. They describe messages that flow in one direction, such as an encrypted file. - Fundamental interactive patterns have two letters, for the initiator and for the responder. The letters are
N(no static key),K(known to the other party),X(transmitted), and for the initiator alsoI(transmitted immediately, in the first message, even though identity hiding is reduced or absent). That gives 12 patterns: NN, KN, NK, KK, NX, KX, XN, IN, XK, IK, XX, and IX. - Deferred patterns put the number 1 after a letter to say that the authentication Diffie-Hellman of that party is moved to the next message. The specification lists 23, such as NK1, X1X, and XX1.
My script reads all the patterns from the text of the specification. It finds 3 one-way patterns, 12 fundamental patterns, and 23 deferred patterns, which makes 38. The count agrees with the text. These 38 are the base patterns. Modifiers such as psk0 turn each one into further named patterns, so a count that includes the variants comes out higher. The specification advises that XX is the most generally useful pattern, because it allows mutual authentication and the transmission of static keys.
A protocol name has five parts joined by underscores: Noise, the pattern with its modifiers, the Diffie-Hellman functions, the cipher, and the hash. The name is limited to 255 bytes. It is the first value that goes into h, which means that two parties with different names cannot complete the handshake.
Pre-Shared Keys
Some protocols give both parties a secret key of 32 bytes in advance. Noise supports this with the psk token and the modifiers psk0, psk1, psk2, and so on. The modifier psk0 puts a psk token at the beginning of the first message, and pskN puts it at the end of message N. The pattern NNpsk0 is:
NNpsk0:
-> psk, e
<- e, ee
When the engine reaches a psk token, it calls MixKeyAndHash(psk). In a handshake that uses a pre-shared key there is one more rule: every e token is followed by MixKey(e), so that keys from the pre-shared key are always randomized by an ephemeral public key. The validity rule says that a party may not send encrypted data after a psk token unless it has sent an ephemeral key. This stops a key that comes from the pre-shared key alone from being used twice.
My script reads the 21 psk patterns that the specification writes out, and my modifier code produces the same patterns for all of them. The vector files use the modifiers up to psk0+psk1+psk2+psk3.
What the Handshake Guarantees
The specification gives each handshake payload two numbers. The source number says how well the recipient can tell who sent it. The destination number says how well the sender knows who can read it. For XX the table reads like this.
| Payload | Source | Destination | In words |
|---|---|---|---|
| Message 1 | 0 | 0 | no authentication, no confidentiality |
| Message 2 | 2 | 1 | the responder is authenticated, but the initiator is an ephemeral party that is not authenticated |
| Message 3 | 2 | 5 | the initiator is authenticated, and the payload has strong forward secrecy |
| Later messages | 2 | 5 | the same, in both directions |
Source 2 means authentication that resists key-compromise impersonation, because it uses an ephemeral-static Diffie-Hellman. Destination 5 means encryption with strong forward secrecy to a known recipient. Destination 1 means encryption to a recipient that is not yet authenticated. The numbers 3 and 4 describe weak forms of forward secrecy that depend on how well an ephemeral key is bound to a static key. The specification gives these tables for every pattern. Section 7.7 covers the one-way and fundamental patterns, and section 18.2 covers the deferred ones. The Noise Explorer tool derived that second table.
Identity hiding is a separate property. The specification lists, for every pattern, whether a static public key is sent in clear, encrypted, or not at all. My tests check one part of this table with real runs. For each of the 38 patterns the code runs a handshake and looks for the static public keys in the bytes that cross the network. A key appears in clear in exactly these patterns: I1K1 (initiator), I1N (initiator), I1X (initiator), I1X1 (initiator), IK1 (initiator), IN (initiator), IX (initiator), IX1 (initiator). They are the patterns in which the initiator sends its static key in the first message, before any key exists. That is what the letter I stands for. In all other patterns, the static keys are either known in advance or sent encrypted.
The specification is also clear about what the handshake does not do. The application must decide whether a received static key is acceptable, for instance with certificates or a list of known keys. The framework gives no protection against truncation of the transport stream, and an application should add length fields or an end signal. It also warns that a pre-message public key must not be used as a secret.
Interactive Visualizer
The visualizer above runs real Noise handshakes in your browser. Choose a pattern from the list. The ten patterns include XX, IK, NN, NK, KK, and N, and two with a pre-shared key: XXpsk3 and IKpsk2. The second one is the pattern of WireGuard.
- Keys. Five comma-separated values: the static and ephemeral key of the initiator, the static and ephemeral key of the responder, and the pre-shared key. Each value is 64 hexadecimal digits, or a word that the page turns into a key with SHA-256.
- Prologue. Data that both sides hash into
hbefore the handshake. - Payloads. One payload for each handshake message, separated by a vertical bar, and then the transport messages.
- Damage. A number that flips one bit in that handshake message, so that you can watch the receiver reject it.
The log shows each message with its tokens, the Diffie-Hellman results, the bytes on the wire, and what the receiver does with them. The defaults are the keys, the prologue, and the first payloads of the official test vector for the same protocol, so the final handshake hash equals the value in the test file. The page uses X25519, ChaCha20-Poly1305, and BLAKE2s with its own implementations of each.
A Worked Example
The first official vector for Noise_XX_25519_ChaChaPoly_BLAKE2s uses the prologue John Galt and these keys. The private keys are in the test file, and these are the public keys.
| Key | Public key |
|---|---|
| Initiator ephemeral | ca35def5ae56cec33dc2036731ab14896bc4c75dbb07a61f879f8e3afa4c7944 |
| Responder ephemeral | 95ebc60d2b1fa672c1f46a8aa265ef51bfe38e7ccb39ec5be34069f144808843 |
| Initiator static | 6bc3822a2aa7f4e6981d6538692b3cdf3e6df9eea6ed269eb41d93c22757b75a |
| Responder static | 31e0303fd6418d2f8c0e78b91f22e8caed0fbe48656dcf4767e4834f701b8f62 |
After the initialization, h = 2b71c58d0cc16c227aefb1fa655eba6031691c8fd1729b07c90d6a01ae2027bb. This is the protocol name of 33 bytes, hashed, because it is longer than the hash. Then the three messages follow with the payloads Ludwig von Mises, Murray Rothbard, and F. A. Hayek:
| Message | Bytes | Starts with | h afterwards |
|---|---|---|---|
| 1, initiator to responder | 48 | ca35def5ae56cec33dc2036731ab14896bc4c75d |
44cc38396d1d63fff1a1df70910673cd3c376ea1f73518435ec3abdbe379dc50 |
| 2, responder to initiator | 111 | 95ebc60d2b1fa672c1f46a8aa265ef51bfe38e7c |
3a24afb2e6dd98616709e1b88261a869a6b567f584d3c573e90002d8757cac33 |
| 3, initiator to responder | 75 | 46c3307de83b014258717d97781c1f50936d8b7d |
6c4c56cf71612f72d05ceb96c0155e6f4ea54a26b504c93de632a2db4a49d200 |
Message 1 is the ephemeral key of the initiator, which is exactly the first 32 bytes, followed by the payload in clear. Message 2 has 111 bytes: the 32-byte key, a static key that is encrypted to 48 bytes, and a payload that is encrypted to 31 bytes. Message 3 has 75 bytes: the static key of 48 bytes and a payload of 27 bytes. The final handshake hash is:
6c4c56cf71612f72d05ceb96c0155e6f4ea54a26b504c93de632a2db4a49d200
Both sides hold this value. It is the value in the test file.
Python Implementation
The engine has four files. The Noise file comes first. It reads the patterns from a JSON file that a script produces from the specification. The other files are the primitives that the engine needs: ChaCha20-Poly1305, the two Diffie-Hellman functions, and AES-256-GCM.
# noise.py
# The Noise Protocol Framework (revision 34): CipherState, SymmetricState, and HandshakeState, with the handshake
# patterns of the specification, the psk modifiers, and the suites 25519 and 448, ChaChaPoly and AESGCM, SHA256,
# SHA512, BLAKE2s, and BLAKE2b.
# Teaching code. It is not constant time, and the ephemeral keys come from the caller so that the vectors can be tested.
import hashlib
import json
import os
import aes_gcm
import chacha_poly
from dh import X25519, X448
PATTERNS = json.load(open(os.path.join(os.path.dirname(__file__), "noise_patterns.json")))
DH = {"25519": X25519, "448": X448}
HASHES = {"SHA256": (hashlib.sha256, 64), "SHA512": (hashlib.sha512, 128), "BLAKE2s": (hashlib.blake2s, 64), "BLAKE2b": (hashlib.blake2b, 128)}
# ---------------------------------------------------------------- the cipher functions
def chachapoly_encrypt(k, n, ad, plaintext):
return chacha_poly.aead_encrypt(k, bytes(4) + n.to_bytes(8, "little"), ad, plaintext)
def chachapoly_decrypt(k, n, ad, ciphertext):
return chacha_poly.aead_decrypt(k, bytes(4) + n.to_bytes(8, "little"), ad, ciphertext)
def aesgcm_encrypt(k, n, ad, plaintext):
return aes_gcm.seal(k, bytes(4) + n.to_bytes(8, "big"), ad, plaintext)
def aesgcm_decrypt(k, n, ad, ciphertext):
return aes_gcm.open_(k, bytes(4) + n.to_bytes(8, "big"), ad, ciphertext)
CIPHERS = {"ChaChaPoly": (chachapoly_encrypt, chachapoly_decrypt), "AESGCM": (aesgcm_encrypt, aesgcm_decrypt)}
# ---------------------------------------------------------------- HMAC and HKDF on top of HASH
class Suite:
def __init__(self, dh, cipher, hash_name):
self.dh, (self.encrypt, self.decrypt), (self.hash_new, self.block_len) = DH[dh], CIPHERS[cipher], HASHES[hash_name]
self.hash_len = self.hash_new().digest_size
def hash(self, data):
return self.hash_new(data).digest()
def hmac(self, key, data):
if len(key) > self.block_len:
key = self.hash(key)
key = key.ljust(self.block_len, b"\x00")
inner = self.hash(bytes(b ^ 0x36 for b in key) + data)
return self.hash(bytes(b ^ 0x5C for b in key) + inner)
def hkdf(self, chaining_key, input_key_material, outputs):
temp_key = self.hmac(chaining_key, input_key_material)
out1 = self.hmac(temp_key, b"\x01")
out2 = self.hmac(temp_key, out1 + b"\x02")
if outputs == 2:
return out1, out2
return out1, out2, self.hmac(temp_key, out2 + b"\x03")
# ---------------------------------------------------------------- the three objects
class CipherState:
def __init__(self, suite):
self.suite, self.k, self.n = suite, None, 0
def initialize_key(self, key):
self.k, self.n = key, 0
def has_key(self):
return self.k is not None
def encrypt_with_ad(self, ad, plaintext):
if self.k is None:
return plaintext
out = self.suite.encrypt(self.k, self.n, ad, plaintext)
self.n += 1
return out
def decrypt_with_ad(self, ad, ciphertext):
if self.k is None:
return ciphertext
out = self.suite.decrypt(self.k, self.n, ad, ciphertext) # raises on a bad tag, and n is not incremented
self.n += 1
return out
class SymmetricState:
def __init__(self, suite, protocol_name):
self.suite = suite
if len(protocol_name) <= suite.hash_len:
self.h = protocol_name.ljust(suite.hash_len, b"\x00")
else:
self.h = suite.hash(protocol_name)
self.ck = self.h
self.cipher = CipherState(suite)
def mix_key(self, input_key_material):
self.ck, temp_k = self.suite.hkdf(self.ck, input_key_material, 2)
self.cipher.initialize_key(temp_k[:32])
def mix_hash(self, data):
self.h = self.suite.hash(self.h + data)
def mix_key_and_hash(self, input_key_material):
self.ck, temp_h, temp_k = self.suite.hkdf(self.ck, input_key_material, 3)
self.mix_hash(temp_h)
self.cipher.initialize_key(temp_k[:32])
def encrypt_and_hash(self, plaintext):
ciphertext = self.cipher.encrypt_with_ad(self.h, plaintext)
self.mix_hash(ciphertext)
return ciphertext
def decrypt_and_hash(self, ciphertext):
plaintext = self.cipher.decrypt_with_ad(self.h, ciphertext)
self.mix_hash(ciphertext)
return plaintext
def split(self):
temp_k1, temp_k2 = self.suite.hkdf(self.ck, b"", 2)
c1, c2 = CipherState(self.suite), CipherState(self.suite)
c1.initialize_key(temp_k1[:32])
c2.initialize_key(temp_k2[:32])
return c1, c2
def parse_protocol_name(name):
parts = name.split("_")
assert parts[0] == "Noise" and len(parts) == 5, name
pattern_section, dh, cipher, hash_name = parts[1:]
base = ""
for ch in pattern_section:
if ch.islower():
break
base += ch
modifiers = pattern_section[len(base):].split("+") if len(pattern_section) > len(base) else []
return base, modifiers, Suite(dh, cipher, hash_name)
def build_pattern(base, modifiers):
# the pattern with its psk modifiers: psk0 puts the token psk first in the first message, and pskN puts it
# last in message N
pattern = json.loads(json.dumps(PATTERNS[base]))
for modifier in modifiers:
assert modifier.startswith("psk"), modifier
n = int(modifier[3:])
if n == 0:
pattern["messages"][0].insert(1, "psk")
else:
pattern["messages"][n - 1].append("psk")
return pattern
class HandshakeState:
def __init__(self, protocol_name, initiator, prologue=b"", s=None, e=None, rs=None, re=None, psks=()):
# s and e are private keys (the vectors fix the ephemeral key), rs and re are public keys
base, modifiers, self.suite = parse_protocol_name(protocol_name)
self.pattern = build_pattern(base, modifiers)
self.symmetric = SymmetricState(self.suite, protocol_name.encode())
self.initiator = initiator
self.s = (s, self.suite.dh.public_key(s)) if s is not None else None
self.e = (e, self.suite.dh.public_key(e)) if e is not None else None
self.rs, self.re = rs, re
self.psks = list(psks)
self.has_psk = bool(modifiers)
self.messages = list(self.pattern["messages"])
self.symmetric.mix_hash(prologue)
# the pre-messages: the public keys that were exchanged before the handshake, the initiator's first
for owner_is_initiator, tokens in ((True, self.pattern["initiator_pre"]), (False, self.pattern["responder_pre"])):
mine = owner_is_initiator == initiator
for token in tokens:
if mine:
public = (self.s if token == "s" else self.e)[1]
else:
public = self.rs if token == "s" else self.re
self._mix_e(public) if token == "e" else self.symmetric.mix_hash(public)
self.done = False
def _dh(self, private, public):
return self.suite.dh.scalar_mult(private, public)
def _mix_e(self, public):
self.symmetric.mix_hash(public)
if self.has_psk:
self.symmetric.mix_key(public)
def write_message(self, payload):
arrow_tokens = self.messages.pop(0)
out = b""
for token in arrow_tokens[1:]:
if token == "e":
assert self.e is not None, "an ephemeral key is needed"
out += self.e[1]
self._mix_e(self.e[1])
elif token == "s":
out += self.symmetric.encrypt_and_hash(self.s[1])
elif token == "psk":
self.symmetric.mix_key_and_hash(self.psks.pop(0))
else:
self._dh_token(token)
out += self.symmetric.encrypt_and_hash(payload)
return out, self._finish()
def read_message(self, message):
arrow_tokens = self.messages.pop(0)
pos, dhlen = 0, self.suite.dh.size
for token in arrow_tokens[1:]:
if token == "e":
self.re = message[pos:pos + dhlen]
pos += dhlen
self._mix_e(self.re)
elif token == "s":
size = dhlen + 16 if self.symmetric.cipher.has_key() else dhlen
self.rs = self.symmetric.decrypt_and_hash(message[pos:pos + size])
pos += size
elif token == "psk":
self.symmetric.mix_key_and_hash(self.psks.pop(0))
else:
self._dh_token(token)
payload = self.symmetric.decrypt_and_hash(message[pos:])
return payload, self._finish()
def _dh_token(self, token):
# the letters are in the order (initiator, responder): "es" is the initiator's ephemeral with the responder's static
mine = {"e": self.e, "s": self.s}
theirs = {"e": self.re, "s": self.rs}
first, second = token[0], token[1]
if self.initiator:
self.symmetric.mix_key(self._dh(mine[first][0], theirs[second]))
else:
self.symmetric.mix_key(self._dh(mine[second][0], theirs[first]))
def _finish(self):
if self.messages:
return None
self.done = True
return self.symmetric.split()
if __name__ == "__main__":
# the keys, the prologue, and the first payloads of the official test vectors of Noise_XX_25519_ChaChaPoly_BLAKE2s
name = "Noise_XX_25519_ChaChaPoly_BLAKE2s"
keys = {
"init_static": "e61ef9919cde45dd5f82166404bd08e38bceb5dfdfded0a34c8df7ed542214d1",
"init_ephemeral": "893e28b9dc6ca8d611ab664754b8ceb7bac5117349a4439a6b0569da977c464a",
"resp_static": "4a3acbfdb163dec651dfa3194dece676d437029c62a408b4c5ea9114246e4893",
"resp_ephemeral": "bbdb4cdbd309f1a1f2e1456967fe288cadd6f712d65dc7b7793d5e63da6b375b",
}
k = {name_: bytes.fromhex(value) for name_, value in keys.items()}
initiator = HandshakeState(name, True, b"John Galt", k["init_static"], k["init_ephemeral"])
responder = HandshakeState(name, False, b"John Galt", k["resp_static"], k["resp_ephemeral"])
print("h after the initialization:", initiator.symmetric.h.hex())
sender, receiver = initiator, responder
payloads = [b"Ludwig von Mises", b"Murray Rothbard", b"F. A. Hayek"]
transports = None
for index, payload in enumerate(payloads):
message, split = sender.write_message(payload)
received, split_r = receiver.read_message(message)
assert received == payload
print("message %d, %s: %d bytes, %s..." % (index + 1, "initiator to responder" if sender is initiator else "responder to initiator", len(message), message[:16].hex()))
if split is not None:
transports = (split, split_r) if sender is initiator else (split_r, split)
sender, receiver = receiver, sender
print("handshake hash:", initiator.symmetric.h.hex(), initiator.symmetric.h == responder.symmetric.h)
(i_send, i_recv), (r_recv, r_send) = transports
wire = r_send.encrypt_with_ad(b"", b"Carl Menger")
print("transport message from the responder:", wire.hex(), "->", i_recv.decrypt_with_ad(b"", wire).decode())
# Reads the handshake patterns of the Noise specification (the text of noiseprotocol.org/noise.html)
# and writes them to noise_patterns.json: the pre-messages and the message patterns of every pattern with a plain name.
import json
import re
import sys
lines = open(sys.argv[1], encoding="utf-8").read().splitlines()
patterns, current = {}, None
for line in lines:
stripped = line.strip()
m = re.fullmatch(r"([A-Z][A-Z0-9]*):", stripped) # a plain pattern name: NN, XX, IK1, X1X1, N, K, X
if m:
current = {"name": m.group(1), "lines": []}
patterns.setdefault(m.group(1), []).append(current)
continue
if current is not None:
if stripped.startswith(("->", "<-")) or stripped == "...":
current["lines"].append(stripped)
continue
if stripped == "":
continue
current = None
result = {}
for name, versions in patterns.items():
parsed = []
for version in versions:
if not version["lines"]:
continue
pre, messages, seen_dots = {"->": [], "<-": []}, [], "..." in version["lines"]
before = seen_dots
for l in version["lines"]:
if l == "...":
before = False
continue
arrow, tokens = l[:2], [t.strip() for t in l[2:].split(",") if t.strip()]
if before:
pre[arrow] += tokens
else:
messages.append([arrow] + tokens)
if messages[0][0] == "->": # the canonical form: the first message is sent by the initiator
parsed.append({"initiator_pre": pre["->"], "responder_pre": pre["<-"], "messages": messages})
if not parsed:
continue
# a pattern is listed several times in the specification: all the listings must agree
assert all(p == parsed[0] for p in parsed), name
result[name] = parsed[0]
json.dump(result, open("noise_patterns.json", "w"), indent=1)
print(len(result), "patterns:", " ".join(sorted(result)))
# chacha_poly.py
# ChaCha20, Poly1305, and the AEAD construction of RFC 8439.
# Teaching code. It is not constant time.
MASK32 = 0xFFFFFFFF
def rotl(x, n):
return ((x << n) & MASK32) | (x >> (32 - n))
def quarter_round(s, a, b, c, d):
s[a] = (s[a] + s[b]) & MASK32
s[d] = rotl(s[d] ^ s[a], 16)
s[c] = (s[c] + s[d]) & MASK32
s[b] = rotl(s[b] ^ s[c], 12)
s[a] = (s[a] + s[b]) & MASK32
s[d] = rotl(s[d] ^ s[a], 8)
s[c] = (s[c] + s[d]) & MASK32
s[b] = rotl(s[b] ^ s[c], 7)
def chacha20_block(key, counter, nonce):
# the state: four constants, the 256-bit key, a 32-bit block counter, and a 96-bit nonce
state = [0x61707865, 0x3320646E, 0x79622D32, 0x6B206574]
state += [int.from_bytes(key[4 * i:4 * i + 4], "little") for i in range(8)]
state += [counter & MASK32]
state += [int.from_bytes(nonce[4 * i:4 * i + 4], "little") for i in range(3)]
work = list(state)
for _ in range(10): # 20 rounds: 10 column rounds and 10 diagonal rounds
quarter_round(work, 0, 4, 8, 12)
quarter_round(work, 1, 5, 9, 13)
quarter_round(work, 2, 6, 10, 14)
quarter_round(work, 3, 7, 11, 15)
quarter_round(work, 0, 5, 10, 15)
quarter_round(work, 1, 6, 11, 12)
quarter_round(work, 2, 7, 8, 13)
quarter_round(work, 3, 4, 9, 14)
return b"".join(((w + s) & MASK32).to_bytes(4, "little") for w, s in zip(work, state))
def chacha20_xor(key, counter, nonce, data):
out = bytearray()
for i in range(0, len(data), 64):
block = chacha20_block(key, counter + i // 64, nonce)
out += bytes(a ^ b for a, b in zip(data[i:i + 64], block))
return bytes(out)
def poly1305(key, message):
# the one-time authenticator: a polynomial in r evaluated modulo 2^130 - 5, plus s
r = int.from_bytes(key[:16], "little") & 0x0FFFFFFC0FFFFFFC0FFFFFFC0FFFFFFF
s = int.from_bytes(key[16:32], "little")
p = (1 << 130) - 5
acc = 0
for i in range(0, len(message), 16):
block = message[i:i + 16]
acc = (acc + int.from_bytes(block + b"\x01", "little")) * r % p
return ((acc + s) & ((1 << 128) - 1)).to_bytes(16, "little")
def pad16(data):
return b"\x00" * (-len(data) % 16)
def aead_encrypt(key, nonce, aad, plaintext):
one_time_key = chacha20_block(key, 0, nonce)[:32]
ciphertext = chacha20_xor(key, 1, nonce, plaintext)
mac_data = aad + pad16(aad) + ciphertext + pad16(ciphertext) + len(aad).to_bytes(8, "little") + len(ciphertext).to_bytes(8, "little")
return ciphertext + poly1305(one_time_key, mac_data)
def aead_decrypt(key, nonce, aad, data):
if len(data) < 16:
raise ValueError("too short")
ciphertext, tag = data[:-16], data[-16:]
one_time_key = chacha20_block(key, 0, nonce)[:32]
mac_data = aad + pad16(aad) + ciphertext + pad16(ciphertext) + len(aad).to_bytes(8, "little") + len(ciphertext).to_bytes(8, "little")
expected = poly1305(one_time_key, mac_data)
if not constant_time_equal(tag, expected):
raise ValueError("authentication failed")
return chacha20_xor(key, 1, nonce, ciphertext)
def constant_time_equal(a, b):
diff = len(a) ^ len(b)
for x, y in zip(a, b):
diff |= x ^ y
return diff == 0
# dh.py
# X25519 and X448 of RFC 7748: Diffie-Hellman on Curve25519 and Curve448 with the Montgomery ladder.
# Teaching code. It is not constant time.
class Curve:
def __init__(self, name, p, a24, size, bits):
self.name, self.p, self.a24, self.size, self.bits = name, p, a24, size, bits
def decode_scalar(self, k):
k = bytearray(k)
if self.size == 32:
k[0] &= 248
k[31] &= 127
k[31] |= 64
else:
k[0] &= 252
k[55] |= 128
return int.from_bytes(k, "little")
def decode_u(self, u):
u = bytearray(u)
if self.size == 32:
u[31] &= 127 # the top bit is ignored for Curve25519
return int.from_bytes(u, "little") % self.p
def scalar_mult(self, k, u):
p, a24 = self.p, self.a24
k = self.decode_scalar(k)
x1 = self.decode_u(u)
x2, z2, x3, z3, swap = 1, 0, x1, 1, 0
for t in range(self.bits - 1, -1, -1):
bit = (k >> t) & 1
swap ^= bit
if swap:
x2, x3, z2, z3 = x3, x2, z3, z2
swap = bit
a, b = (x2 + z2) % p, (x2 - z2) % p
aa, bb = a * a % p, b * b % p
e = (aa - bb) % p
c, d = (x3 + z3) % p, (x3 - z3) % p
da, cb = d * a % p, c * b % p
x3, z3 = (da + cb) ** 2 % p, x1 * (da - cb) ** 2 % p
x2, z2 = aa * bb % p, e * (aa + a24 * e) % p
if swap:
x2, x3, z2, z3 = x3, x2, z3, z2
return (x2 * pow(z2, p - 2, p) % p).to_bytes(self.size, "little")
def public_key(self, private):
base = bytes([9 if self.size == 32 else 5]) + bytes(self.size - 1)
return self.scalar_mult(private, base)
X25519 = Curve("25519", 2 ** 255 - 19, 121665, 32, 255)
X448 = Curve("448", 2 ** 448 - 2 ** 224 - 1, 39081, 56, 448)
# aes_gcm.py
# AES-256 encryption (FIPS 197) and Galois/Counter Mode (NIST SP 800-38D) with a 96-bit nonce and a 128-bit tag.
# Teaching code. It is not constant time: the table lookups of the S-box leak through caches.
def _xtime(a):
a <<= 1
return (a ^ 0x11B) & 0xFF if a & 0x100 else a
def _gmul(a, b):
result = 0
while b:
if b & 1:
result ^= a
a, b = _xtime(a), b >> 1
return result
def _make_sbox():
sbox = []
for x in range(256):
inv = next((y for y in range(1, 256) if _gmul(x, y) == 1), 0) # the inverse in GF(2^8), 0 for 0
value = inv
for shift in range(1, 5):
value ^= ((inv << shift) | (inv >> (8 - shift))) & 0xFF
sbox.append(value ^ 0x63)
return sbox
SBOX = _make_sbox()
def expand_key(key):
words = [list(key[4 * i:4 * i + 4]) for i in range(8)]
rcon = 1
for i in range(8, 60):
word = list(words[i - 1])
if i % 8 == 0:
word = [SBOX[b] for b in word[1:] + word[:1]]
word[0] ^= rcon
rcon = _xtime(rcon)
elif i % 8 == 4:
word = [SBOX[b] for b in word]
words.append([a ^ b for a, b in zip(words[i - 8], word)])
return [sum(words[4 * r:4 * r + 4], []) for r in range(15)]
def encrypt_block(round_keys, block):
state = [b ^ k for b, k in zip(block, round_keys[0])]
for rnd in range(1, 15):
state = [SBOX[b] for b in state]
state = [state[4 * ((c + r) % 4) + r] for c in range(4) for r in range(4)] # ShiftRows
if rnd < 14:
mixed = []
for c in range(4):
a = state[4 * c:4 * c + 4]
mixed += [_xtime(a[0]) ^ (_xtime(a[1]) ^ a[1]) ^ a[2] ^ a[3],
a[0] ^ _xtime(a[1]) ^ (_xtime(a[2]) ^ a[2]) ^ a[3],
a[0] ^ a[1] ^ _xtime(a[2]) ^ (_xtime(a[3]) ^ a[3]),
(_xtime(a[0]) ^ a[0]) ^ a[1] ^ a[2] ^ _xtime(a[3])]
state = mixed
state = [b ^ k for b, k in zip(state, round_keys[rnd])]
return bytes(state)
def ghash_multiply(x, y):
# multiplication in GF(2^128) with the polynomial x^128 + x^7 + x^2 + x + 1, bits in the order of the standard
z, v = 0, y
for i in range(127, -1, -1):
if (x >> i) & 1:
z ^= v
v = (v >> 1) ^ (0xE1 << 120) if v & 1 else v >> 1
return z
def ghash(h, data):
y = 0
for i in range(0, len(data), 16):
block = data[i:i + 16].ljust(16, b"\x00")
y = ghash_multiply(y ^ int.from_bytes(block, "big"), h)
return y
def _counter_blocks(round_keys, nonce, count, start):
return b"".join(encrypt_block(round_keys, nonce + (start + i).to_bytes(4, "big")) for i in range(count))
def seal(key, nonce, aad, plaintext):
round_keys = expand_key(key)
h = int.from_bytes(encrypt_block(round_keys, bytes(16)), "big")
stream = _counter_blocks(round_keys, nonce, (len(plaintext) + 15) // 16, 2)
ciphertext = bytes(a ^ b for a, b in zip(plaintext, stream))
lengths = (8 * len(aad)).to_bytes(8, "big") + (8 * len(ciphertext)).to_bytes(8, "big")
pad = lambda d: d + b"\x00" * (-len(d) % 16)
s = ghash(h, pad(aad) + pad(ciphertext) + lengths)
tag = s ^ int.from_bytes(encrypt_block(round_keys, nonce + (1).to_bytes(4, "big")), "big")
return ciphertext + tag.to_bytes(16, "big")
def open_(key, nonce, aad, data):
if len(data) < 16:
raise ValueError("too short")
ciphertext, tag = data[:-16], data[-16:]
expected = seal_tag(key, nonce, aad, ciphertext)
diff = 0
for a, b in zip(expected, tag):
diff |= a ^ b
if diff:
raise ValueError("authentication failed")
round_keys = expand_key(key)
stream = _counter_blocks(round_keys, nonce, (len(ciphertext) + 15) // 16, 2)
return bytes(a ^ b for a, b in zip(ciphertext, stream))
def seal_tag(key, nonce, aad, ciphertext):
round_keys = expand_key(key)
h = int.from_bytes(encrypt_block(round_keys, bytes(16)), "big")
lengths = (8 * len(aad)).to_bytes(8, "big") + (8 * len(ciphertext)).to_bytes(8, "big")
pad = lambda d: d + b"\x00" * (-len(d) % 16)
s = ghash(h, pad(aad) + pad(ciphertext) + lengths)
return (s ^ int.from_bytes(encrypt_block(round_keys, nonce + (1).to_bytes(4, "big")), "big")).to_bytes(16, "big")
Running the Noise file performs the handshake of the example above:
h after the initialization: 2b71c58d0cc16c227aefb1fa655eba6031691c8fd1729b07c90d6a01ae2027bb
message 1, initiator to responder: 48 bytes, ca35def5ae56cec33dc2036731ab1489...
message 2, responder to initiator: 111 bytes, 95ebc60d2b1fa672c1f46a8aa265ef51...
message 3, initiator to responder: 75 bytes, 46c3307de83b014258717d97781c1f50...
handshake hash: 6c4c56cf71612f72d05ceb96c0155e6f4ea54a26b504c93de632a2db4a49d200 True
transport message from the responder: d5e83adfaac5dc324a68f1862df54549e56d209fba707205f328b2 -> Carl Menger
I checked this code in layers. The bottom layer is made of the primitives, and each has its own official vectors.
- ChaCha20 and Poly1305. The code passes all 5 block vectors, all 3 encryption vectors, all 11 Poly1305 vectors, all 3 key generation vectors, and the AEAD examples of RFC 8439, which I parsed from the text of the RFC.
- X25519 and X448. The code passes the test vectors of RFC 7748, the iterated test after 1 and 1,000 iterations, and the Diffie-Hellman examples for both curves. I did not run the test with a million iterations.
- AES-256 and GCM. The block function matches FIPS 197, appendix C.3. For GCM, I compared the code with OpenSSL on 60 random messages, since this part is covered by the Noise vectors as well.
- SHA-2, BLAKE2, and HMAC. The hash functions come from the Python library. The HMAC and HKDF are my own code, and the vectors test them.
The top layer is the Noise engine. It reproduces all 944 vectors of the file of the cacophony project and all 408 vectors of the file of the snow project, which makes 1,352. The vectors cover 1,048 different protocol names, 72 different patterns with their modifiers, and all 16 combinations of the two Diffie-Hellman functions, the two ciphers, and the four hash functions. In each vector, every handshake message and every transport message matches byte for byte, and the handshake hash matches where the file gives it. The tests also confirm that the 21 psk patterns that the specification writes out equal the patterns that my modifier code builds.
Further tests cover failures. A different prologue, a changed byte in the second or third message, and different pre-shared keys all make the handshake fail. A changed first message of NN is not noticed at once, since nothing is encrypted yet, but the second message cannot be decrypted. The nonce moves with every transport message, the same plaintext gives different ciphertexts, and a replayed message fails.
The page code has its own SHA-256, BLAKE2s, ChaCha20-Poly1305, and X25519. All four match OpenSSL. Its handshake engine reproduces 220 official vectors, which are all the vectors in the two files for X25519 and ChaCha20-Poly1305 with SHA-256 or BLAKE2s. It also reproduces all 72 vectors that the Python code produces for the page, including damaged messages. I ran its page in a simulated browser.
Where It Stands
- A framework, not a standard. The specification is maintained by one author and has the status “official/unstable”. Two projects publish test vectors for it, and my engine matches both collections.
- Used by WireGuard. The WireGuard guide covers a protocol that is built on a Noise pattern, with its own rules for messages and timers. Its exact protocol name is
Noise_IKpsk2_25519_ChaChaPoly_BLAKE2s, so the pre-shared key is mixed in at the end of the second message. - A different style from TLS. A Noise protocol has no negotiation inside the handshake. The two parties must agree on the name in advance, and the specification advises that negotiation done before the handshake should be included in the prologue.
Limitations
This is a teaching implementation, not a production one:
- Not constant time. The Python code branches on secret values, and the table lookups of AES leak through caches. The 25519 functions use Python integers.
- No random source. The ephemeral keys are arguments, so that the vectors can be tested. A real implementation draws them from a secure random source and never reuses them.
- No fallback and no Noise Pipes. I did not implement the
fallbackmodifier of the specification, or the compound protocols that use it. - No rekeying or out-of-order messages. The code has no
Rekeyand noSetNonce. - Static keys in the vectors are fixed. The code does not generate keys, and it does not check whether a remote static key is acceptable. The specification leaves that to the application.
- Payload formats and framing. A real application must define the length fields, the padding, and the end of the session, as the specification advises.
Security Status
The specification lists the points that an application must handle. The static key of a party should be used with a single hash algorithm, and not outside Noise. A pre-shared key should be used with a single hash algorithm too. Every party must send a fresh ephemeral key before it sends encrypted data, and ephemeral keys must never be reused. A nonce must never repeat for a key, and a party must not send more than 2^64 - 1 transport messages.
The text names more risks. A rollback attack is possible if a negotiation before the handshake is not included in the prologue. An attacker can truncate a stream of transport messages unless the application signals the end. Public keys must not be treated as secrets, because an invalid public key can force a known Diffie-Hellman output. For channel binding, a higher protocol should use the handshake hash h, and not the chaining key. The AES-GCM functions lose security gradually as the volume of data under one key grows, and the specification says that a party should not encrypt more than 2^56 bytes with a single key.
My experiments agree with the structure of these rules. The handshake hash catches every tampering after the first encryption, a replayed transport message is rejected, and the patterns that send a static key before any key exists send it in clear.
FAQ
Is Noise a protocol or a framework?
It is a framework. A Noise protocol is a concrete choice of a pattern and algorithms, and it has a name. The same engine runs all of them.
How does Noise differ from TLS?
TLS negotiates its parameters during the handshake, and it uses certificates for authentication. Noise has no negotiation inside the handshake, and it authenticates with static public keys that the application must judge. The handshake is also shorter, with one to three messages.
Which pattern should I choose?
The specification calls XX the most generally useful, because it supports mutual authentication and the transmission of static keys. IK is attractive when the initiator knows the responder in advance, since it takes two messages. WireGuard uses IK with a pre-shared key.
What does the prologue do?
It is data that both sides hash into h before the handshake. If the two prologues differ, the handshake fails. It is the right place for the negotiation data that came before the handshake.
Why is the first payload in clear in XX?
No key exists after the first message, because no Diffie-Hellman result has been mixed in yet. This is why the specification warns that early payloads have weaker properties than transport messages.
What is the handshake hash for?
It binds a higher-level protocol to this session. The specification says that an application should use h for channel binding, since h depends on all the bytes of the handshake.
References
-
Perrin, Trevor. “The Noise Protocol Framework,” revision 34, 11 July 2018. Available at: https://noiseprotocol.org/noise.html
-
RFC 8439, “ChaCha20 and Poly1305 for IETF Protocols,” by Nir and Langley, June 2018. Available at: https://www.rfc-editor.org/rfc/rfc8439
-
RFC 7748, “Elliptic Curves for Security,” by Langley, Hamburg, and Turner, January 2016. Available at: https://www.rfc-editor.org/rfc/rfc7748
-
The test vectors of the cacophony project: cacophony.txt
-
The test vectors of the snow project: snow.txt
-
Kobeissi, Nadim. Noise Explorer, a tool for the formal verification of Noise patterns. Available at: https://noiseexplorer.com/