Skip to content

Latest commit

 

History

History
916 lines (655 loc) · 34.4 KB

SPEC.md

File metadata and controls

916 lines (655 loc) · 34.4 KB

"Secret Handshake" v2 Specification 🤝

Mutually authenticating key agreement to establish shared secrets over an insecure channel.

Pre-requsities

  • There are two participants:
    • The initiator (client) who dials the connection.
    • The responder (server) who answers the connection.
  • Both the initiator and responder have a static Ed25519 (public) verifying and (secret) signing keypair.
    • These will be converted to X25519 public and secret keys as needed.
  • The initiator must know the responder's static Ed25519 (public) verifying key before connecting.
  • Both the initiator and the responder are expecting to use the same static symmetric network key.

Security Guarantees

  • After a successful handshake the peers have verified each other's public keys.
  • The handshake produces a shared secret symmetric key and initial nonce that can be used with a bulk encryption cipher (like Secret Channel) for exchanging further messages.
  • The initiator must know the responder's public key before connecting. The responder learns the initiator's public key during the handshake.
  • Once the intiator has proven their identity the responder can decide they don't want to talk to this initiator and disconnect without confirming their own identity.
  • A man-in-the-middle cannot learn the public key of either peer.
  • Both peers need to know a key that represents the particular network they wish to connect to, however a man-in-the-middle can't learn this key from the handshake. If the handshake succeeds then both ends have confirmed that they wish to use the same network.
  • Past handshakes cannot be replayed. Attempting to replay a handshake will not allow an attacker to discover or confirm guesses about the participants' public keys.
  • Handshakes provide forward secrecy. Recording a user's network traffic and then later stealing their secret key will not allow an attacker to decrypt their past handshakes.

Security Disclaimers

  • This protocol cannot hide your IP address.
  • This protocol does not attempt to obscure packet boundaries.
  • If a man-in-the-middle records a session and later compromises the responder's secret key, they will be able to learn the initiator's public key.

Differences with Secret Handshake v1

Authenticated Encryption: v1 uses XSalsa20-Poly1305, v2 uses ChaCha20-Poly1305 (IETF)

For authenticated encryption,

Fix mismatch with Secret Handshake paper

In the Secret Handshake paper, the responder's first reply is:

$$b_p, hmac[K|a*b](b_p)$$

i.e. the HMAC key is the network key and and product of the new ephemerals keys.

In Secret Handshake v1, the responder's first reply was:

$$hmac[K](b_p), b_p$$

i.e. the HMAC key is only the network key.

(And the order of concatenation is reversed.)

Secret Handshake v2 makes sure to follow the paper for this HMAC key.

Secret Handshake v2 also follows the paper for the order of concatenations on the first two messages:

  • Initiator Hello: $a_p, hmac[K](a_p)$
  • Responder Hello: $a_p, hmac[K|a*b](a_p)$

Because this is consistent with the ordering of (ciphertext, auth_tag) in ChaCha20-Poly1305 (IETF).

Secret Handshake v1 Vulnerability

In 2019 the paper "Prime, Order Please!" was released about a vulnerability in Secret Handshake v1.

The vulnerability combines two aspects of intentionally weak public keys:

  • X25519: With an intentionally weak public key, the shared secret produced by a Diffie-Hellman with that public key is known, regardless of the other secret key.
  • Ed25519: With an intentionally weak public key, it's possible to produce a signature that is a valid signature for any message, with respect to the weak public key.

The first solution in the paper (adopted by Secret Handshake v1) is to reject weak public keys with low order points.

In Secret Handshake v2 we will also follow the second suggestion in the paper:

Our alternative suggestion is to include the initiator’s and responder’s public key when deriving K1 and K2.

Whilst this second suggestion requires a new version of the protocol and risks incompatibility with older clients, it ensures implementations will not accidentally (and silently) forget the low order point check.

This also follows the advice on the libsodium scalar multiplication page:

q represents the X coordinate of a point on the curve. As a result, the number of possible keys is limited to the group size (≈2^252), which is smaller than the key space.

For this reason, and to mitigate subtle attacks due to the fact many (p, n) pairs produce the same result, using the output of the multiplication q directly as a shared key is not recommended.

