hiss 0.4.0

Static, type-level Noise Protocol Framework with pluggable hardware-backed crypto.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
//! X25519 Diffie–Hellman — the Noise `25519` DH function.
//!
//! X25519 is the Curve25519-based ECDH function defined by
//! [RFC 7748](https://www.rfc-editor.org/rfc/rfc7748). It is the
//! dominant Diffie–Hellman function in deployed Noise (libp2p-noise,
//! WireGuard-style protocols), and the spec name component is the bare
//! string `"25519"` — so the protocol name reads e.g.
//! `Noise_XX_25519_ChaChaPoly_BLAKE2b`.
//!
//! This module implements the [`Curve`] and [`DhCurve`] traits for
//! X25519; the provider backend that performs its operations lives in
//! [`crate::provider`]. X25519 is **DH-only** — it deliberately does
//! *not* implement [`SigningCurve`](super::SigningCurve). For signatures
//! over the 25519 family use [`Ed25519`](super::ed25519::Ed25519).
//!
//! # Backend
//!
//! * **Software** ([`SoftwareX25519PrivateKey`], always available) — a
//!   pure-Rust constant-time Montgomery ladder, selected at compile time.
//!   By default (the `x25519-cryptoxide` feature) it uses `cryptoxide`'s
//!   `x25519`, which benchmarks faster; building with `--no-default-features`
//!   falls back to `eccoxide`'s `protocol::x25519` (the ladder shared with
//!   [`X448`](super::x448)). Both are RFC 7748-conformant and byte-for-byte
//!   identical on the wire, so the choice only affects which dependency
//!   carries the primitive. Suitable for tests, WASM, and any platform
//!   without hardware key storage.
//!
//! There is **no** Apple Secure Enclave backend: the Secure Enclave is
//! P-256-only, so X25519 is software on every platform.
//!
//! # Wire format and interop
//!
//! Public keys and shared secrets are plain 32-byte values; there is no
//! point compression flag or KDF. Both backends clamp the scalar and mask
//! the u-coordinate's unused high bit per RFC 7748 inside every exchange,
//! exactly as `snow` does, so handshakes are byte-for-byte interoperable.
//!
//! Per RFC 7748, X25519 has small-order input points whose shared secret
//! is all-zero. The Noise `25519` DH function performs no validation
//! (unlike this crate's P-256, which rejects the identity), so DH never
//! fails here — a contributing low-order key simply yields an all-zero
//! secret, matching `snow` and every conformant Noise implementation.

use std::fmt;

use packtool::Packed;
use rand_core::CryptoRng;

use super::{Curve, DhCurve, SharedSecret};

// ── Errors ─────────────────────────────────────────────────────

/// Errors produced by the X25519 DH curve.
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum Error {
    /// A public-key byte string was not the expected 32 bytes; the wrapped
    /// value is the length supplied. Decoding never fails otherwise — every
    /// 32-byte string is a valid Curve25519 u-coordinate (RFC 7748).
    #[error("invalid public key length: expected 32 bytes, got {0}")]
    InvalidPublicKeyLength(usize),
}

// ── Curve marker ───────────────────────────────────────────────

/// X25519 curve marker — the Noise `25519` DH function.
///
/// Zero-sized type implementing [`Curve`] + [`DhCurve`] (but **not**
/// [`SigningCurve`](super::SigningCurve)) that ties together the concrete
/// [`X25519PublicKey`] and [`SharedSecret`] types. Used as the `Cu` type
/// parameter of a Noise protocol and of
/// [`DhProvider`](crate::provider::DhProvider).
#[derive(Debug, Clone, Copy, Default)]
pub struct X25519;

impl Curve for X25519 {
    const NAME: &'static str = "25519";
    const PUBLIC_KEY_SIZE: usize = 32;
    const PRIVATE_KEY_SIZE: usize = 32;

    type Error = Error;
    type PublicKey = X25519PublicKey;

    fn public_key_from_bytes(bytes: &[u8]) -> Result<Self::PublicKey, Self::Error> {
        X25519PublicKey::from_bytes(bytes)
    }
}

impl DhCurve for X25519 {
    const DHLEN: usize = 32;
    type SharedSecret = SharedSecret<32>;
}

// Deliberately no `impl SigningCurve for X25519`: X25519 is DH-only.

// ── Public key ─────────────────────────────────────────────────

