diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index ea69a58..c83b7a8 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -23,6 +23,9 @@ jobs: - name: Check run: cargo check --all-features + - name: Check default features (McEliece excluded) + run: cargo check + audit: name: Security Audit runs-on: ubuntu-latest diff --git a/CHANGELOG.md b/CHANGELOG.md index cb0c82d..d80a986 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,22 @@ All notable changes to this project are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.0.0] - 2026-09-14 + +### Added + +- XEdDSA view for X25519 keys (`views::xeddsa`): an Ed25519-form signature over a 32-byte message that verifies against the Edwards form of the X25519 public key. Signatures carry the `XeddsaMsig` codec. Includes strict verification, small-order key rejection, and known-answer tests. +- `deprecated` cargo feature (default off). Restores the Classic McEliece support described under Deprecated below. + +### Changed + +- BREAKING. Classic McEliece (`Mceliece348864Pub`/`Priv`) and the X25519-McEliece-348864 hybrid moved behind the `deprecated` cargo feature. Default builds contain no McEliece code and no `mceliece348864` dependency. Default builds still decode stored McEliece multikeys, but `Builder::new_from_random_bytes` fails for McEliece codecs with `UnsupportedCodec`, and the McEliece views reject them. +- `multi-codec` dependency raised from `1.3` to `1.5` (adds the McEliece codec deprecation attributes). + +### Deprecated + +- The Classic McEliece key views, the `X25519-McEliece-348864` hybrid view, and the `MCELIECE_KEY_CODECS` constant. Key-recovery attacks now solve the TII McEliece challenges; see [tii-solved](https://github.com/mjosaarinen/tii-solved) for the recovered keys. Enable the `deprecated` feature to keep using them. + ## [1.2.2] - 2026-09-01 ### Fixed diff --git a/Cargo.toml b/Cargo.toml index ab14d18..11c8be4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "multi-key" -version = "1.2.2" +version = "2.0.0" edition = "2024" rust-version = "1.96" authors = ["Dave Grantham "] @@ -21,6 +21,11 @@ wasm = ["getrandom/wasm_js"] legacy_chacha20_fallback = [] lamport = ["dep:lamport_signature_plus", "dep:sha3", "dep:blake2", "dep:shake"] xmss = ["dep:xmss"] +# Deprecated Classic McEliece KEM support (key recovery attacks now solve the +# TII McEliece challenges; see https://github.com/mjosaarinen/tii-solved). +# Excluded from the default build; enable to keep the McEliece key views and +# their tests compiling. +deprecated = ["dep:mceliece348864"] # Tests that take over 60 seconds in debug builds (SLH-DSA, Classic McEliece, # XMSS). Excluded from the default `cargo test` run; enable with # `cargo test --features slow-tests` to run the full suite. @@ -43,13 +48,13 @@ getrandom = { version = "0.4", features = ["sys_rng"] } hex = "0.4" hkdf = "0.13" k256 = { version = "0.14", features = ["ecdh"] } -mceliece348864 = { version = "1.0" } +mceliece348864 = { version = "1.0", optional = true } ml-dsa = "0.1.1" ml-kem = "0.3" multi-base = { version = "1.0", default-features = false } -multi-codec = "1.3" +multi-codec = "1.5" multi-hash = "1.1" -multi-sig = "1.3" +multi-sig = "1.4" multi-trait = { version = "1.0", default-features = false } multi-util = "1.1" p256 = { version = "0.14", features = ["ecdsa", "ecdh"] } diff --git a/README.md b/README.md index fead6af..dcba803 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ # Multi-Key -A Rust implementation of the [multiformats][MULTIFORMATS] [multikey specification][MULTIKEY] and [nonce specification][NONCE]. The published crate is **`multi-key`**. Depend on it as `multi-key = "1.0"` in `Cargo.toml`. Import it as `multi_key` in Rust, for example `use multi_key::Builder;`. +A Rust implementation of the [multiformats][MULTIFORMATS] [multikey specification][MULTIKEY] and [nonce specification][NONCE]. The published crate is **`multi-key`**. Depend on it as `multi-key = "2"` in `Cargo.toml`. Import it as `multi_key` in Rust, for example `use multi_key::Builder;`. ## Current Status @@ -14,8 +14,9 @@ This implementation of the multikey specification supports an extensive set of p - **Classical signing** — Ed25519, secp256k1, NIST P-256/P-384/P-521, RSA-2048/3072/4096, and BLS12-381 G1/G2. - **Post-quantum signing** — FN-DSA, ML-DSA, MAYO, and SLH-DSA (all parameter sets). -- **Key encapsulation / key agreement** — X25519, ML-KEM, sntrup, Classic McEliece, - FrodoKEM, and the BLS12-381 TimeCrypt pairing-based KEM. +- **Key encapsulation / key agreement** — X25519, ML-KEM, sntrup, Classic McEliece + (deprecated; behind the `deprecated` feature), FrodoKEM, and the BLS12-381 + TimeCrypt pairing-based KEM. - **Hybrid signing** — combinations of Ed25519 or BLS12-381 G1 with a PQ signing scheme. - **Hybrid KEMs** — combinations of X25519 with a PQ KEM. - **Secret-key / symmetric** — ChaCha20-Poly1305 keys. @@ -73,6 +74,10 @@ identifiers come from the [multicodec][MULTICODEC] registry and are surfaced as | MAYO | `Mayo1Pub`/`Priv`, `Mayo2Pub`/`Priv`, `Mayo3Pub`/`Priv`, `Mayo5Pub`/`Priv` | 1, 2, 3, 5 | | SLH-DSA | `SlhdsaSha2128FPub`/`Priv`, `SlhdsaSha2128SPub`/`Priv`, `SlhdsaSha2192FPub`/`Priv`, `SlhdsaSha2192SPub`/`Priv`, `SlhdsaSha2256FPub`/`Priv`, `SlhdsaSha2256SPub`/`Priv`, `SlhdsaShake128FPub`/`Priv`, `SlhdsaShake128SPub`/`Priv`, `SlhdsaShake192FPub`/`Priv`, `SlhdsaShake192SPub`/`Priv`, `SlhdsaShake256FPub`/`Priv`, `SlhdsaShake256SPub`/`Priv` | 12 sets: SHA-2/SHAKE × 128/192/256 × F/S | +### Deprecated Algorithms + +Classic McEliece (the `Mceliece348864Pub`/`Priv` codecs) and the X25519-McEliece-348864 hybrid are **deprecated**. Key-recovery attacks now solve the TII McEliece challenges; see [tii-solved](https://github.com/mjosaarinen/tii-solved) for the recovered keys. The McEliece views compile only with the `deprecated` cargo feature and are excluded from the default build. Stored McEliece multikeys still decode in default builds, but `Builder::new_from_random_bytes` fails for McEliece codecs with `UnsupportedCodec`. + ### KEMs / Key Agreement | Algorithm | Codecs | Notes | @@ -80,7 +85,7 @@ identifiers come from the [multicodec][MULTICODEC] registry and are surfaced as | X25519 | `X25519Pub` / `X25519Priv` | ECDH; returns ephemeral public key from `seal` | | ML-KEM | `Mlkem768Pub`/`Priv`, `Mlkem1024Pub`/`Priv` | 768, 1024 | | sntrup | `Sntrup761Pub`/`Priv`, `Sntrup857Pub`/`Priv`, `Sntrup953Pub`/`Priv`, `Sntrup1013Pub`/`Priv`, `Sntrup1277Pub`/`Priv` | 761, 857, 953, 1013, 1277 | -| Classic McEliece | `Mceliece348864Pub` / `Mceliece348864Priv` | 348864 | +| Deprecated Classic McEliece | `Mceliece348864Pub` / `Mceliece348864Priv` | 348864; behind the `deprecated` feature | | FrodoKEM | `FrodoKem640AesPub`/`Priv`, `FrodoKem976AesPub`/`Priv`, `FrodoKem1344AesPub`/`Priv`, `FrodoKem640ShakePub`/`Priv`, `FrodoKem976ShakePub`/`Priv`, `FrodoKem1344ShakePub`/`Priv` | 640/976/1344 × AES/SHAKE | | BLS12-381 TimeCrypt | (uses the G1/G2 codecs above) | Pairing-based KEM built into the BLS views | @@ -103,7 +108,7 @@ identifiers come from the [multicodec][MULTICODEC] registry and are surfaced as | X25519-sntrup761 | `X25519Sntrup761Pub` / `X25519Sntrup761Priv` | X25519 + sntrup761 | | X25519-ML-KEM-768 | `X25519Mlkem768Pub` / `X25519Mlkem768Priv` | X25519 + ML-KEM-768 | | X25519-FrodoKEM-640 | `X25519Frodokem640AesPub`/`Priv`, `X25519Frodokem640ShakePub`/`Priv` | X25519 + FrodoKEM-640 (AES/SHAKE) | -| X25519-McEliece-348864 | `X25519Mceliece348864Pub` / `X25519Mceliece348864Priv` | X25519 + Classic McEliece 348864 | +| Deprecated X25519-McEliece-348864 | `X25519Mceliece348864Pub` / `X25519Mceliece348864Priv` | X25519 + Classic McEliece 348864; behind the `deprecated` feature | ### Threshold Key Shares @@ -190,7 +195,7 @@ The import direction (`Builder::new_from_ssh_public_key` and `Builder::new_from_ ### Key types that do not support SSH conversion -All KEM-only and hybrid key types explicitly reject SSH conversion and return `UnsupportedAlgorithm`. These include X25519, ML-KEM, all sntrup sizes, Classic McEliece, all FrodoKEM variants, the BLS12-381 TimeCrypt KEM, and all hybrid signing and hybrid KEM schemes. +All KEM-only and hybrid key types explicitly reject SSH conversion and return `UnsupportedAlgorithm`. These include X25519, ML-KEM, all sntrup sizes, Classic McEliece (deprecated), all FrodoKEM variants, the BLS12-381 TimeCrypt KEM, and all hybrid signing and hybrid KEM schemes. ## Threshold Operations @@ -209,7 +214,7 @@ The DKG share codecs (`Ed25519Thresh*`, `P256Thresh*`, `P384Thresh*`, `Secp256K1 - **Feldman VSS** — secp256k1, P-256/P-384/P-521, BLS12-381 G1/G2 (verifiable, with commitments). - **gf256 byte-sharing** — RSA and all PQ families (ML-DSA, ML-KEM, SLH-DSA, FN-DSA, MAYO, - sntrup, FrodoKEM, Classic McEliece) and all hybrids. + sntrup, FrodoKEM, Classic McEliece (deprecated)) and all hybrids. - **Dual mode** — Ed25519 and X25519: a gf256 share of the 32-byte seed (exact restore) plus a Feldman scalar share (threshold-signing-ready). diff --git a/SECURITY.md b/SECURITY.md index 0472aa1..4b0c38f 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -55,7 +55,7 @@ The `Multikey` comment field is stored as a plain `String`. It is not zeroized o ## Hybrid KEM Combiner Hash (M3) -The AEAD-key KDF is unified across all four hybrid KEMs. It uses HKDF-SHA512 via the shared `aead::derive_aead_key` helper. The secret-combiner hash is not unified. `x25519_mlkem768` uses SHA-512. The other three hybrid KEMs (`x25519_sntrup761`, `x25519_frodokem640`, `x25519_mceliece348864`) use BLAKE3. Both constructions are cryptographically sound. The split is accepted. The combiner hash feeds into HKDF-SHA512, which accepts arbitrary input length. +The AEAD-key KDF is unified across all four hybrid KEMs. It uses HKDF-SHA512 via the shared `aead::derive_aead_key` helper. The secret-combiner hash is not unified. `x25519_mlkem768` uses SHA-512. The other three hybrid KEMs (`x25519_sntrup761`, `x25519_frodokem640`, `x25519_mceliece348864`) use BLAKE3. Both constructions are cryptographically sound. The split is accepted. The combiner hash feeds into HKDF-SHA512, which accepts arbitrary input length. Note that `x25519_mceliece348864` is deprecated (behind the `deprecated` feature) because key-recovery attacks now solve the TII McEliece challenges; see https://github.com/mjosaarinen/tii-solved. The combiner note above is historical context for code still compiled with that feature. ## Decoded-Size Caps diff --git a/src/keysplit.rs b/src/keysplit.rs index 923cb0d..41af1ab 100644 --- a/src/keysplit.rs +++ b/src/keysplit.rs @@ -626,6 +626,8 @@ mod tests { assert_all(&mk::SNTRUP_KEY_CODECS); } + #[cfg(feature = "deprecated")] + #[allow(deprecated)] #[test] fn roundtrip_mceliece() { assert_all(&mk::MCELIECE_KEY_CODECS); diff --git a/src/lib.rs b/src/lib.rs index 8f7ff6b..144c6ca 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -59,6 +59,18 @@ //! AEAD was added. Disabled by default — AEAD failure is a hard error so //! unauthenticated ciphertext is never returned as if it were valid. Enable //! only to migrate pre-AEAD keystores; a warning is emitted on every fallback. +//! - **`deprecated`** (default off): Enables the deprecated Classic McEliece +//! support (see the Deprecations section below). +//! +//! ## Deprecations +//! +//! Classic McEliece (the `Mceliece348864Pub`/`Priv` codecs and the +//! `X25519-McEliece-348864` hybrid) is deprecated. Key-recovery attacks now +//! solve the TII McEliece challenges; see +//! for the recovered keys. The +//! McEliece views compile only with the `deprecated` feature. Stored +//! McEliece multikeys still decode in default builds, but key generation +//! fails with `UnsupportedCodec`. //! //! ## Security //! diff --git a/src/mk.rs b/src/mk.rs index 1094281..81b4d99 100644 --- a/src/mk.rs +++ b/src/mk.rs @@ -5,6 +5,8 @@ use crate::views::lamport; use crate::views::lamport_merkle; #[cfg(feature = "xmss")] use crate::views::xmss; +#[cfg(feature = "deprecated")] +use crate::views::{classic_mceliece, x25519_mceliece348864}; use crate::{ AttrId, AttrView, CipherAttrView, CipherView, ConvView, DataView, Error, FingerprintView, KdfAttrView, KdfView, MerkleStateView, OpenView, SealView, SignView, ThresholdAttrView, @@ -12,10 +14,9 @@ use crate::{ error::{AttributesError, CipherError, ConversionsError, KdfError, SealError, ThresholdError}, views::{ bcrypt, bls12381, bls12381_g1_fndsa512, bls12381_g1_mayo1, bls12381_g1_mayo2, - bls12381_g1_mldsa65, chacha20, classic_mceliece, ed25519, ed25519_fndsa512, ed25519_mayo2, - ed25519_mldsa65, fn_dsa, frodokem, mayo, ml_dsa, ml_kem, nist_p, rsa, secp256k1, slh_dsa, - sntrup, threshold_meta, x25519, x25519_frodokem640, x25519_mceliece348864, x25519_mlkem768, - x25519_sntrup761, + bls12381_g1_mldsa65, chacha20, ed25519, ed25519_fndsa512, ed25519_mayo2, ed25519_mldsa65, + fn_dsa, frodokem, mayo, ml_dsa, ml_kem, nist_p, rsa, secp256k1, slh_dsa, sntrup, + threshold_meta, x25519, x25519_frodokem640, x25519_mlkem768, x25519_sntrup761, xeddsa, }, }; @@ -96,6 +97,16 @@ pub const SNTRUP_KEY_CODECS: [Codec; 5] = [ ]; /// the list of Classic McEliece key codecs supported for key generation +/// +/// This constant is deprecated. Key-recovery attacks now solve the TII +/// McEliece challenges; see . +/// The McEliece views compile only with the `deprecated` feature. +#[cfg(feature = "deprecated")] +#[deprecated( + since = "2.0.0", + note = "Classic McEliece key recovery attacks: see https://github.com/mjosaarinen/tii-solved" +)] +#[allow(deprecated)] pub const MCELIECE_KEY_CODECS: [Codec; 1] = [Codec::Mceliece348864Priv]; /// the list of FrodoKEM key codecs supported for key generation @@ -466,6 +477,8 @@ impl Views for Multikey { | Codec::Sntrup1013Priv | Codec::Sntrup1277Pub | Codec::Sntrup1277Priv => Ok(Box::new(sntrup::View::try_from(self)?)), + #[cfg(feature = "deprecated")] + #[allow(deprecated)] Codec::Mceliece348864Pub | Codec::Mceliece348864Priv => { Ok(Box::new(classic_mceliece::View::try_from(self)?)) } @@ -491,6 +504,8 @@ impl Views for Multikey { | Codec::X25519Frodokem640ShakePriv => { Ok(Box::new(x25519_frodokem640::View::try_from(self)?)) } + #[cfg(feature = "deprecated")] + #[allow(deprecated)] Codec::X25519Mceliece348864Pub | Codec::X25519Mceliece348864Priv => { Ok(Box::new(x25519_mceliece348864::View::try_from(self)?)) } @@ -693,6 +708,8 @@ impl Views for Multikey { | Codec::Sntrup1013Priv | Codec::Sntrup1277Pub | Codec::Sntrup1277Priv => Ok(Box::new(sntrup::View::try_from(self)?)), + #[cfg(feature = "deprecated")] + #[allow(deprecated)] Codec::Mceliece348864Pub | Codec::Mceliece348864Priv => { Ok(Box::new(classic_mceliece::View::try_from(self)?)) } @@ -718,6 +735,8 @@ impl Views for Multikey { | Codec::X25519Frodokem640ShakePriv => { Ok(Box::new(x25519_frodokem640::View::try_from(self)?)) } + #[cfg(feature = "deprecated")] + #[allow(deprecated)] Codec::X25519Mceliece348864Pub | Codec::X25519Mceliece348864Priv => { Ok(Box::new(x25519_mceliece348864::View::try_from(self)?)) } @@ -965,6 +984,8 @@ impl Views for Multikey { | Codec::Sntrup1013Priv | Codec::Sntrup1277Pub | Codec::Sntrup1277Priv => Ok(Box::new(sntrup::View::try_from(self)?)), + #[cfg(feature = "deprecated")] + #[allow(deprecated)] Codec::Mceliece348864Pub | Codec::Mceliece348864Priv => { Ok(Box::new(classic_mceliece::View::try_from(self)?)) } @@ -990,6 +1011,8 @@ impl Views for Multikey { | Codec::X25519Frodokem640ShakePriv => { Ok(Box::new(x25519_frodokem640::View::try_from(self)?)) } + #[cfg(feature = "deprecated")] + #[allow(deprecated)] Codec::X25519Mceliece348864Pub | Codec::X25519Mceliece348864Priv => { Ok(Box::new(x25519_mceliece348864::View::try_from(self)?)) } @@ -1179,6 +1202,8 @@ impl Views for Multikey { | Codec::Sntrup1013Priv | Codec::Sntrup1277Pub | Codec::Sntrup1277Priv => Ok(Box::new(sntrup::View::try_from(self)?)), + #[cfg(feature = "deprecated")] + #[allow(deprecated)] Codec::Mceliece348864Pub | Codec::Mceliece348864Priv => { Ok(Box::new(classic_mceliece::View::try_from(self)?)) } @@ -1204,6 +1229,8 @@ impl Views for Multikey { | Codec::X25519Frodokem640ShakePriv => { Ok(Box::new(x25519_frodokem640::View::try_from(self)?)) } + #[cfg(feature = "deprecated")] + #[allow(deprecated)] Codec::X25519Mceliece348864Pub | Codec::X25519Mceliece348864Priv => { Ok(Box::new(x25519_mceliece348864::View::try_from(self)?)) } @@ -1353,6 +1380,8 @@ impl Views for Multikey { | Codec::Sntrup1013Priv | Codec::Sntrup1277Pub | Codec::Sntrup1277Priv => Ok(Box::new(sntrup::View::try_from(self)?)), + #[cfg(feature = "deprecated")] + #[allow(deprecated)] Codec::Mceliece348864Pub | Codec::Mceliece348864Priv => { Ok(Box::new(classic_mceliece::View::try_from(self)?)) } @@ -1378,6 +1407,8 @@ impl Views for Multikey { | Codec::X25519Frodokem640ShakePriv => { Ok(Box::new(x25519_frodokem640::View::try_from(self)?)) } + #[cfg(feature = "deprecated")] + #[allow(deprecated)] Codec::X25519Mceliece348864Pub | Codec::X25519Mceliece348864Priv => { Ok(Box::new(x25519_mceliece348864::View::try_from(self)?)) } @@ -1424,6 +1455,8 @@ impl Views for Multikey { | Codec::Sntrup1013Priv | Codec::Sntrup1277Pub | Codec::Sntrup1277Priv => Ok(Box::new(sntrup::View::try_from(self)?)), + #[cfg(feature = "deprecated")] + #[allow(deprecated)] Codec::Mceliece348864Pub | Codec::Mceliece348864Priv => { Ok(Box::new(classic_mceliece::View::try_from(self)?)) } @@ -1449,6 +1482,8 @@ impl Views for Multikey { | Codec::X25519Frodokem640ShakePriv => { Ok(Box::new(x25519_frodokem640::View::try_from(self)?)) } + #[cfg(feature = "deprecated")] + #[allow(deprecated)] Codec::X25519Mceliece348864Pub | Codec::X25519Mceliece348864Priv => { Ok(Box::new(x25519_mceliece348864::View::try_from(self)?)) } @@ -1638,6 +1673,7 @@ impl Views for Multikey { | Codec::XmssSha216256Priv | Codec::XmssSha220256Pub | Codec::XmssSha220256Priv => Ok(Box::new(xmss::View::try_from(self)?)), + Codec::X25519Pub | Codec::X25519Priv => Ok(Box::new(xeddsa::View::try_from(self)?)), _ => Err(ConversionsError::UnsupportedCodec(self.codec).into()), } } @@ -1886,6 +1922,7 @@ impl Views for Multikey { | Codec::XmssSha216256Priv | Codec::XmssSha220256Pub | Codec::XmssSha220256Priv => Ok(Box::new(xmss::View::try_from(self)?)), + Codec::X25519Pub | Codec::X25519Priv => Ok(Box::new(xeddsa::View::try_from(self)?)), _ => Err(ConversionsError::UnsupportedCodec(self.codec).into()), } } @@ -2034,6 +2071,8 @@ impl Builder { rng.fill_bytes(&mut seed); seed.to_vec() } + #[cfg(feature = "deprecated")] + #[allow(deprecated)] Codec::Mceliece348864Priv => { let mut seed = [0u8; 32]; rng.fill_bytes(&mut seed); @@ -2056,6 +2095,8 @@ impl Builder { rng.fill_bytes(&mut seed); seed.to_vec() } + #[cfg(feature = "deprecated")] + #[allow(deprecated)] Codec::X25519Mceliece348864Priv => { // x25519_seed (32) || mceliece_seed (32) = 64 bytes let mut seed = [0u8; 64]; diff --git a/src/views.rs b/src/views.rs index 4c4ecd7..adf7553 100644 --- a/src/views.rs +++ b/src/views.rs @@ -14,6 +14,8 @@ pub(crate) mod bls12381_g1_mayo2; pub(crate) mod bls12381_g1_mldsa65; pub(crate) mod bls12381_hybrid; pub(crate) mod chacha20; +#[cfg(feature = "deprecated")] +#[allow(deprecated)] pub(crate) mod classic_mceliece; pub(crate) mod dkg_threshold; pub(crate) mod ed25519; @@ -39,6 +41,8 @@ pub(crate) mod sntrup; pub mod threshold_marker; /// Threshold disclosure modes and encrypted metadata helpers. pub mod threshold_meta; +/// XEdDSA view: an Ed25519-form signature over an X25519 key (POP-only). +pub(crate) mod xeddsa; pub use threshold_meta::{ DisclosureView, ThresholdDisclosure, ThresholdDisclosureView, ThresholdMetaCipher, ThresholdMetadata, decrypt_threshold_meta, encrypt_threshold_meta, generate_meta_key, @@ -46,13 +50,16 @@ pub use threshold_meta::{ }; pub(crate) mod x25519; pub(crate) mod x25519_frodokem640; +#[cfg(feature = "deprecated")] +#[allow(deprecated)] pub(crate) mod x25519_mceliece348864; pub(crate) mod x25519_mlkem768; pub(crate) mod x25519_sntrup761; #[cfg(feature = "xmss")] pub(crate) mod xmss; -// shared AEAD helper used by ml_kem, sntrup, classic_mceliece, x25519, and hybrid KEM views +// shared AEAD helper used by ml_kem, sntrup, x25519, and hybrid KEM views +// (plus the deprecated classic_mceliece views) pub(crate) mod aead; /// diff --git a/src/views/classic_mceliece.rs b/src/views/classic_mceliece.rs index ea38142..aa48c5d 100644 --- a/src/views/classic_mceliece.rs +++ b/src/views/classic_mceliece.rs @@ -1,6 +1,12 @@ // SPDX-License-Identifier: Apache-2.0 //! Classic McEliece multikey view; post-quantum KEM (348864 variant). //! +//! # Deprecation +//! +//! This view is deprecated. Key-recovery attacks now solve the TII McEliece +//! challenges; see for the +//! recovered keys. It compiles only with the `deprecated` feature. +//! //! Note: mceliece460896 codec entries exist in the codec table for future //! interoperability, but only mceliece348864 is currently supported for key //! generation and operations. The upstream `classic-mceliece-rust` crate uses diff --git a/src/views/x25519_mceliece348864.rs b/src/views/x25519_mceliece348864.rs index 5478f7d..bd8d55f 100644 --- a/src/views/x25519_mceliece348864.rs +++ b/src/views/x25519_mceliece348864.rs @@ -5,6 +5,12 @@ //! //! Private key layout: `x25519_seed (32) || mceliece_seed (32)` = 64 bytes. //! Public key layout (classical-first): `x25519_pub (32) || mceliece_public_key`. +//! +//! # Deprecation +//! +//! This view is deprecated. Key-recovery attacks now solve the TII McEliece +//! challenges; see for the +//! recovered keys. It compiles only with the `deprecated` feature. use crate::{ AttrId, AttrView, Builder, ConvView, DataView, Error, FingerprintView, Multikey, OpenView, diff --git a/src/views/xeddsa.rs b/src/views/xeddsa.rs new file mode 100644 index 0000000..0e20778 --- /dev/null +++ b/src/views/xeddsa.rs @@ -0,0 +1,570 @@ +// SPDX-License-Identifier: Apache-2.0 +//! XEdDSA view; a signature over a 32-byte message that verifies against the +//! Edwards form of an X25519 public key. +//! +//! The scheme is the Signal XEdDSA specification. The proof-of-possession +//! framework uses it to show that the author of a proof knows the X25519 +//! secret key behind a published public key. +//! +//! Key conventions, verified empirically against curve25519-dalek 5.0: +//! +//! 1. The DH scalar is the clamped X25519 secret key, reduced modulo the +//! group order. The published u-coordinate stays the standard ladder +//! output `clamp(sk) * B_mont`, so X25519 DH keeps working unchanged. +//! 2. The POP signing scalar is the sign-normalized variant of the DH +//! scalar: it is negated when the canonical encoding of `a * B` carries +//! y sign 1. This makes the scalar point equal the canonical lift of the +//! published u, which the verifier recovers with y sign 0. +//! 3. The canonical lift of u is the Edwards point recovered from +//! `to_edwards(sign = 0)`. Its compressed encoding re-encodes identically +//! with the sign bit cleared, and `u(lift) == u`. +//! 4. The verification equation is standard Ed25519 over the lifted point, +//! checked with strict verification. + +use crate::{ + AttrId, AttrView, Builder, ConvView, DataView, Error, FingerprintView, Multikey, SignView, + VerifyView, Views, + error::{AttributesError, ConversionsError, SignError, VerifyError}, +}; +use curve25519_dalek::{edwards::EdwardsPoint, montgomery::MontgomeryPoint, scalar::Scalar}; +use ed25519_dalek::{Signature, Verifier, VerifyingKey}; +use multi_codec::Codec; +use multi_hash::{Multihash, mh}; +use multi_sig::{Multisig, Views as SigViews, ms}; +use sha2::{Digest, Sha512}; +use subtle::ConstantTimeEq; +use zeroize::Zeroizing; + +const X25519_KEY_LENGTH: usize = 32; +const ED25519_SIGNATURE_LENGTH: usize = 64; + +/// Small-order u encodings. A key on this list encodes a point whose +/// discrete logarithm with respect to the torsion subgroup is known. +/// Verification rejects every element. +const SMALL_ORDER_PUBKEYS: [[u8; 32]; 8] = [ + // 0 (identity) + [0u8; 32], + // 1 + [ + 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, + ], + // 3256062507161957023555920433985600817822942841665119516180377884119074600195 + [ + 0xe0, 0xeb, 0x7a, 0x7c, 0x28, 0xb3, 0x51, 0xfd, 0x59, 0x9c, 0xae, 0xc3, 0x3f, 0x6d, 0x8a, + 0x58, 0x24, 0x14, 0x0d, 0x0c, 0x7c, 0x2a, 0x36, 0xb5, 0x0f, 0x3e, 0x0d, 0x6f, 0x5d, 0x03, + 0x2b, 0x02, + ], + // p+1 + [ + 0x5f, 0x9c, 0x95, 0xbc, 0xa3, 0x50, 0x8c, 0x24, 0xb1, 0xd0, 0xb1, 0x55, 0x9c, 0x83, 0xef, + 0x5b, 0x04, 0x44, 0x5c, 0xc4, 0x64, 0x4a, 0x9a, 0x67, 0x2c, 0x69, 0x2c, 0xf0, 0x58, 0x6f, + 0x7a, 0x7f, + ], + // p+2 (order 8) + [ + 0xec, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0x7f, + ], + // p+3 (order 8) + [ + 0xed, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0x7f, + ], + // p+4 (order 4) + [ + 0xee, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0x7f, + ], + // p+5 (order 4) + [ + 0xef, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0x7f, + ], +]; + +pub(crate) struct View<'a> { + mk: &'a Multikey, +} + +impl<'a> TryFrom<&'a Multikey> for View<'a> { + type Error = Error; + + fn try_from(mk: &'a Multikey) -> Result { + Ok(Self { mk }) + } +} + +impl<'a> AttrView for View<'a> { + fn is_encrypted(&self) -> bool { + false + } + fn is_secret_key(&self) -> bool { + self.mk.codec == Codec::X25519Priv + } + fn is_public_key(&self) -> bool { + self.mk.codec == Codec::X25519Pub + } + fn is_secret_key_share(&self) -> bool { + false + } +} + +impl<'a> DataView for View<'a> { + fn key_bytes(&self) -> Result>, Error> { + let key = self + .mk + .attributes + .get(&AttrId::KeyData) + .ok_or(AttributesError::MissingKey)?; + Ok(key.clone()) + } + fn secret_bytes(&self) -> Result>, Error> { + if !self.is_secret_key() { + return Err(AttributesError::NotSecretKey(self.mk.codec).into()); + } + self.key_bytes() + } +} + +/// Compute the DH scalar from the X25519 secret key bytes. +/// +/// The scalar is the clamped secret key itself, reduced modulo the group +/// order. Its base multiple is the point behind the published u-coordinate. +fn dh_scalar(secret_bytes: &[u8]) -> Result { + if secret_bytes.len() != X25519_KEY_LENGTH { + return Err( + ConversionsError::SecretKeyFailure("invalid X25519 secret key length".into()).into(), + ); + } + let seed: [u8; 32] = <[u8; 32]>::try_from(secret_bytes) + .map_err(|_| ConversionsError::SecretKeyFailure("invalid X25519 secret key".into()))?; + + // The X25519 clamp: clear the three low bits, clear the high bit, and + // set bit 254. Reduction modulo the group order preserves the scalar + // multiple because the base point has order l. + let mut scalar_bytes = seed; + scalar_bytes[0] &= 0b1111_1000; + scalar_bytes[31] = (scalar_bytes[31] & 0b0111_1111) | 0b0100_0000; + Ok(Scalar::from_bytes_mod_order(scalar_bytes)) +} + +/// Compute the canonical Edwards lift of a Montgomery u coordinate. +/// +/// The lift forces the Edwards y sign bit to zero. The result is the point +/// that `calculate_A_from_u` in the XEdDSA specification recovers. Returns an +/// error when the u encoding is non-canonical, encodes a small-order point, or +/// encodes a point outside the prime-order subgroup. +fn canonical_lift(u_bytes: &[u8; 32]) -> Result { + // Reject the eight small-order representatives. The identity and the + // order-2, order-4, and order-8 points cannot serve as proof keys. + for small in &SMALL_ORDER_PUBKEYS { + if u_bytes.ct_eq(small).into() { + return Err( + ConversionsError::PublicKeyFailure("small-order X25519 public key".into()).into(), + ); + } + } + + // to_edwards(sign) applies the birational map y = (u-1)/(u+1) and picks + // the x sign. Sign 0 recovers the point whose canonical encoding has y + // sign 0: the deterministic canonical lift. + let montgomery_point = MontgomeryPoint(*u_bytes); + let a_ed = montgomery_point.to_edwards(0).ok_or_else(|| { + ConversionsError::PublicKeyFailure("X25519 key is on the twist, not the curve".into()) + })?; + + // The mixed-order check: the point must be torsion-free. A point on the + // curve but outside the prime-order subgroup fails this check. + if !a_ed.is_torsion_free() { + return Err( + ConversionsError::PublicKeyFailure("mixed-order X25519 public key".into()).into(), + ); + } + + // The identity is small order and already rejected above; this round trip + // additionally guards against any encoding drift. + if a_ed.to_montgomery() != montgomery_point { + return Err(ConversionsError::PublicKeyFailure( + "X25519 public key does not round-trip through the canonical lift".into(), + ) + .into()); + } + + Ok(a_ed) +} + +/// Derive the XEdDSA key pair from the secret key bytes: the POP signing +/// scalar and the canonical Edwards point `A` it generates. +/// +/// The scalar is the sign-normalized DH scalar: it is negated when the +/// canonical encoding of the DH scalar's base multiple carries y sign 1. The +/// resulting scalar point equals the canonical lift of the published +/// u-coordinate, so a signature under it verifies through the lift. +fn key_pair(secret_bytes: &[u8]) -> Result<(Scalar, EdwardsPoint), Error> { + let mut a = dh_scalar(secret_bytes)?; + let a_raw = EdwardsPoint::mul_base(&a); + let a_ed = a_raw.to_montgomery().to_edwards(0).ok_or_else(|| { + ConversionsError::PublicKeyFailure("X25519 key is on the twist, not the curve".into()) + })?; + if a_raw.compress().as_bytes()[31] >> 7 == 1 { + a = -a; + } + Ok((a, a_ed)) +} + +impl<'a> FingerprintView for View<'a> { + fn fingerprint(&self, codec: Codec) -> Result { + let pub_bytes = if self.is_secret_key() { + let pk = self.to_public_key()?; + let dv = pk.data_view()?; + dv.key_bytes()? + } else { + self.key_bytes()? + }; + Ok(mh::Builder::new_from_bytes(codec, pub_bytes.as_slice())?.try_build()?) + } +} + +impl<'a> ConvView for View<'a> { + /// Derive the X25519 public key from the secret key bytes. + /// + /// The published u-coordinate is the standard ladder output + /// `clamp(sk) * B_mont`. The POP signing scalar generates the same + /// point, so the derived key verifies under the XEdDSA verifier. + fn to_public_key(&self) -> Result { + let secret_bytes = { + let kd = self.mk.data_view()?; + kd.secret_bytes()? + }; + let public = x25519_dalek::PublicKey::from(&x25519_dalek::StaticSecret::from( + *secret_bytes.as_slice().first_chunk::<32>().ok_or_else(|| { + ConversionsError::SecretKeyFailure("invalid X25519 secret key".into()) + })?, + )); + + Builder::new(Codec::X25519Pub) + .with_comment(&self.mk.comment) + .with_key_bytes(&public.as_bytes().to_vec()) + .try_build() + } + + fn to_ssh_public_key(&self) -> Result { + Err( + ConversionsError::UnsupportedAlgorithm("X25519 not supported in SSH key format".into()) + .into(), + ) + } + fn to_ssh_private_key(&self) -> Result { + Err( + ConversionsError::UnsupportedAlgorithm("X25519 not supported in SSH key format".into()) + .into(), + ) + } +} + +impl<'a> SignView for View<'a> { + /// Sign a 32-byte message with XEdDSA and return a `xeddsa-msig` multisig. + /// + /// The message restriction is normative. The proof-of-possession framework + /// is the only authorized caller class, and its messages are 32-byte + /// transcript challenges. + fn sign(&self, msg: &[u8], combined: bool, _scheme: Option) -> Result { + let attr = self.mk.attr_view()?; + if !attr.is_secret_key() { + return Err(SignError::NotSigningKey.into()); + } + if msg.len() != X25519_KEY_LENGTH { + return Err(Error::UnsupportedAlgorithm( + "XEdDSA signs 32-byte transcript challenges only; this view is POP-only".into(), + )); + } + + let secret_bytes = { + let kd = self.mk.data_view()?; + kd.secret_bytes()? + }; + + let (a, a_ed) = key_pair(&secret_bytes)?; + + // Nonce derivation per XEdDSA section 2.4: + // r = hash1(a || pad1 || M || pad2 || Z) + // with pad1 = 32 zero bytes, pad2 = 32 zero bytes for SHA-512, and Z + // 64 random bytes from a cryptographically secure source. + let mut z = [0u8; 64]; + getrandom::fill(&mut z).map_err(|e| SignError::SigningFailed(e.to_string()))?; + + let mut nonce_hasher = Sha512::default(); + nonce_hasher.update(a.as_bytes()); + nonce_hasher.update([0u8; 32]); // pad1 + nonce_hasher.update(msg); + nonce_hasher.update([0u8; 32]); // pad2 + nonce_hasher.update(z); + let r = Scalar::from_hash(nonce_hasher); + let big_r = EdwardsPoint::mul_base(&r).compress(); + + // S = r + H(R || A || M) * a (mod l), with A in the canonical + // sign-0 encoding that the verifier also recovers from u. + let a_ed_bytes = a_ed.compress().to_bytes(); + let mut challenge_input = Vec::with_capacity(32 + 32 + msg.len()); + challenge_input.extend_from_slice(big_r.as_bytes()); + challenge_input.extend_from_slice(&a_ed_bytes); + challenge_input.extend_from_slice(msg); + let s = Scalar::from_hash(Sha512::default().chain_update(&challenge_input)) * a + r; + + let mut signature_bytes = [0u8; ED25519_SIGNATURE_LENGTH]; + signature_bytes[..32].copy_from_slice(big_r.as_bytes()); + signature_bytes[32..].copy_from_slice(s.as_bytes()); + + let mut builder = + ms::Builder::new(Codec::XeddsaMsig).with_signature_bytes(&signature_bytes); + if combined { + builder = builder.with_message_bytes(&msg); + } + Ok(builder.try_build()?) + } +} + +impl<'a> VerifyView for View<'a> { + /// Verify an XEdDSA multisig against the Edwards form of the X25519 key. + /// + /// The verifier computes the canonical lift itself. It rejects a + /// non-canonical u encoding, a small-order key, and a mixed-order key. + /// Signature verification is strict: a non-canonical R or S is rejected. + fn verify(&self, multisig: &Multisig, msg: Option<&[u8]>) -> Result<(), Error> { + let attr = self.mk.attr_view()?; + let pubmk = if attr.is_secret_key() { + self.mk.conv_view()?.to_public_key()? + } else { + self.mk.clone() + }; + + if pubmk.codec != Codec::X25519Pub { + return Err(AttributesError::UnsupportedCodec(pubmk.codec).into()); + } + + let key_bytes = { + let kd = pubmk.data_view()?; + kd.key_bytes()? + }; + + let u_bytes: [u8; 32] = key_bytes.as_slice().try_into().map_err(|_| { + ConversionsError::PublicKeyFailure("invalid X25519 public key length".to_string()) + })?; + + let a_ed = canonical_lift(&u_bytes)?; + let verifying_key_bytes: [u8; 32] = a_ed.compress().to_bytes(); + let verifying_key = VerifyingKey::from_bytes(&verifying_key_bytes) + .map_err(|e| ConversionsError::PublicKeyFailure(e.to_string()))?; + + let sv = multisig.data_view()?; + let sig = sv.sig_bytes().map_err(|_| VerifyError::MissingSignature)?; + if sig.len() != ED25519_SIGNATURE_LENGTH { + return Err(VerifyError::BadSignature( + "XEdDSA signature must be exactly 64 bytes".into(), + ) + .into()); + } + + let sig = Signature::from_slice(sig.as_slice()) + .map_err(|e| VerifyError::BadSignature(e.to_string()))?; + + let msg = if let Some(msg) = msg { + msg + } else if !multisig.message.is_empty() { + multisig.message.as_slice() + } else { + return Err(VerifyError::MissingMessage.into()); + }; + if msg.len() != X25519_KEY_LENGTH { + return Err( + VerifyError::BadSignature("XEdDSA signs 32-byte messages only".into()).into(), + ); + } + + // Ed25519 strict verification: a non-canonical R or S fails. + verifying_key + .verify(msg, &sig) + .map_err(|e| VerifyError::BadSignature(e.to_string()))?; + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{Builder, Views}; + + fn key_pair_mks() -> (Multikey, Multikey) { + let mut rng = rand::rng(); + let sk = Builder::new_from_random_bytes(Codec::X25519Priv, &mut rng) + .unwrap() + .try_build() + .unwrap(); + let pk = sk.conv_view().unwrap().to_public_key().unwrap(); + (sk, pk) + } + + fn message32() -> [u8; 32] { + let mut msg = [0u8; 32]; + getrandom::fill(&mut msg).unwrap(); + msg + } + + #[test] + fn test_xeddsa_sign_verify_roundtrip() { + for _ in 0..8 { + let (sk, pk) = key_pair_mks(); + let msg = message32(); + let sig = sk.sign_view().unwrap().sign(&msg, false, None).unwrap(); + pk.verify_view().unwrap().verify(&sig, Some(&msg)).unwrap(); + } + } + + #[test] + fn test_xeddsa_rejects_non_32_byte_message() { + let (sk, _pk) = key_pair_mks(); + let sv = sk.sign_view().unwrap(); + assert!(sv.sign(b"short", false, None).is_err()); + assert!(sv.sign(&[0u8; 33], false, None).is_err()); + assert!(sv.sign(&[0u8; 31], false, None).is_err()); + } + + #[test] + fn test_xeddsa_wrong_message_fails() { + let (sk, pk) = key_pair_mks(); + let msg = message32(); + let other = message32(); + let sig = sk.sign_view().unwrap().sign(&msg, false, None).unwrap(); + assert!( + pk.verify_view() + .unwrap() + .verify(&sig, Some(&other)) + .is_err() + ); + } + + #[test] + fn test_xeddsa_wrong_key_fails() { + let (sk, _pk) = key_pair_mks(); + let (_sk2, pk2) = key_pair_mks(); + let msg = message32(); + let sig = sk.sign_view().unwrap().sign(&msg, false, None).unwrap(); + assert!(pk2.verify_view().unwrap().verify(&sig, Some(&msg)).is_err()); + } + + #[test] + fn test_xeddsa_public_key_cannot_sign() { + let (_sk, pk) = key_pair_mks(); + let msg = message32(); + assert!(pk.sign_view().unwrap().sign(&msg, false, None).is_err()); + } + + #[test] + fn test_xeddsa_tampered_signature_fails() { + let (sk, pk) = key_pair_mks(); + let msg = message32(); + let sig = sk.sign_view().unwrap().sign(&msg, false, None).unwrap(); + let view = sig.data_view().unwrap(); + let mut bytes = view.sig_bytes().unwrap(); + bytes[0] ^= 1; + let tampered = ms::Builder::new(Codec::XeddsaMsig) + .with_signature_bytes(&bytes) + .try_build() + .unwrap(); + assert!( + pk.verify_view() + .unwrap() + .verify(&tampered, Some(&msg)) + .is_err() + ); + } + + #[test] + fn test_xeddsa_kat_rfc7748_seed() { + // KAT over the RFC 7748 Alice key pair. The seed is the RFC 7748 + // Alice private key. The XEdDSA signing scalar is the clamped seed. + let seed: [u8; 32] = [ + 0x77, 0x07, 0x6d, 0x0a, 0x73, 0x18, 0xa5, 0x7d, 0x3c, 0x16, 0xc1, 0x72, 0x51, 0xb2, + 0x66, 0x45, 0xdf, 0x4c, 0x2f, 0x87, 0xeb, 0xc0, 0x99, 0x2a, 0xb1, 0x77, 0xfb, 0xa5, + 0x1d, 0xb9, 0x2c, 0x2a, + ]; + let sk = Builder::new(Codec::X25519Priv) + .with_key_bytes(&seed) + .try_build() + .unwrap(); + let pk = sk.conv_view().unwrap().to_public_key().unwrap(); + + // The published u must be the standard RFC 7748 ladder output. + let expected_u: [u8; 32] = [ + 0x85, 0x20, 0xf0, 0x09, 0x89, 0x30, 0xa7, 0x54, 0x74, 0x8b, 0x7d, 0xdc, 0xb4, 0x3e, + 0xf7, 0x5a, 0x0d, 0xbf, 0x3a, 0x0d, 0x26, 0x38, 0x1a, 0xf4, 0xeb, 0xa4, 0xa9, 0x8e, + 0xaa, 0x9b, 0x4e, 0x6a, + ]; + let dv = pk.data_view().unwrap(); + let pub_bytes = dv.key_bytes().unwrap(); + let mut pub_arr = [0u8; 32]; + pub_arr.copy_from_slice(pub_bytes.as_slice()); + assert_eq!(pub_arr, expected_u); + + // The POP signing scalar is the sign-normalized DH scalar; its + // canonical Edwards point equals the canonical lift of the ladder u. + let (a, a_ed) = key_pair(&seed).unwrap(); + assert_eq!(*a_ed.to_montgomery().as_bytes(), expected_u); + + // The canonical lift of the published u recovers the same point. + let lift = canonical_lift(&pub_arr).unwrap(); + assert_eq!(lift, a_ed); + + // Sign and verify a fixed 32-byte message. The nonce is derived per + // the spec with a fresh random Z, so only verify is deterministic. + let msg: [u8; 32] = core::array::from_fn(|i| (i as u8).wrapping_mul(7)); + let sig = sk.sign_view().unwrap().sign(&msg, false, None).unwrap(); + pk.verify_view().unwrap().verify(&sig, Some(&msg)).unwrap(); + + // Every signature is 64 bytes of R || s. + let dv = sig.data_view().unwrap(); + assert_eq!(dv.sig_bytes().unwrap().len(), 64); + let _ = a; + } + + #[test] + fn test_xeddsa_lift_determinism() { + // The lift is a function: for random valid u, lift-then-encode is + // deterministic and returns sign 0. + for _ in 0..16 { + let (sk, pk) = key_pair_mks(); + let dv = pk.data_view().unwrap(); + let mut u = [0u8; 32]; + u.copy_from_slice(dv.key_bytes().unwrap().as_slice()); + let lift = canonical_lift(&u).unwrap(); + assert_eq!(lift.compress().as_bytes()[31] >> 7, 0); + assert_eq!(*lift.to_montgomery().as_bytes(), u); + let _ = sk; + } + } + + #[test] + fn test_xeddsa_small_order_keys_rejected() { + let (sk, _pk) = key_pair_mks(); + let msg = message32(); + let sig = sk.sign_view().unwrap().sign(&msg, false, None).unwrap(); + + for small in &SMALL_ORDER_PUBKEYS { + let pk = Builder::new(Codec::X25519Pub) + .with_key_bytes(small) + .try_build() + .unwrap(); + assert!( + pk.verify_view().unwrap().verify(&sig, Some(&msg)).is_err(), + "small-order key {small:?} must not verify" + ); + } + } +}