A better way to compute a shared key is h(q ‖ pk1 ‖ pk2), with pk1 and pk2 being the public keys.

By doing so, each party can prove what exact public key they intended to perform a key exchange with (for a given public key, 11 other public keys producing the same shared secret can be trivially computed).

In an abundance of caution, we also apply the same to the "handshake identifier" in each authentication proof: $Hash(a ⋅ b)$ becomes $Hash(Concat(a ⋅ b, a_p, b_p))$.

Initiator Authenticate Payload

Secret Handshake v2 adds an extra 32-byte payload to the Initiator Authenticate message, so an initiator can authenticate to a responder who doesn't recognize their static public key, such as an invite code. The contents of this payload are optional, meaning that 32 zero bytes should be considered "no payload" while any other case is interpreted as an initiator authenticate payload.

Functions

The following functions will be used:

Hash: SHA-256

Hash: Given a variable-length message, returns a 32-byte SHA-256 digest

Hash(msg) -> digest

This corresponds to libsodium's crypto_hash_256 function.

Auth and AuthVerify: HMAC-SHA512-256

Auth (aka HMAC): Given a variable-length message and a key, returns a 32-byte authentication tag.

Auth(key, msg) -> auth_tag

This corresponds to libsodium's crypto_auth function.

(Note for other implementations: This returns a SHA-512 hash truncated to 256 bits. This is not the same as the SHA-512/256 function.)

And AuthVerify: given a variable-length message, a key, and an authentication tag (HMAC), return whether the message is verified.

(This function must execute in constant-time.)

AuthVerify(key, msg, auth_tag) -> boolean

This corresponds to libsodium's crypto_auth_verify function.

DiffieHellman: X25519

DiffieHellman: Given a local secret key and a remote public key, generate a shared secret.

(Note: This function MUST check if given public key is weak (has low order), before proceeding to generate a shared secret.)

DiffieHellman(secret_key, public_key) -> shared_secret

This corresponds to libsodium's crypto_scalarmult function

Scalar multiplication is a function for deriving shared secrets from a pair of secret and public X25519 keys.

The order of arguments matters. In the libsodium crypto_scalarmult function the secret key is provided first.

Note that static (long-term) keys are Ed25519 and must first be converted to X25519.

GenerateX25519Keypair

GenerateX25519Keypair: Uses a secure random number generate to generate an X25519 keypair.

GenerateX25519Keypair() -> (secret_key, public_key)

This corresponds to libsodium's:

ConvertEd25519ToX25519

ConvertVerifyingEd25519ToPublicX25519: Convert an Ed25519 (public) verifying key to an X25519 public key.

ConvertVerifyingEd25519ToPublicX25519(verifying_key, msg) -> public_key

This corresponds to libsodium's crypto_sign_ed25519_pk_to_curve25519 function

ConvertSigningEd25519ToSecretX25519: Convert an Ed25519 (secret) signing key an X25519 secret key.

ConvertSigningEd25519ToSecretX25519(signing_key, msg) -> secret_key

This corresponds to libsodium's crypto_sign_ed25519_sk_to_curve25519 function

Sign and SignVerify: Ed25519

Sign: Given an ED25519 (secret) signing key and a message, return a signature for the message with respect to the signing key.

Sign(signing_key, msg) -> sig

SignVerify: Given an ED25519 (public) verifying key, a message, and a signature, return whether the signature is valid for the message with respect to the verifying key.

This corresonds to libsodium's crypto_sign_detatched function

SignVerify(verifying_key, msg, sig) -> boolean

This corresonds to libsodium's crypto_sign_verify_detatched function

Encrypt and Decrypt: ChaCha20-Poly1305

Encrypt: Given a symmetric key, a nonce, and plaintext message, returns an authenticated ciphertext message.

Encrypt(key, nonce, plaintext) -> ciphertext

This corresponds to libsodium's crypto_aead_chacha20poly1305_ietf_encrypt function

Decrypt: Given a symmetric key, a nonce, and an authenticated ciphertext message, checks whether the message is authentic and decrypts the message into plaintext.

Decrypt(key, nonce, ciphertext) -> plaintext | null

This corresponds to libsodium's crypto_aead_chacha20poly1305_ietf_decrypt function

Concat