/// An X25519 public key — a 32-byte Curve25519 u-coordinate.
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Packed)]
pub struct X25519PublicKey(#[packed(accessor = false)] [u8; 32]);

impl X25519PublicKey {
    /// Construct from a 32-byte slice.
    ///
    /// Only the length is checked: every 32-byte string is a valid
    /// Curve25519 u-coordinate (RFC 7748 masks the high bit internally),
    /// so there is nothing else to reject.
    pub fn from_bytes(bytes: &[u8]) -> Result<Self, Error> {
        let arr: [u8; 32] = bytes
            .try_into()
            .map_err(|_| Error::InvalidPublicKeyLength(bytes.len()))?;
        Ok(Self(arr))
    }

    /// Return the raw 32 bytes.
    pub fn as_bytes(&self) -> &[u8; 32] {
        &self.0
    }
}

impl AsRef<[u8]> for X25519PublicKey {
    fn as_ref(&self) -> &[u8] {
        &self.0
    }
}

impl fmt::Display for X25519PublicKey {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(&hex::encode(self.0))
    }
}

impl fmt::Debug for X25519PublicKey {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(&hex::encode(self.0))
    }
}

// ── Backend (feature-selected) ─────────────────────────────────

/// X25519 scalar multiplication, selected at compile time.
///
/// The default backend is `cryptoxide`'s `x25519`, enabled by the
/// `x25519-cryptoxide` default feature (it also wins under `--all-features`,
/// since Cargo features are additive). Building with `--no-default-features`
/// falls back to [`eccoxide`](eccoxide::protocol::x25519), the constant-time
/// Montgomery ladder shared with [`X448`](super::x448). Both clamp the
/// scalar and mask the u-coordinate's unused high bit per RFC 7748, so they
/// emit byte-identical output: the entire feature divergence is the two thin
/// impls below.
mod backend {
    #[cfg(feature = "x25519-cryptoxide")]
    pub(super) use cryptoxide_backend::{dh, public_key};
    #[cfg(not(feature = "x25519-cryptoxide"))]
    pub(super) use eccoxide_backend::{dh, public_key};

    #[cfg(not(feature = "x25519-cryptoxide"))]
    mod eccoxide_backend {
        use eccoxide::protocol::x25519;

        /// `X25519(scalar, 9)` — the public u-coordinate for a raw scalar.
        pub fn public_key(secret: [u8; 32]) -> [u8; 32] {
            x25519::SecretKey::from_bytes(secret)
                .public_key()
                .to_bytes()
        }

        /// `X25519(scalar, peer)` — the shared secret against a peer key.
        pub fn dh(secret: [u8; 32], peer: [u8; 32]) -> [u8; 32] {
            x25519::SecretKey::from_bytes(secret)
                .diffie_hellman(&x25519::PublicKey::from_bytes(peer))
                .to_bytes()
        }
    }

    #[cfg(feature = "x25519-cryptoxide")]
    mod cryptoxide_backend {
        use cryptoxide::x25519;

        /// `X25519(scalar, 9)` — the public u-coordinate for a raw scalar.
        pub fn public_key(secret: [u8; 32]) -> [u8; 32] {
            x25519::base(&x25519::SecretKey::from(secret)).into()
        }

        /// `X25519(scalar, peer)` — the shared secret against a peer key.
        pub fn dh(secret: [u8; 32], peer: [u8; 32]) -> [u8; 32] {
            x25519::dh(
                &x25519::SecretKey::from(secret),
                &x25519::PublicKey::from(peer),
            )
            .into()
        }
    }
}

// ── Software private key ───────────────────────────────────────

/// Software X25519 private key — 32 raw scalar bytes.
///
/// The scalar is stored un-clamped; the selected backend applies the
/// RFC 7748 clamp inside every exchange, so the on-wire and shared-secret
/// bytes match `snow` and any conformant peer. The bytes are zeroised on
/// drop.
///
/// This is the software backend — always available, and the only X25519
/// backend (the Apple Secure Enclave is P-256-only).
pub struct SoftwareX25519PrivateKey {
    /// The 32-byte scalar (un-clamped).
    secret: [u8; 32],
}

impl SoftwareX25519PrivateKey {
    /// Generate a new random X25519 key.
    ///
    /// The caller supplies the RNG, which must be cryptographically
    /// secure. This makes generation compatible with platform-provided
    /// CSPRNGs and allows reproducible tests with a seeded RNG.
    pub fn generate<R: CryptoRng>(mut rng: R) -> Self {
        let mut secret = [0u8; 32];
        rng.fill_bytes(&mut secret);
        Self { secret }
    }

