diff --git a/docs/design/fips-session-layer.md b/docs/design/fips-session-layer.md index faa37ee9..93becd62 100644 --- a/docs/design/fips-session-layer.md +++ b/docs/design/fips-session-layer.md @@ -223,7 +223,10 @@ than network addresses. A session survives: FSP uses Noise XK for session encryption, distinct from the Noise IK pattern used at the link layer. The full Noise descriptor is -`Noise_XK_secp256k1_ChaChaPoly_SHA256`. +`Noise_XK_secp256k1_ChaChaPoly_SHA256`, with one deviation recorded in +[the security reference](../reference/security.md): the handshake AEAD +uses an empty associated-data field where standard Noise +`EncryptAndHash` uses the handshake hash `h`. The XK pattern (pre-message: `← s`): diff --git a/docs/reference/security.md b/docs/reference/security.md index a439b0c3..ed2c46b0 100644 --- a/docs/reference/security.md +++ b/docs/reference/security.md @@ -58,16 +58,34 @@ idempotent). | Curve | secp256k1 | FMP IK, FSP XK, Schnorr signatures | | Diffie-Hellman | ECDH on secp256k1 (x-only normalized) | Noise IK, Noise XK | | AEAD | ChaCha20-Poly1305 | FMP link encryption, FSP session encryption | -| Hash | SHA-256 | NodeAddr derivation, Noise transcript | +| Hash | SHA-256 | NodeAddr derivation, Noise key schedule | | Key derivation | HKDF-SHA256 | Noise key schedule | | Signatures | secp256k1 Schnorr | TreeAnnounce, LookupResponse proof, Nostr adverts | -| Noise pattern (link) | `Noise_IK_secp256k1_ChaChaPoly_SHA256` | FMP link layer (IK with epoch payload) | -| Noise pattern (session) | `Noise_XK_secp256k1_ChaChaPoly_SHA256` | FSP session layer (XK with epoch payload) | +| Noise pattern (link) | `Noise_IK_secp256k1_ChaChaPoly_SHA256`, with the deviation below | FMP link layer (IK with epoch payload) | +| Noise pattern (session) | `Noise_XK_secp256k1_ChaChaPoly_SHA256`, with the deviation below | FSP session layer (XK with epoch payload) | These choices align with the Nostr cryptographic stack (secp256k1 + ChaCha20-Poly1305 + SHA-256) and the NIP-44 encrypted messaging standard. +### Deviation: Empty Associated Data in the Handshake AEAD + +Both Noise patterns above deviate from the standard construction in one +respect. The handshake AEAD uses an empty associated-data field where +standard Noise `EncryptAndHash` uses the handshake hash `h`. + +The choice was deliberate. Using secp256k1 rather than 25519 already put the +construction outside standard Noise, so no standard-Noise peer could be +confused with it, and the transcript hash bought no distinguishing value. + +That argument is about domain separation, and on those grounds it holds. It +does not cover transcript binding, which is the property actually absent. +Domain separation and DH binding survive through the chaining key `ck`, which +`mix_key` chains from `ck = h`, seeded from the protocol name in +`SymmetricState::initialize` (`src/noise/handshake.rs`). The handshake hash +`h` is maintained at every step and is never fed to the AEAD, so it binds +nothing. + ## Rekey Defaults Both link-layer and session-layer Noise sessions rekey under one of diff --git a/src/noise/handshake.rs b/src/noise/handshake.rs index a7de40bc..db9f4000 100644 --- a/src/noise/handshake.rs +++ b/src/noise/handshake.rs @@ -21,7 +21,13 @@ use std::fmt; struct SymmetricState { /// Chaining key for key derivation. ck: [u8; 32], - /// Handshake hash for transcript binding. + /// Running SHA-256 accumulator over the handshake transcript. + /// + /// Maintained by `mix_hash` at every step, but never fed to the AEAD: + /// `encrypt_and_hash` passes an empty associated-data field. Nothing in + /// production reads it, so it provides no transcript binding today, and + /// anything built on `handshake_hash()` (channel binding, an exporter, + /// cookie binding) will silently not work until the AAD carries `h`. h: [u8; 32], /// Current cipher state for encrypting handshake payloads. cipher: CipherState, @@ -101,7 +107,7 @@ impl SymmetricState { (CipherState::new(k1), CipherState::new(k2)) } - /// Get the handshake hash (for channel binding). + /// Get the handshake hash. fn handshake_hash(&self) -> [u8; 32] { self.h } @@ -916,7 +922,7 @@ impl HandshakeState { )) } - /// Get the handshake hash (for channel binding, available after complete). + /// Get the handshake hash (available after complete). pub fn handshake_hash(&self) -> [u8; 32] { self.symmetric.handshake_hash() } diff --git a/src/noise/session.rs b/src/noise/session.rs index 0df0aaef..92a8a059 100644 --- a/src/noise/session.rs +++ b/src/noise/session.rs @@ -14,7 +14,7 @@ pub struct NoiseSession { send_cipher: CipherState, /// Cipher for receiving. recv_cipher: CipherState, - /// Handshake hash for channel binding. + /// Handshake hash. handshake_hash: [u8; 32], /// Remote peer's static public key. remote_static: PublicKey, @@ -190,7 +190,7 @@ impl NoiseSession { self.replay_window.reset(); } - /// Get the handshake hash for channel binding. + /// Get the handshake hash. pub fn handshake_hash(&self) -> &[u8; 32] { &self.handshake_hash }