Concat: Concatenate a set of buffers of bytes together into one buffer of bytes.

Concat(...buffers) -> buffer

Steps

From the Secret Handshake Paper, here are the steps:

$$\begin{align*} ? \to \;?\; &: a_{p}, hmac_{K}(a_{p}) \\\ ? \gets \;?\; &: b_{p}, hmac_{[K|a\cdot b]}(b_{p}) \\\ H&=Sign_A(K|B_{p}|hash(a\cdot b))|A_{p} \\\ A \to B &: Box_{[K|a \cdot b | a \cdot B]}(H)\\\ A \gets B &: Box_{[K|a \cdot b | a \cdot B | A \cdot b]}(Sign_B(K|H|hash(a\cdot b)) )\\\ \end{align*}$$

We will break this down into the following sequence:

sequenceDiagram
    Note over Initiator, Responder: Pre-Handshake Knowledge
    Initiator->>Responder: Initiator Hello
    Note over Responder: Responder Acknowledge
    Responder->>Initiator: Responder Hello
    Note over Initiator: Initiator Acknowledge
    Initiator->>Responder: Initiator Authenticate
    Note over Responder: Responder Accept
    Responder->>Initiator: Responder Authenticate
    Note over Initiator: Initiator Accept
    Note over Initiator, Responder: Post-Handshake Knowledge
Loading
  1. Pre-Handshake Knowledge: What is known by Initiator and Responder before the handshake begins
  2. Initiator Hello: Initiator sends new ephemeral X22519 public key in cleartext, with an authentication token (using network key).
  3. Responder Acknowledge: Responder receives initiatior's ephemeral X25519 public key and checks authentication token (using network key).
  4. Responder Hello: Responder sends new ephemeral X22519 public key in cleartext, with an authentication (using key derived from network key and first shared secret).
  5. Initiator Acknowledge: Initiator receives initiator's ephemeral X25519 public key and checks authentication token (using key derived from network key and first shared secret).
  6. Initiator Authenticate: Initiator sends own static Ed25519 (public) verifying key and a signature of current state (using own static Ed25519 signing key), encrypted with key derived from network key, both ephemeral public keys, and two shared secrets.
  7. Responder Accept: Responder receives and decrypts initiator's static Ed25519 (public) verifying key, signature, and optional payload. Checks signature matches key. Either accepts initiator based on key or based on optional payload.
  8. Responder Authenticate: Responder sends own static Ed25519 (public) verifying key and a signature of current state (using own static Ed25519 signing key), encrypted with key derived from network key, both ephemeral public keys, and three shared secrets.
  9. Initiator Accept: Initiator receives and decrypts responder's signature. Checks signature matches key.
  10. Post-Handshake Knowledge: What is known by initiator and responder after the handshake ends

Initiator is the computer dialing the TCP connection and responder is the computer receiving it. Once the handshake is complete this distinction goes away.

Pre-Handshake Knowledge

Before starting the handshake, both the initiator and responder know their own respective static Ed25519 public and secret keypairs.

They both also know and expect to use the same static network key.

The network key is a fixed key of 32 bytes.

The network key allows separate isolated networks to be created, for example private networks or testnets. An eavesdropper cannot extract the network identifier directly from what is sent over the wire, although they could confirm a guess for a network if the network key is publicly known.

The initator also knows the responder's static public key.

Initiator Hello

The initiator generates a new ephemeral public and secret keypair: $a$.

Then sends "Initiator Hello": a message which combines:

  • proof that the initiator knows the network key: $N$,
  • and the initiator's new ephemeral public key: $a_p$.

Initiator:

initiator_hello_msg = Concat(
  initiator_ephemeral_public_key,
  Auth(
    msg: initiator_ephemeral_public_key,
    key: network_key
  )
)

Which looks like:

Initiator Hello: 64-bytes (512-bits)
+----------------------------------+------------------+
|  initiator ephemeral public key  |     auth tag     |
+----------------------------------+------------------+
|          32B (256-bits)          |  32B (256-bits)  |
+----------------------------------+------------------+

Auth (aka HMAC) and AuthVerify are functions to tag and verify a message with regards to a secret key. In this case the network identifier ($N$) is used as the secret key.