    /// Construct from known 32-byte scalar material.
    ///
    /// Useful for testing with deterministic keys or for restoring a key
    /// from storage. The bytes are used as-is (clamped at use time).
    pub fn from_bytes(secret: [u8; 32]) -> Self {
        Self { secret }
    }

    /// Return the corresponding public key (`X25519(scalar, 9)`).
    pub fn public_key(&self) -> X25519PublicKey {
        X25519PublicKey(backend::public_key(self.secret))
    }

    /// Perform Diffie–Hellman key exchange with a peer's public key.
    ///
    /// Returns the 32-byte Curve25519 shared secret. Never fails: per
    /// RFC 7748 a low-order peer key yields an all-zero secret rather than
    /// an error, matching the Noise `25519` DH function.
    pub fn dh(&self, peer: &X25519PublicKey) -> SharedSecret<32> {
        SharedSecret::new(backend::dh(self.secret, peer.0))
    }

    /// Return the raw 32-byte scalar.
    ///
    /// Use with care — this is secret material. Intended for persisting
    /// the key to storage (Keychain, sealed blob, etc.).
    pub fn as_bytes(&self) -> &[u8; 32] {
        &self.secret
    }
}

impl Drop for SoftwareX25519PrivateKey {
    fn drop(&mut self) {
        crate::zeroize::zeroize_array(&mut self.secret);
    }
}

#[cfg(not(test))]
impl fmt::Debug for SoftwareX25519PrivateKey {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.debug_struct("SoftwareX25519PrivateKey")
            .finish_non_exhaustive()
    }
}

#[cfg(test)]
impl fmt::Debug for SoftwareX25519PrivateKey {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.debug_struct("SoftwareX25519PrivateKey")
            .field("secret", &hex::encode(self.secret))
            .finish()
    }
}

// ── Tests ──────────────────────────────────────────────────────

#[cfg(test)]
mod tests {
    use super::*;
    use proptest::prelude::*;

    /// Decode a 32-byte hex string into a fixed array.
    fn h32(s: &str) -> [u8; 32] {
        hex::decode(s).unwrap().try_into().unwrap()
    }

    /// RFC 7748 §6.1 — the authoritative X25519 ECDH known-answer test:
    /// derive both public keys from the listed scalars and agree on the
    /// listed shared secret, both directions.
    #[test]
    fn rfc7748_section_6_1_ecdh_known_answer() {
        let alice_sk = h32("77076d0a7318a57d3c16c17251b26645df4c2f87ebc0992ab177fba51db92c2a");
        let alice_pk = h32("8520f0098930a754748b7ddcb43ef75a0dbf3a0d26381af4eba4a98eaa9b4e6a");
        let bob_sk = h32("5dab087e624a8a4b79e17f8b83800ee66f3bb1292618b6fd1c2f8b27ff88e0eb");
        let bob_pk = h32("de9edb7d7b7dc1b4d35b61c2ece435373f8343c85b78674dadfc7e146f882b4f");
        let shared = h32("4a5d9d5ba4ce2de1728e3bf480350f25e07e21c947d19e3376f09b3c1e161742");

        let alice = SoftwareX25519PrivateKey::from_bytes(alice_sk);
        let bob = SoftwareX25519PrivateKey::from_bytes(bob_sk);

        // Public keys derive correctly.
        assert_eq!(alice.public_key().as_bytes(), &alice_pk);
        assert_eq!(bob.public_key().as_bytes(), &bob_pk);

        // Both sides agree on the published shared secret.
        let ss_ab = alice.dh(&X25519PublicKey::from_bytes(&bob_pk).unwrap());
        let ss_ba = bob.dh(&X25519PublicKey::from_bytes(&alice_pk).unwrap());
        assert_eq!(ss_ab.as_bytes(), &shared);
        assert_eq!(ss_ba.as_bytes(), &shared);
    }

