Noise sv2 hardening - #2283
Conversation
|
To be merged after: #2270 |
|
Some adaptation needs to be made to sv2-apps. |
c1c3a69 to
e9ee5a4
Compare
GitGab19
left a comment
There was a problem hiding this comment.
Clanker review:
Findings
-
Remove the unsupported AES-GCM reference
The updated
Initiatordocumentation says transport supports either ChaCha20-Poly1305 or AES-GCM. However, following #2270,NoiseEnginecontains onlyChaCha20Poly1305ciphers.Changed documentation · NoiseEngine implementation
Suggested wording:
After the handshake, it facilitates secure communication using [
ChaCha20Poly1305]. Sensitive data is securely erased when no longer needed. -
Narrow the stated
CryptoRngguaranteeThe new comment says that
CryptoRngrejects deterministic generators. It does not enforce that guarantee: deterministic CSPRNGs such as a seededStdRngimplementCryptoRng, as demonstrated by the tests in this crate.The bound itself is appropriate; only the explanation should be adjusted. For example:
The
CryptoRngbound requires generators that declare themselves suitable for cryptographic use.Seed quality and correct
CryptoRngimplementations remain the caller’s responsibility. -
Add direct regression coverage for split transport state
The new
TransportEncryptState/TransportDecryptStatepath currently has no dedicated test in this PR. Please add a round-trip test covering both directions and sequential state reuse to protect the cipher direction and nonce-counter behavior.
`for mut b in self.ck` iterates the `[u8; 32]` by value, so each `b` is a stack-local copy and `write_volatile(&mut b, 0)` zeroes that copy rather than the field. Iterate by mutable reference instead, matching the `self.k` loop just above.
Same by-value iteration bug as in `Initiator::erase`: the volatile writes landed on stack-local copies, leaving `ck` and `h` untouched.
Both tests fail against the previous by-value erase loops.
Both types carry Noise handshake and transport state. Duplicating them hands out two independent nonce counters over the same key, so cloning is never safe here. Nothing in the workspace relied on it.
A cloned handshake role keeps the same chaining key, so both copies derive identical transport keys and then encrypt distinct plaintexts under the same key and nonce.
Each clone of a transport cipher carries its own nonce counter while sharing the key, so the copies silently encrypt different plaintexts under the same key and nonce.
Ephemeral keypairs were generated from any `Rng`, which accepted weak or deterministic generators. Bound `generate_key_with_rng` on `CryptoRng` and propagate it through the `Initiator` and `Responder` constructors.
A completed handshake yields one cipher per direction, each with its own key and nonce counter. Keeping them together forces the read and write sides of a connection to share the engine, so neither side can be owned independently. `into_split` consumes the engine and hands out `NoiseEncryptor` and `NoiseDecryptor`, one cipher each. Consuming is what keeps this safe: a duplicated cipher would reuse a nonce under the same key, which is why none of these types are `Clone`.
`encode` matched on the state and inlined the whole serialize-then-encrypt sequence in its transport arm. Move that sequence into `encrypt_frame`, which takes the encryption step as a closure, so the arm is a single delegation. No behaviour change: this only gives the transport path a caller-agnostic home before a second caller is added.
Mirror of the encoder change: the transport arm of `next_frame` moves into `next_transport`, and `decode_noise_frame` takes the decryption step as a closure instead of the engine itself. The arm becomes a single delegation. No behaviour change: this only gives the transport path a caller-agnostic home before a second caller is added.
A connection's reader and writer each need only one direction of the transport ciphers, but both had to share a `State`. `State` is not `Clone` — duplicating it would hand out two nonce counters over the same key — so the two halves could not be owned by separate tasks. `State::split_transport` consumes a transport-mode state and returns `TransportEncryptState` and `TransportDecryptState`, holding one cipher each. `NoiseEncoder::encode_transport` and `StandardNoiseDecoder::next_transport_frame` take them, so passing the wrong half to the wrong side is a compile error. Purely additive: `State`, its variants and constructors, `encode` and `next_frame` are unchanged, and an unsplit state still drives both directions.
0b200c0 to
0ed0e35
Compare
|
@GitGab19 added the test, should good now. |
|
Second round of clanker review: Review — Noise sv2 hardeningConcept ACK — the Findings below are additional to the three already resolved in this thread (AES-GCM doc reference,
noise_sv21. The derived key material is never wiped — including the transport session keysSeverity: Medium · This PR makes let (temp_k1, temp_k2) = Self::hkdf_2(self.get_ck(), &[]); // the two transport session keys
let c1 = ChaCha20Poly1305::new(&temp_k1.into()); // + a GenericArray copy of each
let c2 = ChaCha20Poly1305::new(&temp_k2.into());
let c1: Cipher<ChaCha20Poly1305> = Cipher::from_key_and_cipher(temp_k1, c1); // + a copy in Cipher.k
// ...
encryptor.erase_k(); // only wipes Cipher.k
decryptor.erase_k();
This is squarely the theme of #2248/#2249, and arguably higher-value than Worth stating explicitly in the PR: stack zeroization in Rust is best-effort — LLVM may already have spilled copies elsewhere, and 2. The
|
dfb7852 to
1b68931
Compare
|
@GitGab19 comments were pretty good, specially the zeroize key material on stack. Though I have not taken up 5, 6 and 9, considering we gonna be ripping apart the codec runtime panic infra soon (this week). Rest are incorporated, thanks. |
Follow-up review —
|
erase covered ck and h, but the session keys, the HKDF pseudorandom key and the ECDH secrets were left in their stack slots.
Cipher and NoiseEngine are Send + Sync. Nonce uniqueness comes from &mut exclusivity and the missing Clone, not from the absence of Sync.
It never touched handshake_cipher, which relies on ChaCha20Poly1305 zeroizing its own key on drop.
A failed chunk returned past the offset reset, so the next frame encoded at a stale start behind the remains of this one.
The same offset leak as the encoder. MissingBytes is normal flow and keeps its state.
split_transport consumes the state on its error path too.
TestMsg is single-chunk, and the decoder was rebuilt for every frame.
Drop the ones that only restate the code: the test doc-comments and the descriptions of the round-trip helpers. Keep the ones a reader cannot recover from the code. split_transport consumes the state on its error path as well, which is surprising enough that callers need it in the rustdoc, together with the pointer to is_transport. The encoder and decoder error-path resets read as no-ops unless you know the write offset and the bytes buffered so far persist across calls. And core::result::Result in the encoder tests reads as redundant until you know the glob import shadows it with the crate alias.
1b68931 to
ca5b6a9
Compare
|
Extended the hmac buffer capacity and restored the agent deleted comments |
closes: #2248
closes: https://github.com/project-loupe/audit-stratum/issues/21
closes: #2249
closes: https://github.com/project-loupe/audit-stratum/issues/41
closes: #2250
closes: https://github.com/project-loupe/audit-stratum/issues/22
closes: #2251
closes: https://github.com/project-loupe/audit-stratum/issues/80
closes: #2252
closes: https://github.com/project-loupe/audit-stratum/issues/61
closes: https://github.com/project-loupe/audit-stratum/issues/67
closes: https://github.com/project-loupe/audit-stratum/issues/98
companion stratum-mining/sv2-apps#706