Both the message creator and verifier have to know the same message and secret key for the verification to succeed, but the secret key is not revealed to an eavesdropper.

Responder Acknowledge

The responder receives "Initiator Hello" and verifies the length of the message is 64 bytes.

Then extracts the initiator's authentication tag (HMAC) from the first 32 bytes and initiator ephemeral public key ($a_p$) from the last 32 bytes.

Then uses these to verify that the initiator is using the same network key ($N$).

Responder:

initiator_ephemeral_public_key = initiator_hello_msg[0..32]
initiator_hello_msg_auth_tag = initiator_hello_msg[32..64]

assert(
  AuthVerify(
    key: network_key,
    msg: initiator_ephemeral_public_key,
    auth_tag: initiator_hello_msg_auth_tag
  )
)

Responder Hello

Now the responder reciprocates with their own hello.

The responder generates a new ephemeral public and secret keypair.

The responder can now generate the first shared secret: $a ⋅ b$

Responder:

shared_secret_ab = DiffieHellman(
  secret_key: responder_ephemeral_secret_key,
  public_key: initiator_ephemeral_public_key
)

Shared secrets are derived using Diffie-Hellman scalar multiplication.

Alice and Bob both send each other their public key.

If Alice combines her secret key with Bob's public key,

And Bob combines his secret key with Alice's public key,

Each never revealing their secret keys to one another,

They will end up with the same shared secret.

And meanwhile, Mallory, who hears everything, is not able to come up with the same shared secret.

Mathemagic!

Next the responder sends "Responder Hello": a message which combines:

  • proof that the initiator knows the network key,
  • and the initiator's new ephemeral public key

Responder:

responder_hello_msg_key = Hash(
  Concat(
    network_key,
    shared_secret_ab,
  )
)

responder_hello_msg = Concat(
  responder_ephemeral_public_key,
  Auth(
    msg: responder_ephemeral_public_key,
    key: responder_hello_msg_key,
  )
)

Which looks like:

Responder Hello: 64-bytes (512-bits)
+----------------------------------+------------------+
|  responder ephemeral public key  |     auth tag     |
+----------------------------------+------------------+
|          32B (256-bits)          |  32B (256-bits)  |
+----------------------------------+------------------+

Initiator Acknowledge

The initiator receives "Responder Hello" and verifies the length of the message is 64 bytes.

Then extracts the responder's authentication tag (HMAC) from the first 32 bytes and responder ephemeral public key from the last 32 bytes.

Now that ephemeral keys have been exchanged, both ends can derive the same first shared secret: $a ⋅ b$.

The initiator and responder each combine their own ephemeral secret key with the other’s ephemeral public key to produce the same shared secret on both ends. An eavesdropper doesn’t know either secret key so they can’t generate the shared secret. A man-in-the-middle could swap out the ephemeral keys in Messages 1 and 2 for their own keys, so the shared secret $a ⋅ b$ alone is not enough for the initiator and responder to know that they are talking to each other and not a man-in-the-middle.

The initiator uses these to generate the first shared secret ($a ⋅ b$) and verify that the responder is using the same network key and received their ephemeral public key.

Initiator:

responder_ephemeral_public_key = responder_hello_msg[0..32]
responder_hello_msg_auth_tag = responder_hello_msg[32..64]

shared_secret_ab = DiffieHellman(
  secret_key: initiator_ephemeral_secret_key,
  public_key: responder_ephemeral_public_key
)

responder_hello_msg_key = Hash(
  Concat(
    network_key,
    shared_secret_ab,
  )
)

assert(
  AuthVerify(
    key: responder_hello_msg_key,
    msg: responder_ephemeral_public_key,
    auth_tag: responder_hello_msg_auth_tag
  )
)

Initiator Authenticate

Now it's time for the initiator to authenticate themself to the responder.

Because the initiator already knows the responder's static Ed25519 (public) verifying key $B_p$, both can derive a second secret using the initiator's ephemeral key pair (either the public or the secret key) and the responder's static key pair (respectively either the secret or private key) that will allow the initiator to send a message that only the real responder can read and not a man-in-the-middle.

First, the initiator creates an authentication proof:

Initiator:

handshake_id = Hash(
  Concat(
    shared_secret_ab,
    initiator_ephemeral_public_key,
    responder_ephemeral_public_key
  )
)