    /// The canonical public-key encoding (`as_ref()`) is wire-relevant:
    /// downstream protocols key MACs over these octets and compare them
    /// for tie-breaks. Pin it — 32 bytes, the raw RFC 7748 u-coordinate,
    /// byte for byte — so an encoding refactor trips here instead of
    /// silently re-keying a downstream MAC.
    #[test]
    fn public_key_canonical_encoding_pinned() {
        let sk = h32("77076d0a7318a57d3c16c17251b26645df4c2f87ebc0992ab177fba51db92c2a");
        let expected = h32("8520f0098930a754748b7ddcb43ef75a0dbf3a0d26381af4eba4a98eaa9b4e6a");

        let pk = SoftwareX25519PrivateKey::from_bytes(sk).public_key();
        assert_eq!(X25519::PUBLIC_KEY_SIZE, 32);
        assert_eq!(pk.as_ref().len(), 32);
        assert_eq!(pk.as_ref(), &expected[..]);

        // The canonical bytes reparse to the same encoding.
        let reparsed = X25519PublicKey::from_bytes(pk.as_ref()).unwrap();
        assert_eq!(reparsed.as_ref(), pk.as_ref());
    }

    /// RFC 7748 §5.2 — the authoritative single-iteration scalar/u-coordinate
    /// known-answer test (a non-base u-coordinate, exercising the ladder
    /// directly rather than only the base point).
    #[test]
    fn rfc7748_section_5_2_scalarmult_known_answer() {
        let scalar = h32("a546e36bf0527c9d3b16154b82465edd62144c0ac1fc5a18506a2244ba449ac4");
        let u = h32("e6db6867583030db3594c1a424b15f7c726624ec26b3353b10a903a6d0ab1c4c");
        let expected = h32("c3da55379de9c6908e94ea4df28d084f32eccf03491c71f754b4075577a28552");

        let sk = SoftwareX25519PrivateKey::from_bytes(scalar);
        let out = sk.dh(&X25519PublicKey::from_bytes(&u).unwrap());
        assert_eq!(out.as_bytes(), &expected);
    }

    #[test]
    fn dh_is_symmetric() {
        let sk1 = SoftwareX25519PrivateKey::generate(rand::rng());
        let pk1 = sk1.public_key();
        let sk2 = SoftwareX25519PrivateKey::generate(rand::rng());
        let pk2 = sk2.public_key();

        assert_eq!(sk1.dh(&pk2).as_bytes(), sk2.dh(&pk1).as_bytes());
    }

    #[test]
    fn dh_different_peers_produce_different_secrets() {
        let sk = SoftwareX25519PrivateKey::generate(rand::rng());
        let peer1 = SoftwareX25519PrivateKey::generate(rand::rng()).public_key();
        let peer2 = SoftwareX25519PrivateKey::generate(rand::rng()).public_key();

        assert_ne!(sk.dh(&peer1).as_bytes(), sk.dh(&peer2).as_bytes());
    }

    #[test]
    fn public_key_round_trip() {
        let sk = SoftwareX25519PrivateKey::generate(rand::rng());
        let pk = sk.public_key();

        let pk2 = X25519PublicKey::from_bytes(pk.as_bytes()).unwrap();
        assert_eq!(pk, pk2);
    }

    #[test]
    fn public_key_wrong_length_rejected() {
        let err = X25519PublicKey::from_bytes(&[0u8; 31]).unwrap_err();
        assert!(matches!(err, Error::InvalidPublicKeyLength(31)));

        let err = X25519PublicKey::from_bytes(&[0u8; 33]).unwrap_err();
        assert!(matches!(err, Error::InvalidPublicKeyLength(33)));
    }

    /// Contrast with P-256, which rejects the identity: X25519 performs no
    /// validation, so a low-order (here all-zero) peer key yields an
    /// all-zero shared secret rather than an error — exactly the Noise
    /// `25519` behaviour, byte-for-byte with `snow`.
    #[test]
    fn low_order_point_yields_zero_secret_per_spec() {
        let sk = SoftwareX25519PrivateKey::generate(rand::rng());
        let low_order = X25519PublicKey::from_bytes(&[0u8; 32]).unwrap();
        assert_eq!(sk.dh(&low_order).as_bytes(), &[0u8; 32]);
    }

    proptest! {
        /// DH never panics for arbitrary 32-byte peer keys.
        #[test]
        fn dh_never_panics(peer_bytes in any::<[u8; 32]>(), secret in any::<[u8; 32]>()) {
            let sk = SoftwareX25519PrivateKey::from_bytes(secret);
            let peer = X25519PublicKey::from_bytes(&peer_bytes).unwrap();
            let _ = sk.dh(&peer);
        }
    }
}