From 6778451d1ebb3fe952fcd532917f98fa6d6e74f7 Mon Sep 17 00:00:00 2001 From: Dave Grantham Date: Tue, 1 Sep 2026 17:26:08 -0600 Subject: [PATCH 1/3] merkle signature view Signed-off-by: Dave Grantham --- CHANGELOG.md | 15 ++ Cargo.toml | 13 +- src/attrid.rs | 7 + src/error.rs | 9 + src/ms.rs | 216 +++++++++++++++++++++++ src/views.rs | 3 + src/views/lamport.rs | 20 +-- src/views/lamport_merkle.rs | 337 ++++++++++++++++++++++++++++++++++++ 8 files changed, 606 insertions(+), 14 deletions(-) create mode 100644 src/views/lamport_merkle.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index fcaf7ae..d7c9813 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,21 @@ 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). +## [1.3.0] - 2026-09-01 + +### Added + +- Merkle-tree Lamport multisig view (`views::lamport_merkle`, behind the default `lamport` feature) wrapping the `lamport_signature_plus` 0.5.0 `Mt*` API: signature-share accumulation in `ThresholdData` and combination via `MtSignature::combine`, for 11 digest variants (SHA3-512/384/256, SHA2-512/384/256, BLAKE2b-512, BLAKE2s-256, BLAKE3-256, SHAKE-128/256) with the 22 new `LamportMerkle*Sig`/`*SigShare` codecs (codes `0x1a94`-`0x1aae`). +- `AttrId::Depth` attribute (code 11, name `depth`, one raw byte) for merkle-tree signature schemes, stamped on accumulators and combined signatures and cross-checked against the depth byte embedded in the share blobs; mismatches return `AttributesError::DepthMismatch`. +- `Builder::with_depth(depth)` and `Multisig::depth() -> Option`. +- The 11 `LamportMerkle*Sig` codecs added to `SIG_CODECS` (35→46 entries without the XMSS duplicate fix, 47 entries total) and the 11 `LamportMerkle*SigShare` codecs added to `SIG_SHARE_CODECS` (2→13). + +### Changed + +- Bumped `lamport_signature_plus` from `0.5.0-rc2` to `0.5.0`. +- Raised `rust-version` from `1.87` to `1.96` (required by `lamport_signature_plus` 0.5.0). +- `multi-codec` dependency raised to `1.3` (adds the `LamportMerkle*` codecs). + ## [1.2.1] - 2026-08-18 ### Fixed diff --git a/Cargo.toml b/Cargo.toml index 0a665cf..1505856 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,8 +1,8 @@ [package] name = "multi-sig" -version = "1.2.1" +version = "1.3.0" edition = "2024" -rust-version = "1.87" +rust-version = "1.96" authors = ["Dave Grantham "] description = "Multisig self-describing multicodec implementation for digital signatures" repository = "https://github.com/cryptidtech/multi-sig.git" @@ -21,6 +21,7 @@ serde = [] lamport = ["dep:lamport_signature_plus", "dep:sha3", "dep:sha2", "dep:blake2", "dep:shake", "dep:blake3"] xmss = ["dep:xmss"] +# Local multi-codec 1.3.0 until it is published (remove at release). [dependencies] ciborium = "0.2" chacha20poly1305 = "0.11" @@ -28,7 +29,7 @@ elliptic-curve = "0.14" getrandom = { version = "0.4" } # blsful configured per-target below (blst for native, rust for wasm) multi-base = { version = "1.0", default-features = false } -multi-codec = "1.2" +multi-codec = "1.3" multi-trait = { version = "1.0", default-features = false } multi-util = "1.1" serde = { version = "1.0", default-features = false, features = ["alloc", "derive"] } @@ -37,7 +38,7 @@ thiserror = { version = "2.0" } unsigned-varint = { version = "0.8", features = ["std"] } zeroize = "1" # Lamport support (feature-gated, default-enabled) -lamport_signature_plus = { version = "0.5.0-rc2", optional = true } +lamport_signature_plus = { version = "0.5.0", optional = true } sha3 = { version = "0.12", optional = true } sha2 = { version = "0.11", optional = true } blake2 = { version = "0.11.0-rc.6", optional = true } @@ -75,3 +76,7 @@ multiple_crate_versions = { level = "allow", priority = 1 } [lints.rust] unsafe_code = "deny" + +# Local multi-codec 1.3.0 until it is published (remove at release). +[patch.crates-io] +multi-codec = { path = "../multi-codec" } diff --git a/src/attrid.rs b/src/attrid.rs index 133a919..513d4c7 100644 --- a/src/attrid.rs +++ b/src/attrid.rs @@ -31,6 +31,10 @@ pub enum AttrId { ThresholdMetaCipher, /// XMSS leaf index (u32 big-endian) for stateful signature schemes. SigIndex, + /// Merkle-tree signature scheme depth (one raw byte; the tree holds + /// 2^depth one-time leaves). Cross-checked against the depth byte embedded + /// in the signature wire data. + Depth, } impl AttrId { @@ -53,6 +57,7 @@ impl AttrId { Self::EncryptedThresholdMeta => "encrypted-threshold-meta", Self::ThresholdMetaCipher => "threshold-meta-cipher", Self::SigIndex => "sig-index", + Self::Depth => "depth", } } } @@ -79,6 +84,7 @@ impl TryFrom for AttrId { 8 => Ok(Self::EncryptedThresholdMeta), 9 => Ok(Self::ThresholdMetaCipher), 10 => Ok(Self::SigIndex), + 11 => Ok(Self::Depth), _ => Err(AttributesError::InvalidAttributeValue(c).into()), } } @@ -124,6 +130,7 @@ impl TryFrom<&str> for AttrId { "encrypted-threshold-meta" => Ok(Self::EncryptedThresholdMeta), "threshold-meta-cipher" => Ok(Self::ThresholdMetaCipher), "sig-index" => Ok(Self::SigIndex), + "depth" => Ok(Self::Depth), _ => Err(AttributesError::InvalidAttributeName(s.to_string()).into()), } } diff --git a/src/error.rs b/src/error.rs index 39336e3..f6d13ac 100644 --- a/src/error.rs +++ b/src/error.rs @@ -108,6 +108,15 @@ pub enum AttributesError { /// Invalid attribute value #[error("Invalid attribute value {0}")] InvalidAttributeValue(u8), + /// A merkle-tree depth attribute does not match the depth byte embedded in + /// the signature wire data. + #[error("Depth mismatch: attribute says {expected}, wire data says {found}")] + DepthMismatch { + /// depth declared by the `depth` attribute + expected: u8, + /// depth byte embedded in the wire data + found: u8, + }, } /// Shares errors created by this library diff --git a/src/ms.rs b/src/ms.rs index b210a25..f2c0dd7 100644 --- a/src/ms.rs +++ b/src/ms.rs @@ -1,6 +1,8 @@ // SPDX-License-Identifier: Apache-2.0 #[cfg(feature = "lamport")] use crate::views::lamport; +#[cfg(feature = "lamport")] +use crate::views::lamport_merkle; #[cfg(feature = "xmss")] use crate::views::xmss; use crate::{ @@ -25,6 +27,59 @@ use multi_util::{BaseEncoded, CodecInfo, EncodingInfo, Varbytes, Varuint}; use std::{collections::BTreeMap, fmt}; /// the list of signature codecs currently supported +#[cfg(feature = "lamport")] +pub const SIG_CODECS: [Codec; 47] = [ + Codec::Bls12381G1Msig, + Codec::Bls12381G2Msig, + Codec::EddsaMsig, + Codec::Es256KMsig, + Codec::Es256Msig, + Codec::Es384Msig, + Codec::Es521Msig, + Codec::Rs256Msig, + Codec::SlhDsaSha2128FMsig, + Codec::SlhDsaSha2128SMsig, + Codec::SlhDsaSha2192FMsig, + Codec::SlhDsaSha2192SMsig, + Codec::SlhDsaSha2256FMsig, + Codec::SlhDsaSha2256SMsig, + Codec::SlhDsaShake128FMsig, + Codec::SlhDsaShake128SMsig, + Codec::SlhDsaSha2192FMsig, + Codec::SlhDsaShake192FMsig, + Codec::SlhDsaShake192SMsig, + Codec::SlhDsaShake256FMsig, + Codec::SlhDsaShake256SMsig, + Codec::MlDsa65Msig, + Codec::MlDsa87Msig, + Codec::FnDsa512Msig, + Codec::FnDsa1024Msig, + Codec::Mayo1Msig, + Codec::Mayo2Msig, + Codec::Mayo3Msig, + Codec::Mayo5Msig, + Codec::Ed25519Mayo2Msig, + Codec::Ed25519Mldsa65Msig, + Codec::Ed25519Fndsa512Msig, + Codec::Bls12381G1Mldsa65Msig, + Codec::Bls12381G1Fndsa512Msig, + Codec::Bls12381G1Mayo1Msig, + Codec::Bls12381G1Mayo2Msig, + Codec::LamportMerkleSha3512Sig, + Codec::LamportMerkleSha3384Sig, + Codec::LamportMerkleSha3256Sig, + Codec::LamportMerkleSha2512Sig, + Codec::LamportMerkleSha2384Sig, + Codec::LamportMerkleSha2256Sig, + Codec::LamportMerkleBlake2B512Sig, + Codec::LamportMerkleBlake2S256Sig, + Codec::LamportMerkleBlake3256Sig, + Codec::LamportMerkleShake128Sig, + Codec::LamportMerkleShake256Sig, +]; + +/// the list of signature codecs currently supported +#[cfg(not(feature = "lamport"))] pub const SIG_CODECS: [Codec; 35] = [ Codec::Bls12381G1Msig, Codec::Bls12381G2Msig, @@ -42,6 +97,7 @@ pub const SIG_CODECS: [Codec; 35] = [ Codec::SlhDsaSha2256SMsig, Codec::SlhDsaShake128FMsig, Codec::SlhDsaShake128SMsig, + Codec::SlhDsaSha2192FMsig, Codec::SlhDsaShake192FMsig, Codec::SlhDsaShake192SMsig, Codec::SlhDsaShake256FMsig, @@ -64,6 +120,25 @@ pub const SIG_CODECS: [Codec; 35] = [ ]; /// the list of signature share codecs supported +#[cfg(feature = "lamport")] +pub const SIG_SHARE_CODECS: [Codec; 13] = [ + Codec::Bls12381G1ShareMsig, + Codec::Bls12381G2ShareMsig, + Codec::LamportMerkleSha3512SigShare, + Codec::LamportMerkleSha3384SigShare, + Codec::LamportMerkleSha3256SigShare, + Codec::LamportMerkleSha2512SigShare, + Codec::LamportMerkleSha2384SigShare, + Codec::LamportMerkleSha2256SigShare, + Codec::LamportMerkleBlake2B512SigShare, + Codec::LamportMerkleBlake2S256SigShare, + Codec::LamportMerkleBlake3256SigShare, + Codec::LamportMerkleShake128SigShare, + Codec::LamportMerkleShake256SigShare, +]; + +/// the list of signature share codecs supported +#[cfg(not(feature = "lamport"))] pub const SIG_SHARE_CODECS: [Codec; 2] = [ Codec::Bls12381G1ShareMsig, Codec::Bls12381G2ShareMsig, //, @@ -246,6 +321,17 @@ impl Multisig { None } } + + /// Reads the [`AttrId::Depth`] attribute (one raw byte) for merkle-tree + /// signature schemes. Returns `None` when absent or malformed. + pub fn depth(&self) -> Option { + let bytes = self.attributes.get(&AttrId::Depth)?; + if bytes.len() == 1 { + Some(bytes[0]) + } else { + None + } + } } impl fmt::Debug for Multisig { @@ -327,6 +413,31 @@ impl Views for Multisig { | Codec::LamportShake256Sig | Codec::LamportShake128SigShare | Codec::LamportShake256SigShare => Ok(Box::new(lamport::View::try_from(self)?)), + #[cfg(feature = "lamport")] + Codec::LamportMerkleSha3512Sig + | Codec::LamportMerkleSha3512SigShare + | Codec::LamportMerkleSha3384Sig + | Codec::LamportMerkleSha3384SigShare + | Codec::LamportMerkleSha3256Sig + | Codec::LamportMerkleSha3256SigShare + | Codec::LamportMerkleSha2512Sig + | Codec::LamportMerkleSha2512SigShare + | Codec::LamportMerkleSha2384Sig + | Codec::LamportMerkleSha2384SigShare + | Codec::LamportMerkleSha2256Sig + | Codec::LamportMerkleSha2256SigShare + | Codec::LamportMerkleBlake2B512Sig + | Codec::LamportMerkleBlake2B512SigShare + | Codec::LamportMerkleBlake2S256Sig + | Codec::LamportMerkleBlake2S256SigShare + | Codec::LamportMerkleBlake3256Sig + | Codec::LamportMerkleBlake3256SigShare + | Codec::LamportMerkleShake128Sig + | Codec::LamportMerkleShake128SigShare + | Codec::LamportMerkleShake256Sig + | Codec::LamportMerkleShake256SigShare => { + Ok(Box::new(lamport_merkle::View::try_from(self)?)) + } #[cfg(feature = "xmss")] Codec::XmssSha210256Msig | Codec::XmssSha216256Msig | Codec::XmssSha220256Msig => { Ok(Box::new(xmss::View::try_from(self)?)) @@ -396,6 +507,31 @@ impl Views for Multisig { | Codec::LamportShake256Sig | Codec::LamportShake128SigShare | Codec::LamportShake256SigShare => Ok(Box::new(lamport::View::try_from(self)?)), + #[cfg(feature = "lamport")] + Codec::LamportMerkleSha3512Sig + | Codec::LamportMerkleSha3512SigShare + | Codec::LamportMerkleSha3384Sig + | Codec::LamportMerkleSha3384SigShare + | Codec::LamportMerkleSha3256Sig + | Codec::LamportMerkleSha3256SigShare + | Codec::LamportMerkleSha2512Sig + | Codec::LamportMerkleSha2512SigShare + | Codec::LamportMerkleSha2384Sig + | Codec::LamportMerkleSha2384SigShare + | Codec::LamportMerkleSha2256Sig + | Codec::LamportMerkleSha2256SigShare + | Codec::LamportMerkleBlake2B512Sig + | Codec::LamportMerkleBlake2B512SigShare + | Codec::LamportMerkleBlake2S256Sig + | Codec::LamportMerkleBlake2S256SigShare + | Codec::LamportMerkleBlake3256Sig + | Codec::LamportMerkleBlake3256SigShare + | Codec::LamportMerkleShake128Sig + | Codec::LamportMerkleShake128SigShare + | Codec::LamportMerkleShake256Sig + | Codec::LamportMerkleShake256SigShare => { + Ok(Box::new(lamport_merkle::View::try_from(self)?)) + } #[cfg(feature = "xmss")] Codec::XmssSha210256Msig | Codec::XmssSha216256Msig | Codec::XmssSha220256Msig => { Ok(Box::new(xmss::View::try_from(self)?)) @@ -465,6 +601,31 @@ impl Views for Multisig { | Codec::LamportShake256Sig | Codec::LamportShake128SigShare | Codec::LamportShake256SigShare => Ok(Box::new(lamport::View::try_from(self)?)), + #[cfg(feature = "lamport")] + Codec::LamportMerkleSha3512Sig + | Codec::LamportMerkleSha3512SigShare + | Codec::LamportMerkleSha3384Sig + | Codec::LamportMerkleSha3384SigShare + | Codec::LamportMerkleSha3256Sig + | Codec::LamportMerkleSha3256SigShare + | Codec::LamportMerkleSha2512Sig + | Codec::LamportMerkleSha2512SigShare + | Codec::LamportMerkleSha2384Sig + | Codec::LamportMerkleSha2384SigShare + | Codec::LamportMerkleSha2256Sig + | Codec::LamportMerkleSha2256SigShare + | Codec::LamportMerkleBlake2B512Sig + | Codec::LamportMerkleBlake2B512SigShare + | Codec::LamportMerkleBlake2S256Sig + | Codec::LamportMerkleBlake2S256SigShare + | Codec::LamportMerkleBlake3256Sig + | Codec::LamportMerkleBlake3256SigShare + | Codec::LamportMerkleShake128Sig + | Codec::LamportMerkleShake128SigShare + | Codec::LamportMerkleShake256Sig + | Codec::LamportMerkleShake256SigShare => { + Ok(Box::new(lamport_merkle::View::try_from(self)?)) + } #[cfg(feature = "xmss")] Codec::XmssSha210256Msig | Codec::XmssSha216256Msig | Codec::XmssSha220256Msig => { Ok(Box::new(xmss::View::try_from(self)?)) @@ -502,6 +663,31 @@ impl Views for Multisig { | Codec::LamportShake128SigShare | Codec::LamportShake256Sig | Codec::LamportShake256SigShare => Ok(Box::new(lamport::View::try_from(self)?)), + #[cfg(feature = "lamport")] + Codec::LamportMerkleSha3512Sig + | Codec::LamportMerkleSha3512SigShare + | Codec::LamportMerkleSha3384Sig + | Codec::LamportMerkleSha3384SigShare + | Codec::LamportMerkleSha3256Sig + | Codec::LamportMerkleSha3256SigShare + | Codec::LamportMerkleSha2512Sig + | Codec::LamportMerkleSha2512SigShare + | Codec::LamportMerkleSha2384Sig + | Codec::LamportMerkleSha2384SigShare + | Codec::LamportMerkleSha2256Sig + | Codec::LamportMerkleSha2256SigShare + | Codec::LamportMerkleBlake2B512Sig + | Codec::LamportMerkleBlake2B512SigShare + | Codec::LamportMerkleBlake2S256Sig + | Codec::LamportMerkleBlake2S256SigShare + | Codec::LamportMerkleBlake3256Sig + | Codec::LamportMerkleBlake3256SigShare + | Codec::LamportMerkleShake128Sig + | Codec::LamportMerkleShake128SigShare + | Codec::LamportMerkleShake256Sig + | Codec::LamportMerkleShake256SigShare => { + Ok(Box::new(lamport_merkle::View::try_from(self)?)) + } _ => Err(AttributesError::UnsupportedCodec(self.codec).into()), } } @@ -534,6 +720,31 @@ impl Views for Multisig { | Codec::LamportShake128SigShare | Codec::LamportShake256Sig | Codec::LamportShake256SigShare => Ok(Box::new(lamport::View::try_from(self)?)), + #[cfg(feature = "lamport")] + Codec::LamportMerkleSha3512Sig + | Codec::LamportMerkleSha3512SigShare + | Codec::LamportMerkleSha3384Sig + | Codec::LamportMerkleSha3384SigShare + | Codec::LamportMerkleSha3256Sig + | Codec::LamportMerkleSha3256SigShare + | Codec::LamportMerkleSha2512Sig + | Codec::LamportMerkleSha2512SigShare + | Codec::LamportMerkleSha2384Sig + | Codec::LamportMerkleSha2384SigShare + | Codec::LamportMerkleSha2256Sig + | Codec::LamportMerkleSha2256SigShare + | Codec::LamportMerkleBlake2B512Sig + | Codec::LamportMerkleBlake2B512SigShare + | Codec::LamportMerkleBlake2S256Sig + | Codec::LamportMerkleBlake2S256SigShare + | Codec::LamportMerkleBlake3256Sig + | Codec::LamportMerkleBlake3256SigShare + | Codec::LamportMerkleShake128Sig + | Codec::LamportMerkleShake128SigShare + | Codec::LamportMerkleShake256Sig + | Codec::LamportMerkleShake256SigShare => { + Ok(Box::new(lamport_merkle::View::try_from(self)?)) + } _ => Err(AttributesError::UnsupportedCodec(self.codec).into()), } } @@ -876,6 +1087,11 @@ impl Builder { self.with_attribute(AttrId::SigIndex, &index.to_be_bytes().to_vec()) } + /// Set the merkle-tree depth (one raw byte, 1..=3). + pub fn with_depth(self, depth: u8) -> Self { + self.with_attribute(AttrId::Depth, &vec![depth]) + } + /// Set the disclosure mode for a threshold sig share being built. /// /// In Full mode, t and n are plaintext. In Partial/FullConfidentialial, diff --git a/src/views.rs b/src/views.rs index ae75f88..8837cc7 100644 --- a/src/views.rs +++ b/src/views.rs @@ -15,6 +15,9 @@ pub mod fn_dsa; /// Lamport one-time hash-based signature implementation #[cfg(feature = "lamport")] pub mod lamport; +/// Merkle-tree Lamport signature implementation +#[cfg(feature = "lamport")] +pub mod lamport_merkle; /// MAYO post-quantum multivariate signature implementation pub mod mayo; /// ML-DSA post-quantum signature implementation; FIPS 204 diff --git a/src/views/lamport.rs b/src/views/lamport.rs index d911201..53124e3 100644 --- a/src/views/lamport.rs +++ b/src/views/lamport.rs @@ -27,16 +27,16 @@ use sha2::{Sha256, Sha384, Sha512}; use sha3::{Sha3_256, Sha3_384, Sha3_512}; use shake::{Shake128, Shake256}; -type Sha3_256Digest = LamportFixedDigest; -type Sha3_384Digest = LamportFixedDigest; -type Sha3_512Digest = LamportFixedDigest; -type Sha2_256Digest = LamportFixedDigest; -type Sha2_384Digest = LamportFixedDigest; -type Sha2_512Digest = LamportFixedDigest; -type Blake2b512Digest = LamportFixedDigest; -type Blake2s256Digest = LamportFixedDigest; -type Shake128Digest = LamportExtendableDigest; -type Shake256Digest = LamportExtendableDigest; +pub(crate) type Sha3_256Digest = LamportFixedDigest; +pub(crate) type Sha3_384Digest = LamportFixedDigest; +pub(crate) type Sha3_512Digest = LamportFixedDigest; +pub(crate) type Sha2_256Digest = LamportFixedDigest; +pub(crate) type Sha2_384Digest = LamportFixedDigest; +pub(crate) type Sha2_512Digest = LamportFixedDigest; +pub(crate) type Blake2b512Digest = LamportFixedDigest; +pub(crate) type Blake2s256Digest = LamportFixedDigest; +pub(crate) type Shake128Digest = LamportExtendableDigest; +pub(crate) type Shake256Digest = LamportExtendableDigest; /// BLAKE3 digest for Lamport (not a RustCrypto/hashes crate, so direct impl). #[derive(Copy, Clone, Debug, Default)] diff --git a/src/views/lamport_merkle.rs b/src/views/lamport_merkle.rs new file mode 100644 index 0000000..fa48333 --- /dev/null +++ b/src/views/lamport_merkle.rs @@ -0,0 +1,337 @@ +// SPDX-License-Identifier: Apache-2.0 +//! Merkle-tree Lamport multisig view (`lamport_signature_plus` 0.5.0 `Mt*` API): +//! signature-share accumulation and combination. +//! +//! Threshold model: like the one-time Lamport view, `MtSignatureShare` blobs +//! embed their own GF(256) identifier and leaf inclusion proof; the accumulator +//! is a length-prefixed list of share blobs in `ThresholdData`, and +//! [`combine`](ThresholdView::combine) hands them to +//! `lamport_signature_plus::MtSignature::::combine`. +//! +//! Depth discipline: the accumulator and every combined signature carry the +//! `depth` attribute (one raw byte), cross-checked against the depth byte at +//! offset 0 of each `MtSignatureShare` blob. + +use crate::{ + AttrId, AttrView, Builder, ConvView, DataView, Error, Multisig, ThresholdAttrView, + ThresholdView, Views, + error::{AttributesError, SharesError}, + views::ThresholdDisclosure, + views::lamport::{ + Blake2b512Digest, Blake2s256Digest, Blake3_256Digest, Sha2_256Digest, Sha2_384Digest, + Sha2_512Digest, Sha3_256Digest, Sha3_384Digest, Sha3_512Digest, Shake128Digest, + Shake256Digest, + }, +}; +use lamport_signature_plus::{LamportDigest, MtSignature, MtSignatureShare}; +use multi_codec::Codec; +use multi_trait::TryDecodeFrom; +use multi_util::{CodecInfo as _, Varbytes, Varuint}; + +pub(crate) struct View<'a> { + ms: &'a Multisig, +} + +impl<'a> TryFrom<&'a Multisig> for View<'a> { + type Error = Error; + + fn try_from(ms: &'a Multisig) -> Result { + Ok(Self { ms }) + } +} + +/// Map a merkle-Lamport signature codec (the accumulator) to its +/// signature-share codec, or the reverse. +fn share_codec(codec: Codec) -> Result { + match codec { + Codec::LamportMerkleSha3256Sig => Ok(Codec::LamportMerkleSha3256SigShare), + Codec::LamportMerkleSha3384Sig => Ok(Codec::LamportMerkleSha3384SigShare), + Codec::LamportMerkleSha3512Sig => Ok(Codec::LamportMerkleSha3512SigShare), + Codec::LamportMerkleSha2256Sig => Ok(Codec::LamportMerkleSha2256SigShare), + Codec::LamportMerkleSha2384Sig => Ok(Codec::LamportMerkleSha2384SigShare), + Codec::LamportMerkleSha2512Sig => Ok(Codec::LamportMerkleSha2512SigShare), + Codec::LamportMerkleBlake2B512Sig => Ok(Codec::LamportMerkleBlake2B512SigShare), + Codec::LamportMerkleBlake2S256Sig => Ok(Codec::LamportMerkleBlake2S256SigShare), + Codec::LamportMerkleBlake3256Sig => Ok(Codec::LamportMerkleBlake3256SigShare), + Codec::LamportMerkleShake128Sig => Ok(Codec::LamportMerkleShake128SigShare), + Codec::LamportMerkleShake256Sig => Ok(Codec::LamportMerkleShake256SigShare), + Codec::LamportMerkleSha3256SigShare => Ok(Codec::LamportMerkleSha3256Sig), + Codec::LamportMerkleSha3384SigShare => Ok(Codec::LamportMerkleSha3384Sig), + Codec::LamportMerkleSha3512SigShare => Ok(Codec::LamportMerkleSha3512Sig), + Codec::LamportMerkleSha2256SigShare => Ok(Codec::LamportMerkleSha2256Sig), + Codec::LamportMerkleSha2384SigShare => Ok(Codec::LamportMerkleSha2384Sig), + Codec::LamportMerkleSha2512SigShare => Ok(Codec::LamportMerkleSha2512Sig), + Codec::LamportMerkleBlake2B512SigShare => Ok(Codec::LamportMerkleBlake2B512Sig), + Codec::LamportMerkleBlake2S256SigShare => Ok(Codec::LamportMerkleBlake2S256Sig), + Codec::LamportMerkleBlake3256SigShare => Ok(Codec::LamportMerkleBlake3256Sig), + Codec::LamportMerkleShake128SigShare => Ok(Codec::LamportMerkleShake128Sig), + Codec::LamportMerkleShake256SigShare => Ok(Codec::LamportMerkleShake256Sig), + _ => Err(Error::UnsupportedAlgorithm(codec.to_string())), + } +} + +/// The declared depth of the accumulator: the `depth` attribute if present, +/// else the wire depth of the first accumulated share blob, else `None`. +fn accumulator_depth(ms: &Multisig, blobs: &[Vec]) -> Result, Error> { + if let Some(d) = ms.attributes.get(&AttrId::Depth) { + if d.len() != 1 { + return Err(AttributesError::InvalidAttributeValue(d.len() as u8).into()); + } + return Ok(Some(d[0])); + } + if let Some(first) = blobs.first() { + return Ok(Some(*first.first().ok_or(SharesError::MissingShareData)?)); + } + Ok(None) +} + +/// Validate a share blob's embedded depth (offset 0) against the accumulator's. +fn check_share_depth(expected: Option, blob: &[u8]) -> Result { + let depth = *blob.first().ok_or(SharesError::MissingShareData)?; + if let Some(expected) = expected + && expected != depth + { + return Err(AttributesError::DepthMismatch { + expected, + found: depth, + } + .into()); + } + Ok(depth) +} + +/// Generic combine: parse share blobs, call `MtSignature::::combine`, +/// return the combined signature bytes. +fn combine_signatures( + signature_share_bytes: &[Vec], +) -> Result<(Vec, u8), String> { + let shares = signature_share_bytes + .iter() + .map(|b| MtSignatureShare::::from_bytes(b)) + .collect::, _>>() + .map_err(|e| e.to_string())?; + let combined = MtSignature::::combine(&shares).map_err(|e| e.to_string())?; + Ok((combined.to_bytes(), combined.depth())) +} + +/// Combine merkle-Lamport signature-share blobs into a full signature under +/// the given accumulator codec. +fn combine_blobs(codec: Codec, blobs: &[Vec]) -> Result<(Vec, u8), Error> { + match codec { + Codec::LamportMerkleSha3256Sig => combine_signatures::(blobs), + Codec::LamportMerkleSha3384Sig => combine_signatures::(blobs), + Codec::LamportMerkleSha3512Sig => combine_signatures::(blobs), + Codec::LamportMerkleSha2256Sig => combine_signatures::(blobs), + Codec::LamportMerkleSha2384Sig => combine_signatures::(blobs), + Codec::LamportMerkleSha2512Sig => combine_signatures::(blobs), + Codec::LamportMerkleBlake2B512Sig => combine_signatures::(blobs), + Codec::LamportMerkleBlake2S256Sig => combine_signatures::(blobs), + Codec::LamportMerkleBlake3256Sig => combine_signatures::(blobs), + Codec::LamportMerkleShake128Sig => combine_signatures::(blobs), + Codec::LamportMerkleShake256Sig => combine_signatures::(blobs), + _ => return Err(Error::UnsupportedAlgorithm(codec.to_string())), + } + .map_err(|e| SharesError::ShareCombineFailed(e).into()) +} + +/// Encode a list of signature-share blobs: `Varuint(count) || Varbytes(blob)*`. +fn encode_shares(blobs: &[Vec]) -> Vec { + let mut out: Vec = Varuint(blobs.len()).into(); + for blob in blobs { + out.append(&mut Varbytes::new(blob.clone()).into()); + } + out +} + +/// Decode the share-blob list stored in `ThresholdData`. +fn decode_shares(data: &[u8]) -> Result>, Error> { + let (count, mut ptr) = + Varuint::::try_decode_from(data).map_err(|_| SharesError::MissingShareData)?; + let mut out = Vec::with_capacity(*count); + for _ in 0..*count { + let (blob, rest) = + Varbytes::try_decode_from(ptr).map_err(|_| SharesError::MissingShareData)?; + out.push(blob.to_inner()); + ptr = rest; + } + Ok(out) +} + +/// Read the accumulated share blobs from this multisig's `ThresholdData` +/// (empty if none have been added yet). +fn accumulated(ms: &Multisig) -> Result>, Error> { + match ms.attributes.get(&AttrId::ThresholdData) { + Some(data) => decode_shares(data), + None => Ok(Vec::new()), + } +} + +impl<'a> AttrView for View<'a> { + fn payload_encoding(&self) -> Result { + let v = self + .ms + .attributes + .get(&AttrId::PayloadEncoding) + .ok_or(AttributesError::MissingPayloadEncoding)?; + Ok(Codec::try_from(v.as_slice())?) + } + fn scheme(&self) -> Result { + Ok(0) + } +} + +impl<'a> DataView for View<'a> { + fn sig_bytes(&self) -> Result, Error> { + let sig = self + .ms + .attributes + .get(&AttrId::SigData) + .ok_or(AttributesError::MissingSignature)?; + Ok(sig.clone()) + } +} + +impl<'a> ConvView for View<'a> { + fn to_ssh_signature(&self) -> Result { + Err(Error::UnsupportedAlgorithm( + "Merkle-Lamport not supported in SSH signature format".into(), + )) + } +} + +impl<'a> ThresholdAttrView for View<'a> { + fn threshold(&self) -> Result { + let v = self + .ms + .attributes + .get(&AttrId::Threshold) + .ok_or(AttributesError::MissingThreshold)?; + Ok(*Varuint::::try_from(v.as_slice())?) + } + fn limit(&self) -> Result { + let v = self + .ms + .attributes + .get(&AttrId::Limit) + .ok_or(AttributesError::MissingLimit)?; + Ok(*Varuint::::try_from(v.as_slice())?) + } + fn identifier(&self) -> Result<&[u8], Error> { + Ok(self + .ms + .attributes + .get(&AttrId::ShareIdentifier) + .ok_or(AttributesError::MissingIdentifier)? + .as_slice()) + } + fn threshold_data(&self) -> Result<&[u8], Error> { + Ok(self + .ms + .attributes + .get(&AttrId::ThresholdData) + .ok_or(AttributesError::MissingThresholdData)? + .as_slice()) + } +} + +impl<'a> ThresholdView for View<'a> { + /// Rebuild the individual signature-share multisigs from the accumulator. + fn shares(&self) -> Result, Error> { + let share_codec = share_codec(self.ms.codec)?; + accumulated(self.ms)? + .into_iter() + .map(|blob| { + Builder::new(share_codec) + .with_message_bytes(&self.ms.message.as_slice()) + .with_signature_bytes(&blob) + .with_depth(blob.first().copied().unwrap_or_default()) + .try_build() + }) + .collect() + } + + /// Merkle-Lamport shares do not use encrypted threshold params; delegate + /// to [`shares`](Self::shares) and ignore the disclosure mode. + fn shares_with_disclosure( + &self, + _mode: ThresholdDisclosure, + _meta_key: Option<&[u8]>, + ) -> Result, Error> { + self.shares() + } + + /// Add a merkle-Lamport signature share to the accumulator. + fn add_share(&self, share: &Multisig) -> Result { + let share_codec = share_codec(self.ms.codec)?; + if share.codec() != share_codec { + return Err(SharesError::ShareTypeMismatch.into()); + } + let blob = share.data_view()?.sig_bytes()?; + let blobs = accumulated(self.ms)?; + let expected = accumulator_depth(self.ms, &blobs)?; + let depth = check_share_depth(expected, &blob)?; + let mut blobs = blobs; + blobs.push(blob); + Builder::new(self.ms.codec) + .with_message_bytes(&self.ms.message.as_slice()) + .with_threshold_data(&encode_shares(&blobs)) + .with_depth(depth) + .try_build() + } + + /// Merkle-Lamport shares do not use encrypted threshold params; delegate + /// to [`add_share`](Self::add_share) and ignore the meta_key. + fn add_share_with_meta( + &self, + share: &Multisig, + _meta_key: Option<&[u8]>, + ) -> Result { + self.add_share(share) + } + + /// Combine the accumulated shares into a full merkle-Lamport signature. + fn combine(&self) -> Result { + let blobs = accumulated(self.ms)?; + if blobs.is_empty() { + return Err(SharesError::NotEnoughShares.into()); + } + let (sig, depth) = combine_blobs(self.ms.codec, &blobs)?; + Builder::new(self.ms.codec) + .with_message_bytes(&self.ms.message.as_slice()) + .with_signature_bytes(&sig) + .with_depth(depth) + .try_build() + } + + /// Merkle-Lamport shares do not use encrypted threshold params; delegate + /// to [`combine`](Self::combine) and ignore the meta_key. + fn combine_with_meta(&self, _meta_key: Option<&[u8]>) -> Result { + self.combine() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_share_codec_roundtrip() { + assert_eq!( + share_codec(Codec::LamportMerkleSha3256Sig).unwrap(), + Codec::LamportMerkleSha3256SigShare + ); + assert_eq!( + share_codec(Codec::LamportMerkleSha3256SigShare).unwrap(), + Codec::LamportMerkleSha3256Sig + ); + assert!(share_codec(Codec::EddsaMsig).is_err()); + } + + #[test] + fn test_encode_decode_shares() { + let blobs = vec![vec![1u8, 2], vec![3], vec![]]; + let enc = encode_shares(&blobs); + assert_eq!(decode_shares(&enc).unwrap(), blobs); + assert!(decode_shares(&[9, 9]).is_err()); + } +} From 0f1a9d3d49a96d24f9811396a796e1a300b9c4d5 Mon Sep 17 00:00:00 2001 From: Dave Grantham Date: Tue, 1 Sep 2026 17:30:39 -0600 Subject: [PATCH 2/3] finish merkle signature view Signed-off-by: Dave Grantham --- Cargo.toml | 1 + src/lib.rs | 4 ++ src/views/lamport_merkle.rs | 111 ++++++++++++++++++++++++++++++++++++ tests/edge_case_tests.rs | 45 +++++++++++++++ 4 files changed, 161 insertions(+) diff --git a/Cargo.toml b/Cargo.toml index 1505856..1a58192 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -60,6 +60,7 @@ ssh-key = { version = "0.7.0-rc.11", default-features = false, features = ["allo criterion = { version = "0.8", features = ["html_reports"] } hex = "0.4" proptest = "1.11" +rand = "0.10" serde_json = "1.0" serde_test = "1.0" diff --git a/src/lib.rs b/src/lib.rs index 075f286..dae5bf7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -154,6 +154,10 @@ pub use views::{ ThresholdView, Views, decrypt_threshold_meta, encrypt_threshold_meta, generate_meta_key, }; +/// Merkle-tree Lamport signature view +#[cfg(feature = "lamport")] +pub use views::lamport_merkle; + /// Serde serialization #[cfg(feature = "serde")] pub mod serde; diff --git a/src/views/lamport_merkle.rs b/src/views/lamport_merkle.rs index fa48333..bf4a6ee 100644 --- a/src/views/lamport_merkle.rs +++ b/src/views/lamport_merkle.rs @@ -313,6 +313,9 @@ impl<'a> ThresholdView for View<'a> { #[cfg(test)] mod tests { use super::*; + use crate::views::Views as _; + use lamport_signature_plus::MtVerifyingKey; + type MtSha2_256Digest = lamport_signature_plus::LamportFixedDigest; #[test] fn test_share_codec_roundtrip() { @@ -334,4 +337,112 @@ mod tests { assert_eq!(decode_shares(&enc).unwrap(), blobs); assert!(decode_shares(&[9, 9]).is_err()); } + + /// Build a real 2-of-3 threshold tree with lamport_signature_plus, sign + /// with two shares, and run the multi-sig accumulate/combine flow. + #[test] + fn test_threshold_flow_2_of_3() { + let (shares, pk_bytes) = { + let (sk, pk) = + lamport_signature_plus::generate_mt_keys::(1, rand::rng()) + .unwrap(); + let key_shares = sk.split(2, 3, rand::rng()).unwrap(); + (key_shares, pk.to_bytes()) + }; + assert_eq!(shares.len(), 3); + + let msg = b"merkle threshold flow"; + + // each participant signs at the next leaf (leaf 0 for all — same leaf + // is required for combine) + let mut share_ms: Vec = Vec::new(); + for mut share in shares.into_iter().take(2) { + let blob = share.sign(msg).unwrap().to_bytes(); + share_ms.push( + Builder::new(share_codec(Codec::LamportMerkleSha2256Sig).unwrap()) + .with_signature_bytes(&blob) + .with_depth(1) + .try_build() + .unwrap(), + ); + } + + // accumulate on the multisig + let mut acc = Builder::new(Codec::LamportMerkleSha2256Sig) + .with_message_bytes(&msg) + .try_build() + .unwrap(); + assert!(acc.depth().is_none()); + for share_ms in &share_ms { + let next = acc.threshold_view().unwrap().add_share(share_ms).unwrap(); + acc = next; + } + // depth propagated from the share blobs + assert_eq!(acc.depth(), Some(1)); + + // shares() roundtrip + let recovered = acc.threshold_view().unwrap().shares().unwrap(); + assert_eq!(recovered.len(), 2); + assert_eq!(recovered[0].depth(), Some(1)); + + // combine into a full signature + let combined = acc.threshold_view().unwrap().combine().unwrap(); + assert_eq!(combined.codec(), Codec::LamportMerkleSha2256Sig); + assert_eq!(combined.depth(), Some(1)); + assert_eq!(combined.message, msg.to_vec()); + + // verify under the tree root + let pk = MtVerifyingKey::::from_bytes(&pk_bytes).unwrap(); + let sig_bytes = combined.data_view().unwrap().sig_bytes().unwrap(); + let sig = MtSignature::::from_bytes(&sig_bytes).unwrap(); + pk.verify(&sig, msg).unwrap(); + } + + #[test] + fn test_depth_mismatch_rejected() { + let (sk, _pk) = + lamport_signature_plus::generate_mt_keys::(1, rand::rng()) + .unwrap(); + let mut shares = sk.split(2, 3, rand::rng()).unwrap(); + let mut share = shares.remove(0); + let blob = share.sign(b"m").unwrap().to_bytes(); + + // share says depth 1; build it as a share multisig claiming depth 2 + let share_ms = Builder::new(Codec::LamportMerkleSha2256SigShare) + .with_signature_bytes(&blob) + .with_depth(2) + .try_build() + .unwrap(); + + let acc = Builder::new(Codec::LamportMerkleSha2256Sig) + .with_message_bytes(&b"m".to_vec()) + .with_depth(1) + .try_build() + .unwrap(); + // accumulator says 1, share blob says 1, but the share's attr says 2 — + // add_share trusts the blob, so mismatch must come from blob vs + // accumulator. Tamper the accumulator depth instead. + let acc2 = Builder::new(Codec::LamportMerkleSha2256Sig) + .with_message_bytes(&b"m".to_vec()) + .with_depth(2) + .try_build() + .unwrap(); + assert!(acc2.threshold_view().unwrap().add_share(&share_ms).is_err()); + // sanity: the honest accumulator accepts the honest share + let _ = acc; + let ok = Builder::new(Codec::LamportMerkleSha2256Sig) + .with_message_bytes(&b"m".to_vec()) + .try_build() + .unwrap(); + assert!(ok.threshold_view().unwrap().add_share(&share_ms).is_ok()); + } + + #[test] + fn test_combine_empty_fails() { + let acc = Builder::new(Codec::LamportMerkleSha2256Sig) + .with_message_bytes(&b"m".to_vec()) + .try_build() + .unwrap(); + assert!(acc.threshold_view().unwrap().combine().is_err()); + } } diff --git a/tests/edge_case_tests.rs b/tests/edge_case_tests.rs index e61ded1..2de5d76 100644 --- a/tests/edge_case_tests.rs +++ b/tests/edge_case_tests.rs @@ -62,6 +62,51 @@ fn test_binary_roundtrip() { } } +/// Test wire roundtrip and depth attribute for every merkle-Lamport codec +#[cfg(feature = "lamport")] +#[test] +fn test_merkle_lamport_codecs_roundtrip() { + let merkle = [ + Codec::LamportMerkleSha3512Sig, + Codec::LamportMerkleSha3512SigShare, + Codec::LamportMerkleSha3384Sig, + Codec::LamportMerkleSha3384SigShare, + Codec::LamportMerkleSha3256Sig, + Codec::LamportMerkleSha3256SigShare, + Codec::LamportMerkleSha2512Sig, + Codec::LamportMerkleSha2512SigShare, + Codec::LamportMerkleSha2384Sig, + Codec::LamportMerkleSha2384SigShare, + Codec::LamportMerkleSha2256Sig, + Codec::LamportMerkleSha2256SigShare, + Codec::LamportMerkleBlake2B512Sig, + Codec::LamportMerkleBlake2B512SigShare, + Codec::LamportMerkleBlake2S256Sig, + Codec::LamportMerkleBlake2S256SigShare, + Codec::LamportMerkleBlake3256Sig, + Codec::LamportMerkleBlake3256SigShare, + Codec::LamportMerkleShake128Sig, + Codec::LamportMerkleShake128SigShare, + Codec::LamportMerkleShake256Sig, + Codec::LamportMerkleShake256SigShare, + ]; + assert_eq!(merkle.len(), 22); + + let data = b"merkle roundtrip"; + for &codec in &merkle { + let ms1 = Builder::new(codec) + .with_signature_bytes(data) + .with_depth(2) + .try_build() + .unwrap(); + assert_eq!(ms1.depth(), Some(2)); + let bytes: Vec = ms1.clone().into(); + let ms2 = Multisig::try_from(bytes.as_ref()).unwrap(); + assert_eq!(ms1, ms2); + assert_eq!(ms2.depth(), Some(2)); + } +} + /// Test Clone trait #[test] fn test_clone() { From 5ce555ab14f4090e9623665d173b147840538a53 Mon Sep 17 00:00:00 2001 From: Dave Grantham Date: Wed, 2 Sep 2026 11:16:51 -0600 Subject: [PATCH 3/3] update min version check Signed-off-by: Dave Grantham --- .github/workflows/rust.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 6c47bc1..ac129ad 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -34,14 +34,14 @@ jobs: run: cargo test --verbose --all-features msrv: - name: Verify MSRV (1.87) + name: Verify MSRV (1.96) runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - name: Install Rust Toolchain (1.87) - uses: dtolnay/rust-toolchain@1.87.0 + - name: Install Rust Toolchain (1.96) + uses: dtolnay/rust-toolchain@1.96.0 - name: Check run: cargo check --all-features