initiator_auth_proof = Concat(
  network_key,
  responder_static_verifying_key,
  handshake_id,
)

Which looks like:

Initiator Authenticate Proof: 96-bytes (768-bits)
+------------------+--------------------------------+--------------------+
|   network key    | responder static verifying key |    handshake id    |
+------------------+--------------------------------+--------------------+
|  32B (256-bits)  |         32B (256-bits)         |   32B (256-bits)   |
+------------------+--------------------------------+--------------------+

Which is then signed.

Initiator:

initiator_auth_proof_sig = Sign(
  signing_key: initiator_static_signing_key,
  msg: initiator_auth_proof
)

Then the initiator creates their full authentication message.

In this message the initiator might want to include an extra 32-byte authenticate payload. If the initiator has no such payload, then the initiator must use 32 bytes of zeros instead.

Initiator:

initiator_auth_msg_plaintext = Concat(
  initiator_auth_proof_sig,
  initiator_static_verifying_key,
  initiator_optional_auth_payload
)

Which looks like:

Initiator Authenticate (plaintext): 128-bytes (1024-bits)
+--------------------------------+--------------------------------+---------------------------------+
| initiator auth proof signature | initiator static verifying key | initiator optional auth payload |
+--------------------------------+--------------------------------+---------------------------------+
|         64B (512-bits)         |         32B (256-bits)         |         32B (256-bits)          |
+--------------------------------+--------------------------------+---------------------------------+

Then the initiator encrypts this message.

To do this,

  • they convert the responder's static Ed25519 (public) verifying key $B_p$ to a static X25519 public key,
  • then, they compute the second shared secret ($a ⋅ B$).

Initiator:

shared_secret_aB = DiffieHellman(
  secret_key: initiator_ephemeral_secret_key,
  public_key: ConvertVerifyingEd25519ToPublicX25519(
    responder_static_verifying_key
  )
)

The symmetric key for the encryption combines the current shared knowledge, including shared secrets $a ⋅ b$ and $a ⋅ B$.

The nonce for the encryption is 24 bytes of zeros.

An all-zero nonce is used for the symmetric encryption. The encryption cipher requires that you must NEVER re-use the same (key, nonce) pair. It’s important to get this detail right because reusing a nonce will allow an attacker to recover the key and encrypt or decrypt anything using that key. Using a zero nonce is allowed here because this is the only secret box that ever uses the key: Hash(Concat($N$, $a ⋅ b$, $a ⋅ B$)).

Initiator:

initiator_auth_msg_key = Hash(
  Concat(
    network_key,
    shared_secret_ab,
    shared_secret_aB,
    initiator_ephemeral_public_key,
    responder_ephemeral_public_key
  )
)

initiator_auth_msg_ciphertext = Encrypt(
  key: initiator_auth_msg_key,
  nonce: 24_bytes_of_zeros,
  plaintext: initiator_auth_msg_plaintext,
)

Which looks like:

Initiator Authenticate (ciphertext): 144-bytes (1152-bits)
+-------------------------------+----------------+
|   initiator auth ciphertext   |    auth tag    |
+-------------------------------+----------------+
|        128B (1024-bits)       | 16B (128-bits) |
+-------------------------------+----------------+

Responder Accept

The initiator reveals their identity to the responder by sending their static Ed25519 (public) verifying key. The initiator also makes a signature using their static Ed25519 (public) verifying key. By signing the keys used earlier in the handshake the initiator proves their identity and confirms that they do indeed wish to be part of this handshake.

The responder receives "Initiator Authenticate" and verifies the length of the message is 144 bytes.

Then creates the same symmetric key used for encryption, in the process creating the $a ⋅ B$ shared secret:

Responder:

shared_secret_aB = DiffieHellman(
  secret_key: ConvertSigningEd25519ToSecretX25519(
    responder_static_signing_key
  ),
  public_key: initiator_ephemeral_public_key
)

initiator_auth_msg_key = Hash(
  Concat(
    network_key,
    shared_secret_ab,
    shared_secret_aB,
    initiator_ephemeral_public_key,
    responder_ephemeral_public_key
  )
)

Then tries to decrypt the message (which will also verify the authentication tag).

(Using the same 24 bytes of zeros as a nonce.)

Responder:

initiator_auth_msg_plaintext = Decrypt(
  key: initiator_auth_msg_key,
  nonce: 24_bytes_of_zeros,
  ciphertext: initiator_auth_msg_ciphertext,
)

Now the responder can deconstruct the Initiator Authenticate message into constituent parts.

(The length of the plaintext is 128 bytes.)

Responder:

initiator_auth_proof_sig = initiator_auth_msg_plaintext[0..64]
initiator_static_verifying_key = initiator_auth_msg_plaintext[64..96]
initiator_optional_auth_payload = initiator_auth_msg_plaintext[96..128]

Then generate the signed proof and verify the signature is correct:

Responder:

handshake_id = Hash(
  Concat(
    shared_secret_ab,
    initiator_ephemeral_public_key,
    responder_ephemeral_public_key
  )
)

initiator_auth_proof = Concat(
  network_key,
  responder_static_verifying_key,
  handshake_id
)

assert(
  SignVerify(
    key: initiator_static_verifying_key,
    msg: initiator_auth_proof,
    sig: initiator_auth_proof_sig
  )
)

This confirms:

  • The initiator's identity is the same as the static (public) verifying key they sent,
  • And the initiator has pre-handshake knowledge of the responder's identity.

Now the responder may choose whether or not to accept the initiator.

The acceptance can be seen as Capability Based Security:

  • The initiator's identity, as a capability,
  • Or, the initiator's optional payload, as a capability.

One of these capabilities may grant the initiator access to the responder. Or not.

If the responder doesn't accept the initiator, they close the connection and the handshake ends.

To maximize security, the process of checking whether the initiator is accepted or not, should take a constant amount of time, regardless of whether they are authentic and have the desired capability.

If the responder does accept the initiator, they continue with the next step.

Responder Authenticate

Now it's time for the responder to authenticate themself to the initiator.

First, the responder creates an authentication proof:

Responder:

responder_auth_proof = Concat(
  network_key,
  initiator_auth_proof_sig,
  initiator_static_verifying_key,
  handshake_id
)

Which looks like:

Responder Authenticate Proof: 160-bytes (1280-bits)
+------------------+--------------------------+--------------------------------+--------------------+
|   network key    | initiator auth proof sig | responder static verifying key |    handshake id    |
+------------------+--------------------------+--------------------------------+--------------------+
|  32B (256-bits)  |      64B (512-bits)      |         32B (256-bits)         |   32B (256-bits)   |
+------------------+--------------------------+--------------------------------+--------------------+

Which is then signed.

Responder:

responder_auth_proof_sig = Sign(
  signing_key: responder_static_signing_key,
  msg: responder_auth_proof
)

Then the responder encrypts their signature, to become their authentication message.

To do this,

  • they convert the initiator's static Ed25519 (public) verifying key $A_p$ to a static X25519 public key,
  • then, they compute the third shared secret ($A ⋅ b$).

Responder:

shared_secret_Ab = DiffieHellman(
  secret_key: responder_ephemeral_secret_key,
  public_key: ConvertVerifyingEd25519ToPublicX25519(
    initiator_static_verifying_key
  )
)

The symmetric key for the encryption combines the current shared knowledge, including shared secrets $a ⋅ b$, $a ⋅ B$, and $A ⋅ b$.

The nonce for the encryption is again, 24 bytes of zeros. (Only because this key will never be used again.)

Responder:

responder_auth_msg_key = Hash(
  Concat(
    network_key,
    shared_secret_ab,
    shared_secret_aB,
    shared_secret_AB,
    initiator_ephemeral_public_key,
    responder_ephemeral_public_key
  )
)

responder_auth_msg_ciphertext = Encrypt(
  key: responder_auth_msg_key,
  nonce: 24_bytes_of_zeros,
  plaintext: responder_auth_proof_sig,
)

Which looks like:

Initiator Authenticate (ciphertext): 80-bytes (640-bits)
+--------------------------+----------------+
| responder sig ciphertext |    auth tag    |
+--------------------------+----------------+
|      64B (512-bits)      | 16B (128-bits) |
+--------------------------+----------------+

Initiator Accept

The initiator receives "Responder Authenticate" and verifies the length of the message is 80 bytes.

Then creates the same symmetric key used for encryption, in the process creating the $A ⋅ b$ shared secret:

Initiator:

shared_secret_Ab = DiffieHellman(
  secret_key: ConvertSigningEd25519ToSecretX25519(
    initiator_static_signing_key
  ),
  responder_ephemeral_public_key,
)

responder_auth_msg_key = Hash(
  Concat(
    network_key,
    shared_secret_ab,
    shared_secret_aB,
    shared_secret_Ab,
    initiator_ephemeral_public_key,
    responder_ephemeral_public_key
  )
)

Then tries to decrypt the message (which will also verify the authentication tag).

(Using the same 24 bytes of zeros as a nonce.)

Initiator:

responder_auth_proof_sig = Decrypt(
  key: responder_auth_msg_key,
  nonce: 24_bytes_of_zeros,
  ciphertext: responder_auth_msg_ciphertext,
)

Now the initiator can generate the signed proof and verify the signature is correct:

Initiator:

responder_auth_proof = Concat(
  network_key,
  initiator_auth_proof_sig,
  initiator_static_verifying_key,
  handshake_id
)

assert(
  SignVerify(
    key: responder_static_verifying_key,
    msg: responder_auth_proof,
    sig: responder_auth_proof_sig
  )
)

Post-Handshake Knowledge

At this point the handshake has succeeded. The initiator and responder have proven their identities to each other.

After the handshake, the initiator and responder share these secrets:

  • $N$
  • $a ⋅ b$
  • $a ⋅ B$
  • $A ⋅ b$

Since the Responder Authenticate encryption key was $Hash(Concat(N, a ⋅ b, a ⋅ B, A ⋅ b, a_p, b_p)), we won't directly use that again, instead hash one more time.

The final secret is:

shared_secret_final = Hash(responder_auth_msg_key)

Using shared_secret_final we can derive any more keys we need.

Once you have the final secret, it's prudent to clean any references to any previous shared secrets.

Now we can setup a pair of symmetric encryption keys for securely bulk exchanging further messages.

As our key to encrypt from initiator to responder:

initiator_to_responder_key = Hash(Concat(shared_secret_final, responder_static_verifying_key))

And our key to encrypt from responder to initiator:

responder_to_initiator_key = Hash(Concat(shared_secret_final, initiator_static_verifying_key))

Secret Channel

"Secret Channel" is a secure message stream protocol, designed for after Secret Handshake.

Comparisons

Noise is a generalized framework for Diffie-Hellman key agreements and post-handshake transports.

Noise provides a multiplicity of patterns, from every variety. Some are more secure, less secure, in-between, a tapestry of trade-offs.

Different from Secret Handshake, Noise uses HKDF to generate the necessary keys at every step: 15.2 Hash functions and hashing

HKDF is well-known and HKDF "chains" are used in similar ways in other protocols (e.g. Signal, IPsec, TLS 1.3).

HKDF has a published analysis.

HKDF applies multiple layers of hashing between each MixKey() input. This "extra" hashing might mitigate the impact of hash function weakness.

Secret Handshake has a single pattern for a specific purpose.

Secret Handshake is most similar to Noise pattern XK1psk0, however the Noise documentation states:

Misusing public keys as secrets: It might be tempting to use a pattern with a pre-message public key and assume that a successful handshake implies the other party's knowledge of the public key. Unfortunately, this is not the case, since setting public keys to invalid values might cause predictable DH output. For example, a Noise_NK_25519 initiator might send an invalid ephemeral public key to cause a known DH output of all zeros, despite not knowing the responder's static public key. If the parties want to authenticate with a shared secret, it should be used as a PSK.

Channel binding: Depending on the DH functions, it might be possible for a malicious party to engage in multiple sessions that derive the same shared secret key by setting public keys to invalid values that cause predictable DH output (as in the previous bullet). It might also be possible to set public keys to equivalent values that cause the same DH output for different inputs. This is why a higher-level protocol should use the handshake hash (h) for a unique channel binding, instead of ck, as explained in Section 11.2.

Secret Handshake is designed for mutual authentication, where the initiator must authenticate first, signing a message that references the responder's static key.

To do the same in Noise seems to require extra steps.

References