From 63efce13fc25ad73ec9a229b4f73510d65d126af Mon Sep 17 00:00:00 2001 From: Dave Grantham Date: Tue, 14 Jul 2026 14:45:23 -0600 Subject: [PATCH 1/2] add threshold hardening Signed-off-by: Dave Grantham --- Cargo.toml | 1 + README.md | 142 +++++++ src/attrid.rs | 18 + src/error.rs | 17 + src/lib.rs | 9 +- src/mk.rs | 80 +++- src/views.rs | 28 ++ src/views/aead.rs | 2 + src/views/bls12381.rs | 140 +++++++ src/views/threshold_meta.rs | 765 ++++++++++++++++++++++++++++++++++++ 10 files changed, 1195 insertions(+), 7 deletions(-) create mode 100644 src/views/threshold_meta.rs diff --git a/Cargo.toml b/Cargo.toml index 42b831a..6274ccc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,7 @@ wasm = ["getrandom/wasm_js"] aes-gcm = "0.10" bcrypt-pbkdf = "0.10" blake3 = { version = "1.5.1", features = ["traits-preview", "zeroize"] } +ciborium = "0.2" curve25519-dalek = { version = "5.0.0", features = ["group"] } # blsful configured per-target below (blst for native, rust for wasm) frodo-kem-rs = { version = "0.7", default-features = false, features = ["frodo640aes", "frodo976aes", "frodo1344aes", "frodo640shake", "frodo976shake", "frodo1344shake"] } diff --git a/README.md b/README.md index 2a68c08..cc97480 100644 --- a/README.md +++ b/README.md @@ -274,6 +274,148 @@ as free functions (`split`, `combine`, `verify_share`) producing `KeySplitShare` - **Dual mode** — Ed25519 and X25519: a gf256 share of the 32-byte seed (exact restore) plus a Feldman scalar share (threshold-signing-ready). +## Threshold Confidentiality + +By default, threshold `t` and share count `n` are stored as **plaintext** attributes on every +key share — any observer of a share learns the threshold parameters. This crate supports three +configurable disclosure modes that control the confidentiality of `t` and `n`, applicable to +BLS12-381 Shamir shares and the generic `keysplit` module. + +### Disclosure Modes + +| Mode | `t` (threshold) | `n` (limit) | Who sees `t` | Who sees `n` | +|---|---|---|---|---| +| `Full` (default, 0) | plaintext attribute | plaintext attribute | everyone | everyone | +| `Partial` (1) | encrypted (AEAD) | plaintext attribute | key-holder only | everyone (auditable) | +| `FullConfidentialial` (2) | encrypted (AEAD) | encrypted (AEAD) | key-holder only | key-holder only | + +The encrypted values are sealed with **ChaCha20-Poly1305 AEAD** and stored as a CBOR-encoded +`ThresholdMetadata` blob in `AttrId::EncryptedThresholdMeta`. The cipher parameters (codec + +nonce) are recorded in `AttrId::ThresholdMetaCipher` so the blob is self-describing for +decryption. A separate **meta key** (a 32-byte symmetric `Multikey` with +`Codec::Chacha20Poly1305`) is required to encrypt/decrypt the metadata. + +### When to Use Each Mode + +- **`Full`** — Use when `t` and `n` are not sensitive. This is the default and is + backward-compatible with all existing shares. Appropriate for open governance systems where + the threshold structure is public knowledge. + +- **`Partial`** — Use when the total number of participants `n` should be auditable (e.g. for + governance transparency) but the threshold `t` should be hidden from share holders and + observers. Hiding `t` means an adversary who compromises some shares does not know how many + more they need to reconstruct the key. The `meta_key` is required to read `t` but `n` is + freely readable. + +- **`FullConfidentialial`** — Use when both `t` and `n` must be kept secret. An observer who + sees a share cannot determine the group size or how many shares are needed. This is the + strongest confidentiality mode. The `meta_key` is required to read both `t` and `n`. + +### Trade-offs + +| Consideration | Full | Partial | FullConfidentialial | +|---|---|---|---| +| Backward compatible | yes | yes (attribute defaults to Full if absent) | yes | +| Observer learns `t` | yes | no | no | +| Observer learns `n` | yes | yes | no | +| Requires `meta_key` | no | for reading `t` | for reading `t` and `n` | +| Auditable `n` | yes | yes | no | +| Risk if `meta_key` lost | n/a | `t` irrecoverable | `t` and `n` irrecoverable | +| Performance overhead | none | negligible (AEAD on ~10 bytes) | negligible | + +**Key management risk:** Losing the `meta_key` makes `t` (Partial) or both `t`/`n` +(FullConfidentialial) irrecoverable, preventing key combination. The `meta_key` should be +stored/backed up using the existing at-rest encryption mechanisms. You can always convert back +to `Full` mode (with the `meta_key`) before losing it. + +**DKG note:** DKG threshold values (`t`/`n`) are inherently known to all participants because +they are agreed during the DKG ceremony. The confidentiality modes do not apply to DKG shares — +a `to_disclosure()` call on a DKG share returns an error. Future work could add "hidden +threshold DKG" where participants don't know `t`, but that requires protocol-level changes +(FROST-style) not just encoding changes. + +### Creating Shares with a Disclosure Mode + +There are three ways to produce shares in a given disclosure mode: + +**1. Direct creation via `split_with_disclosure()`:** + +```rust +use multi_key::{Builder, Views, ThresholdDisclosure}; + +let meta_key = multi_key::generate_meta_key(); +let meta_mk = Builder::new(Codec::Chacha20Poly1305) + .with_key_bytes(&meta_key.as_slice()) + .try_build()?; + +// BLS Shamir split with FullConfidentialial disclosure +let shares = mk.threshold_view()?.split_with_disclosure(3, 5, + ThresholdDisclosure::FullConfidentialial, Some(&meta_mk))?; +``` + +**2. Builder construction:** + +```rust +let share = Builder::new(Codec::Bls12381G2PrivShare) + .with_disclosure(ThresholdDisclosure::Partial, Some(&meta_mk), 3, 5) + .with_identifier(&identifier) + .with_key_bytes(&key_bytes) + .try_build()?; +``` + +**3. Convert an existing share:** + +```rust +let encrypted = share.disclosure_view()? + .to_disclosure(ThresholdDisclosure::FullConfidentialial, Some(&meta_mk), None)?; +``` + +### Reading Threshold Parameters from Encrypted Shares + +Use `read_threshold_params()` with the `meta_key` to decrypt `t` and `n`: + +```rust +let (t, n) = encrypted.disclosure_view()? + .read_threshold_params(Some(&meta_mk))?; +``` + +### Combining Encrypted Shares + +```rust +let combined = mk.threshold_view()? + .combine_with_meta(Some(&meta_mk))?; +``` + +For the generic `keysplit` module, use `split_with_disclosure()` and `combine_with_meta()`: + +```rust +use multi_key::keysplit; + +let shares = keysplit::split_with_disclosure( + &mk, 3, 5, ThresholdDisclosure::Partial, Some(&meta_mk), rand::rng())?; +let combined = keysplit::combine_with_meta(&shares, Some(&meta_mk))?; +``` + +### Converting Between Modes + +The `to_disclosure()` method converts between any pair of modes. It reads the current `t`/`n` +(decrypting if needed with `current_meta_key`), then re-stamps the attributes in the target mode +(encrypting if needed with `meta_key`): + +```rust +// Full → Partial +let partial = full.disclosure_view()? + .to_disclosure(ThresholdDisclosure::Partial, Some(&meta_mk), None)?; + +// Partial → FullConfidentialial +let confidential = partial.disclosure_view()? + .to_disclosure(ThresholdDisclosure::FullConfidentialial, Some(&meta_mk), Some(&meta_mk))?; + +// FullConfidentialial → Full +let full_again = confidential.disclosure_view()? + .to_disclosure(ThresholdDisclosure::Full, None, Some(&meta_mk))?; +``` + ## Encryption ### At-Rest Multi-Key Encryption diff --git a/src/attrid.rs b/src/attrid.rs index 6731ad8..15a23f6 100644 --- a/src/attrid.rs +++ b/src/attrid.rs @@ -69,6 +69,15 @@ pub enum AttrId { /// ‖ limit), produced by the controller's signing key. Lets a verifier reject /// a tampered marker before trusting it (defeats TSIG-1 marker forgery). ThresholdMarkerSig, + /// Threshold disclosure mode (varuint u8): 0=Full, 1=Partial, 2=FullConfidentialial. + /// Absent means Full (legacy backward-compatible). + ThresholdDisclosure, + /// AEAD-encrypted `ThresholdMetadata` CBOR blob. Present in Partial and + /// FullConfidentialial modes. + EncryptedThresholdMeta, + /// CBOR-encoded `ThresholdMetaCipher` (cipher codec + nonce) for decrypting + /// `EncryptedThresholdMeta`. + ThresholdMetaCipher, } impl AttrId { @@ -104,6 +113,9 @@ impl AttrId { AttrId::ThresholdGroupPublicKey => "threshold-group-public-key", AttrId::ThresholdParticipants => "threshold-participants", AttrId::ThresholdMarkerSig => "threshold-marker-sig", + AttrId::ThresholdDisclosure => "threshold-disclosure", + AttrId::EncryptedThresholdMeta => "encrypted-threshold-meta", + AttrId::ThresholdMetaCipher => "threshold-meta-cipher", } } } @@ -143,6 +155,9 @@ impl TryFrom for AttrId { 21 => Ok(AttrId::ThresholdGroupPublicKey), 22 => Ok(AttrId::ThresholdParticipants), 23 => Ok(AttrId::ThresholdMarkerSig), + 24 => Ok(AttrId::ThresholdDisclosure), + 25 => Ok(AttrId::EncryptedThresholdMeta), + 26 => Ok(AttrId::ThresholdMetaCipher), _ => Err(AttributesError::InvalidAttributeValue(c).into()), } } @@ -202,6 +217,9 @@ impl TryFrom<&str> for AttrId { "threshold-group-public-key" => Ok(AttrId::ThresholdGroupPublicKey), "threshold-participants" => Ok(AttrId::ThresholdParticipants), "threshold-marker-sig" => Ok(AttrId::ThresholdMarkerSig), + "threshold-disclosure" => Ok(AttrId::ThresholdDisclosure), + "encrypted-threshold-meta" => Ok(AttrId::EncryptedThresholdMeta), + "threshold-meta-cipher" => Ok(AttrId::ThresholdMetaCipher), _ => Err(AttributesError::InvalidAttributeName(s.to_string()).into()), } } diff --git a/src/error.rs b/src/error.rs index 05c1784..0ff97df 100644 --- a/src/error.rs +++ b/src/error.rs @@ -327,6 +327,23 @@ pub enum ThresholdError { /// Share combine failed #[error("Combining secret key shares failed: {0}")] ShareCombineFailed(String), + /// Threshold metadata encryption/decryption error + #[error("Threshold metadata error: {0}")] + MetaEncryption(String), + /// Missing threshold metadata key for decrypting t/n + #[error("Missing threshold metadata key")] + MissingMetaKey, + /// Threshold disclosure mode mismatch between shares + #[error("Threshold disclosure mode mismatch: expected {expected}, found {found}")] + DisclosureMismatch { + /// Expected disclosure mode code + expected: u8, + /// Found disclosure mode code + found: u8, + }, + /// Duplicate share identifier in threshold data + #[error("Duplicate share identifier")] + DuplicateShare, } /// Verify errors created by this library diff --git a/src/lib.rs b/src/lib.rs index d25222d..545d5d7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -93,10 +93,15 @@ pub use views::threshold_marker::{ self, group_public_key, participants, set_group_public_key, set_participants, threshold_kind, threshold_params, MarkerView, ThresholdParticipant, ThresholdScheme, }; +pub use views::threshold_meta::{ + self, decrypt_threshold_meta, encrypt_threshold_meta, generate_meta_key, DisclosureView, + ThresholdDisclosure, ThresholdMetaCipher, ThresholdMetadata, read_threshold_params, + stamp_disclosure_attrs, +}; pub use views::{ AttrView, CipherAttrView, CipherView, ConvView, DataView, FingerprintView, KdfAttrView, - KdfView, OpenView, SealView, SignView, ThresholdAttrView, ThresholdKeyView, ThresholdView, - VerifyView, Views, + KdfView, OpenView, SealView, SignView, ThresholdAttrView, ThresholdDisclosureView, + ThresholdKeyView, ThresholdView, VerifyView, Views, }; /// Key splitting / recombination (verifiable threshold shares) diff --git a/src/mk.rs b/src/mk.rs index 70fad31..e2b15b1 100644 --- a/src/mk.rs +++ b/src/mk.rs @@ -5,12 +5,12 @@ use crate::{ 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, x25519, x25519_frodokem640, x25519_mceliece348864, x25519_mlkem768, - x25519_sntrup761, + sntrup, threshold_meta, x25519, x25519_frodokem640, x25519_mceliece348864, + x25519_mlkem768, x25519_sntrup761, }, AttrId, AttrView, CipherAttrView, CipherView, ConvView, DataView, Error, FingerprintView, - KdfAttrView, KdfView, OpenView, SealView, SignView, ThresholdAttrView, ThresholdKeyView, - ThresholdView, VerifyView, Views, + KdfAttrView, KdfView, OpenView, SealView, SignView, ThresholdAttrView, ThresholdDisclosureView, + ThresholdKeyView, ThresholdView, VerifyView, Views, }; use ::fn_dsa::{ @@ -21,7 +21,7 @@ use elliptic_curve::sec1::ToSec1Point; use elliptic_curve::Generate; use multi_base::Base; use multi_codec::Codec; -use multi_trait::{Null, TryDecodeFrom}; +use multi_trait::{EncodeInto, Null, TryDecodeFrom}; use multi_util::{BaseEncoded, CodecInfo, EncodingInfo, Varbytes, Varuint}; use rand_core::CryptoRng; use ssh_key::{ @@ -1228,6 +1228,11 @@ impl Views for Multikey { _ => Err(ConversionsError::UnsupportedCodec(self.codec).into()), } } + + /// Provide an interface for threshold disclosure mode operations + fn disclosure_view<'a>(&'a self) -> Result, Error> { + Ok(Box::new(threshold_meta::DisclosureView::new(self))) + } } /// Multikey builder constructs private keys only. If you need a public key you @@ -2545,6 +2550,71 @@ impl Builder { self.with_attribute(AttrId::ThresholdData, &tdata.as_ref().to_vec()) } + /// Set the disclosure mode for a threshold share being built. + /// + /// In [`ThresholdDisclosure::Full`] mode, t and n are stored as plaintext + /// attributes (same as `with_threshold()`/`with_limit()`). + /// In [`ThresholdDisclosure::Partial`] mode, t is encrypted and n is plaintext. + /// In [`ThresholdDisclosure::FullConfidentialial`] mode, both t and n are encrypted. + /// + /// `meta_key` is required for Partial/FullConfidentialial modes. + pub fn with_disclosure( + self, + mode: threshold_meta::ThresholdDisclosure, + meta_key: Option<&Multikey>, + threshold: usize, + limit: usize, + ) -> Self { + let mut attributes = self.attributes.unwrap_or_default(); + let _ = threshold_meta::stamp_disclosure_attrs( + &mut attributes, + mode, + threshold, + limit, + meta_key, + ); + Self { + attributes: Some(attributes), + ..self + } + } + + /// Stamp pre-encrypted threshold metadata directly (advanced use). + /// + /// This bypasses the encryption step — the caller has already encrypted the + /// metadata and provides the ciphertext + cipher info directly. + pub fn with_encrypted_threshold_meta( + self, + mode: threshold_meta::ThresholdDisclosure, + encrypted_meta: Vec, + cipher_info: threshold_meta::ThresholdMetaCipher, + plaintext_limit: Option, + ) -> Self { + let mut attributes = self.attributes.unwrap_or_default(); + attributes.remove(&AttrId::Threshold); + attributes.remove(&AttrId::Limit); + attributes.remove(&AttrId::EncryptedThresholdMeta); + attributes.remove(&AttrId::ThresholdMetaCipher); + attributes.remove(&AttrId::ThresholdDisclosure); + + if let Some(n) = plaintext_limit { + let n_bytes: Vec = Varuint(n).into(); + attributes.insert(AttrId::Limit, n_bytes.into()); + } + attributes.insert(AttrId::EncryptedThresholdMeta, encrypted_meta.into()); + if let Ok(bytes) = cipher_info.to_cbor_bytes() { + attributes.insert(AttrId::ThresholdMetaCipher, bytes.into()); + } + attributes.insert( + AttrId::ThresholdDisclosure, + Zeroizing::new(threshold_meta::ThresholdDisclosure::encode_into(&mode)), + ); + Self { + attributes: Some(attributes), + ..self + } + } + /// add a key share pub fn add_key_share(mut self, share: &Multikey) -> Self { let mut shares = self.shares.unwrap_or_default(); diff --git a/src/views.rs b/src/views.rs index e1568eb..f808690 100644 --- a/src/views.rs +++ b/src/views.rs @@ -33,6 +33,13 @@ pub(crate) mod slh_dsa; pub(crate) mod sntrup; /// Split (Shamir) threshold marker attributes and scheme classifier. pub mod threshold_marker; +/// Threshold disclosure modes and encrypted metadata helpers. +pub mod threshold_meta; +pub use threshold_meta::{ + decrypt_threshold_meta, encrypt_threshold_meta, generate_meta_key, DisclosureView, + ThresholdDisclosure, ThresholdDisclosureView, ThresholdMetaCipher, ThresholdMetadata, + read_threshold_params, stamp_disclosure_attrs, +}; pub(crate) mod x25519; pub(crate) mod x25519_frodokem640; pub(crate) mod x25519_mceliece348864; @@ -196,10 +203,29 @@ pub trait SignView { pub trait ThresholdView { /// try to split the key into key shares given the threshold and limit fn split(&self, threshold: usize, limit: usize) -> Result, Error>; + /// Split with a specific disclosure mode for t/n. + fn split_with_disclosure( + &self, + threshold: usize, + limit: usize, + mode: ThresholdDisclosure, + meta_key: Option<&Multikey>, + ) -> Result, Error>; /// add a new share and return the Multikey with the share added fn add_share(&self, share: &Multikey) -> Result; + /// Add a share with a meta_key for decrypting threshold params. + fn add_share_with_meta( + &self, + share: &Multikey, + meta_key: Option<&Multikey>, + ) -> Result; /// reconstruct the key from teh shares fn combine(&self) -> Result; + /// Combine with a meta_key for decrypting threshold params. + fn combine_with_meta( + &self, + meta_key: Option<&Multikey>, + ) -> Result; } /// trait exposing higher-level DKG-shaped read-only metadata on a Multikey @@ -259,4 +285,6 @@ pub trait Views { fn threshold_view<'a>(&'a self) -> Result, Error>; /// Provide an interface to verify a Multisig and optional message fn verify_view<'a>(&'a self) -> Result, Error>; + /// Provide an interface for threshold disclosure mode operations + fn disclosure_view<'a>(&'a self) -> Result, Error>; } diff --git a/src/views/aead.rs b/src/views/aead.rs index b6a9419..13e09d9 100644 --- a/src/views/aead.rs +++ b/src/views/aead.rs @@ -49,6 +49,7 @@ pub(crate) fn nonce_size(codec: Codec) -> Result { /// Encrypt plaintext with the given AEAD codec and key. /// Returns `(nonce, ciphertext_with_tag)`. +#[allow(deprecated)] pub(crate) fn aead_seal( codec: Codec, key: &[u8], @@ -120,6 +121,7 @@ pub(crate) fn aead_seal( } /// Decrypt ciphertext with the given AEAD codec, key, and nonce. +#[allow(deprecated)] pub(crate) fn aead_open( codec: Codec, key: &[u8], diff --git a/src/views/bls12381.rs b/src/views/bls12381.rs index c4e8c81..63d5ab4 100644 --- a/src/views/bls12381.rs +++ b/src/views/bls12381.rs @@ -4,6 +4,7 @@ use crate::{ AttributesError, CipherError, ConversionsError, KdfError, SealError, SignError, ThresholdError, VerifyError, }, + views::threshold_meta::{self, ThresholdDisclosure}, AttrId, AttrView, Builder, CipherAttrView, ConvView, DataView, Error, FingerprintView, KdfAttrView, Multikey, OpenView, SealView, SignView, ThresholdAttrView, ThresholdView, VerifyView, Views, @@ -1011,6 +1012,145 @@ impl<'a> ThresholdView for View<'a> { _ => Err(Error::UnsupportedAlgorithm(self.mk.codec.to_string())), } } + + /// Split with a specific disclosure mode for t/n. + fn split_with_disclosure( + &self, + threshold: usize, + limit: usize, + mode: ThresholdDisclosure, + meta_key: Option<&Multikey>, + ) -> Result, Error> { + // generate Full-mode shares first, then convert each to the target mode + let shares = self.split(threshold, limit)?; + shares + .iter() + .map(|s| { + s.disclosure_view()? + .to_disclosure(mode, meta_key, None) + }) + .collect() + } + + /// Add a share with a meta_key for decrypting threshold params. + fn add_share_with_meta( + &self, + share: &Multikey, + meta_key: Option<&Multikey>, + ) -> Result { + // read t/n from the share using the meta_key if needed + let (share_t, share_n) = + threshold_meta::read_threshold_params(share, meta_key)?; + + // get the share data + let (key_share, identifier) = { + let av = share.threshold_attr_view()?; + let identifier = bytes_to_identifier(av.identifier()?)?; + let dv = share.data_view()?; + let key_bytes = dv.key_bytes()?; + ( + KeyShare(identifier, share_t, share_n, key_bytes.to_vec()), + identifier, + ) + }; + + // update threshold data + let threshold_data: Vec = { + let av = self.mk.threshold_attr_view()?; + let mut tdata = match av.threshold_data() { + Ok(b) => ThresholdData::try_from(b) + .map_err(|e| ThresholdError::ShareCombineFailed(e.to_string()))?, + Err(_) => ThresholdData::default(), + }; + if tdata.0.contains_key(&identifier) { + return Err(ThresholdError::DuplicateShare.into()); + } + tdata.0.insert(identifier, key_share); + tdata.into() + }; + + let comment = if self.mk.comment.is_empty() { + share.comment.clone() + } else { + self.mk.comment.clone() + }; + + let builder = Builder::new(self.mk.codec) + .with_comment(&comment) + .with_threshold(share_t) + .with_limit(share_n) + .with_threshold_data(&threshold_data); + + builder.try_build() + } + + /// Combine with a meta_key for decrypting threshold params. + fn combine_with_meta( + &self, + meta_key: Option<&Multikey>, + ) -> Result { + // read threshold using meta_key if needed + let (threshold, _limit) = + threshold_meta::read_threshold_params(self.mk, meta_key)?; + + // get the current threshold data + let threshold_data = { + let av = self.mk.threshold_attr_view()?; + match av.threshold_data() { + Ok(b) => ThresholdData::try_from(b) + .map_err(|e| ThresholdError::ShareCombineFailed(e.to_string()))?, + Err(_) => ThresholdData::default(), + } + }; + + // check that we have enough shares to combine + let num_shares = threshold_data.0.len(); + if num_shares < threshold { + return Err(ThresholdError::NotEnoughShares.into()); + } + + match self.mk.codec { + Codec::Bls12381G1Priv => { + let mut shares = Vec::with_capacity(threshold_data.0.len()); + threshold_data + .0 + .iter() + .try_for_each(|(id, share)| -> Result<(), Error> { + let value = bytes_to_value(share.3.as_slice())?; + let vsss = Share::with_identifier_and_value(*id, value); + shares.push(SecretKeyShare::(vsss)); + Ok(()) + })?; + let key = SecretKey::combine(shares.as_slice()) + .map_err(|e| ThresholdError::ShareCombineFailed(e.to_string()))?; + let key_bytes = key.to_be_bytes().to_vec(); + Builder::new(Codec::Bls12381G1Priv) + .with_comment(&self.mk.comment) + .with_key_bytes(&key_bytes) + .try_build() + } + Codec::Bls12381G2Priv => { + let mut shares = Vec::with_capacity(threshold_data.0.len()); + threshold_data + .0 + .iter() + .try_for_each(|(id, share)| -> Result<(), Error> { + let value = bytes_to_value(share.3.as_slice())?; + let vsss = Share::with_identifier_and_value(*id, value); + shares.push(SecretKeyShare::(vsss)); + Ok(()) + })?; + let key = SecretKey::combine(shares.as_slice()) + .map_err(|e| ThresholdError::ShareCombineFailed(e.to_string()))?; + let key_bytes = key.to_be_bytes().to_vec(); + Builder::new(Codec::Bls12381G2Priv) + .with_comment(&self.mk.comment) + .with_key_bytes(&key_bytes) + .try_build() + } + _ => Err(Error::UnsupportedAlgorithm(self.mk.codec.to_string())), + } + } } impl<'a> VerifyView for View<'a> { diff --git a/src/views/threshold_meta.rs b/src/views/threshold_meta.rs new file mode 100644 index 0000000..ba6bc20 --- /dev/null +++ b/src/views/threshold_meta.rs @@ -0,0 +1,765 @@ +// SPDX-License-Identifier: Apache-2.0 +//! Threshold disclosure modes and encrypted metadata helpers. +//! +//! This module provides the types and functions for configurable confidentiality +//! of threshold `t` and share-count `n` values on key shares. +//! +//! Three disclosure modes are supported: +//! +//! - **[`ThresholdDisclosure::Full`]** — t and n are plaintext attributes (default). +//! - **[`ThresholdDisclosure::Partial`]** — n is plaintext, t is encrypted. +//! - **[`ThresholdDisclosure::FullConfidentialial`]** — both t and n are encrypted. + +use crate::{ + AttrId, Error, Multikey, Views, +}; +use chacha20poly1305::{ + aead::{Aead, KeyInit, Payload}, + ChaCha20Poly1305, Nonce, +}; +use multi_codec::Codec; +use multi_trait::{EncodeInto, TryDecodeFrom}; +use multi_util::Varuint; +use serde::{Deserialize, Serialize}; +use zeroize::Zeroizing; + +/// Disclosure mode for threshold parameters (t and n). +#[repr(u8)] +#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)] +#[non_exhaustive] +pub enum ThresholdDisclosure { + /// t and n are plaintext attributes (default, backward-compatible). + #[default] + Full = 0, + /// n is plaintext, t is encrypted (auditable n, hidden t). + Partial = 1, + /// Both t and n are encrypted. + FullConfidentialial = 2, +} + +impl ThresholdDisclosure { + /// Get the human-readable name for this mode. + #[must_use] + pub fn as_str(&self) -> &'static str { + match self { + Self::Full => "full", + Self::Partial => "partial", + Self::FullConfidentialial => "full-confidentialial", + } + } +} + +impl core::fmt::Display for ThresholdDisclosure { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!(f, "{}", self.as_str()) + } +} + +impl From for u8 { + fn from(val: ThresholdDisclosure) -> Self { + val as u8 + } +} + +impl TryFrom for ThresholdDisclosure { + type Error = Error; + + fn try_from(code: u8) -> Result { + match code { + 0 => Ok(Self::Full), + 1 => Ok(Self::Partial), + 2 => Ok(Self::FullConfidentialial), + _ => Err(Error::Threshold(ThresholdError::MetaEncryption( + format!("invalid disclosure mode: {code}"), + ))), + } + } +} + +impl EncodeInto for ThresholdDisclosure { + fn encode_into(&self) -> Vec { + let v: u8 = (*self).into(); + v.encode_into() + } +} + +impl<'a> TryDecodeFrom<'a> for ThresholdDisclosure { + type Error = Error; + + fn try_decode_from(bytes: &'a [u8]) -> Result<(Self, &'a [u8]), Self::Error> { + let (code, ptr) = u8::try_decode_from(bytes) + .map_err(Error::Multitrait)?; + let mode = Self::try_from(code)?; + Ok((mode, ptr)) + } +} + +impl Serialize for ThresholdDisclosure { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_u8((*self).into()) + } +} + +impl<'de> Deserialize<'de> for ThresholdDisclosure { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let code = u8::deserialize(deserializer)?; + Self::try_from(code).map_err(serde::de::Error::custom) + } +} + +/// The threshold parameters that may be encrypted. +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +pub struct ThresholdMetadata { + /// The threshold value `t`, or `None` if not stored in the encrypted blob. + pub threshold: Option, + /// The limit value `n`, or `None` if not stored in the encrypted blob. + pub limit: Option, +} + +impl ThresholdMetadata { + /// Create metadata with both t and n. + #[must_use] + pub fn new(threshold: u16, limit: u16) -> Self { + Self { + threshold: Some(threshold), + limit: Some(limit), + } + } + + /// Create metadata with only the threshold (for Partial mode). + #[must_use] + pub fn threshold_only(threshold: u16) -> Self { + Self { + threshold: Some(threshold), + limit: None, + } + } + + /// Encode to CBOR bytes. + pub fn to_cbor_bytes(&self) -> Result, Error> { + let mut buf = Vec::new(); + ciborium::into_writer(self, &mut buf) + .map_err(|e| Error::Threshold(ThresholdError::MetaEncryption(format!("CBOR encode: {e}"))))?; + Ok(buf) + } + + /// Decode from CBOR bytes. + pub fn from_cbor_bytes(bytes: &[u8]) -> Result { + ciborium::from_reader(bytes) + .map_err(|e| Error::Threshold(ThresholdError::MetaEncryption(format!("CBOR decode: {e}")))) + } +} + +/// Cipher parameters for decrypting [`ThresholdMetadata`]. +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +pub struct ThresholdMetaCipher { + /// The multicodec code of the AEAD cipher (e.g. `0x2000` for ChaCha20-Poly1305). + pub cipher_codec: u64, + /// The nonce bytes. + pub nonce: Vec, +} + +impl ThresholdMetaCipher { + /// Create new cipher info from a codec and nonce. + #[must_use] + pub fn new(codec: Codec, nonce: Vec) -> Self { + Self { + cipher_codec: codec.into(), + nonce, + } + } + + /// Get the codec as a [`Codec`]. + pub fn codec(&self) -> Result { + Codec::try_from(self.cipher_codec) + .map_err(|e| Error::Threshold(ThresholdError::MetaEncryption(format!("invalid codec: {e}")))) + } + + /// Encode to CBOR bytes. + pub fn to_cbor_bytes(&self) -> Result, Error> { + let mut buf = Vec::new(); + ciborium::into_writer(self, &mut buf) + .map_err(|e| Error::Threshold(ThresholdError::MetaEncryption(format!("CBOR encode cipher: {e}"))))?; + Ok(buf) + } + + /// Decode from CBOR bytes. + pub fn from_cbor_bytes(bytes: &[u8]) -> Result { + ciborium::from_reader(bytes) + .map_err(|e| Error::Threshold(ThresholdError::MetaEncryption(format!("CBOR decode cipher: {e}")))) + } +} + +/// Encrypt threshold metadata using ChaCha20-Poly1305 AEAD. +/// +/// `key` must be 32 bytes. A random nonce is generated and returned in the +/// [`ThresholdMetaCipher`]. +#[allow(deprecated)] +pub fn encrypt_threshold_meta( + meta: &ThresholdMetadata, + key: &[u8], +) -> Result<(Vec, ThresholdMetaCipher), Error> { + if key.len() != 32 { + return Err(Error::Threshold(ThresholdError::MetaEncryption(format!( + "invalid key length: expected 32, got {}", + key.len() + )))); + } + + let plaintext = meta.to_cbor_bytes()?; + + let mut nonce_bytes = vec![0u8; 12]; + getrandom::fill(&mut nonce_bytes).map_err(|e| { + Error::Threshold(ThresholdError::MetaEncryption(format!("RNG failure: {e}"))) + })?; + + let cipher = ChaCha20Poly1305::new_from_slice(key) + .map_err(|e| Error::Threshold(ThresholdError::MetaEncryption(format!("AEAD key init: {e}"))))?; + + let ciphertext = cipher + .encrypt( + Nonce::from_slice(&nonce_bytes), + Payload { + msg: &plaintext, + aad: b"threshold-meta", + }, + ) + .map_err(|e| Error::Threshold(ThresholdError::MetaEncryption(format!("AEAD seal: {e}"))))?; + + let cipher_info = ThresholdMetaCipher::new(Codec::Chacha20Poly1305, nonce_bytes); + Ok((ciphertext, cipher_info)) +} + +/// Decrypt threshold metadata using ChaCha20-Poly1305 AEAD. +#[allow(deprecated)] +pub fn decrypt_threshold_meta( + encrypted: &[u8], + cipher_info: &ThresholdMetaCipher, + key: &[u8], +) -> Result { + if key.len() != 32 { + return Err(Error::Threshold(ThresholdError::MetaEncryption(format!( + "invalid key length: expected 32, got {}", + key.len() + )))); + } + + let codec = cipher_info.codec()?; + match codec { + Codec::Chacha20Poly1305 => { + let cipher = ChaCha20Poly1305::new_from_slice(key) + .map_err(|e| Error::Threshold(ThresholdError::MetaEncryption(format!("AEAD key init: {e}"))))?; + + let plaintext = cipher + .decrypt( + Nonce::from_slice(&cipher_info.nonce), + Payload { + msg: encrypted, + aad: b"threshold-meta", + }, + ) + .map_err(|e| Error::Threshold(ThresholdError::MetaEncryption(format!("AEAD open: {e}"))))?; + + ThresholdMetadata::from_cbor_bytes(&plaintext) + } + _ => Err(Error::Threshold(ThresholdError::MetaEncryption(format!( + "unsupported AEAD codec: {codec}" + )))), + } +} + +/// Generate a random 32-byte ChaCha20-Poly1305 key. +pub fn generate_meta_key() -> Zeroizing> { + let mut key = Zeroizing::new(vec![0u8; 32]); + getrandom::fill(key.as_mut_slice()) + .expect("getrandom failure during meta key generation"); + key +} + +/// Read the raw 32-byte key from a `Multikey` that contains a symmetric +/// cipher key (e.g. a ChaCha20-Poly1305 key Multikey). This bridges the +/// `Multikey` at-rest encryption infrastructure to the threshold metadata +/// encryption. +fn extract_meta_key(meta_key: &Multikey) -> Result>, Error> { + let dv = meta_key.data_view()?; + let key = dv.key_bytes()?; + if key.len() != 32 { + return Err(Error::Threshold(ThresholdError::MetaEncryption(format!( + "meta key must be 32 bytes, got {}", + key.len() + )))); + } + Ok(key) +} + +/// Read the disclosure mode from a Multikey. Returns [`ThresholdDisclosure::Full`] +/// if no `ThresholdDisclosure` attribute is present (backward compatible). +pub fn disclosure_mode(mk: &Multikey) -> Result { + match mk.attributes.get(&AttrId::ThresholdDisclosure) { + Some(v) => { + let (mode, _) = ThresholdDisclosure::try_decode_from(v.as_slice())?; + Ok(mode) + } + None => Ok(ThresholdDisclosure::Full), + } +} + +/// Read t and n from a Multikey, decrypting if necessary. +/// +/// In Full mode, reads plaintext `Threshold`/`Limit` attributes. +/// In Partial mode, reads `Limit` from plaintext and decrypts `Threshold`. +/// In FullConfidentialial mode, decrypts both from `EncryptedThresholdMeta`. +/// +/// `meta_key` is required for Partial/FullConfidentialial modes. +pub fn read_threshold_params( + mk: &Multikey, + meta_key: Option<&Multikey>, +) -> Result<(usize, usize), Error> { + let mode = disclosure_mode(mk)?; + match mode { + ThresholdDisclosure::Full => { + let t = mk + .attributes + .get(&AttrId::Threshold) + .ok_or(AttributesError::MissingThreshold)?; + let n = mk + .attributes + .get(&AttrId::Limit) + .ok_or(AttributesError::MissingLimit)?; + let t = Varuint::::try_from(t.as_slice())?.to_inner(); + let n = Varuint::::try_from(n.as_slice())?.to_inner(); + Ok((t, n)) + } + ThresholdDisclosure::Partial => { + let n = mk + .attributes + .get(&AttrId::Limit) + .ok_or(AttributesError::MissingLimit)?; + let n = Varuint::::try_from(n.as_slice())?.to_inner(); + + let encrypted = mk + .attributes + .get(&AttrId::EncryptedThresholdMeta) + .ok_or(ThresholdError::MetaEncryption( + "missing EncryptedThresholdMeta".to_string(), + ))?; + let cipher_info_bytes = mk + .attributes + .get(&AttrId::ThresholdMetaCipher) + .ok_or(ThresholdError::MetaEncryption( + "missing ThresholdMetaCipher".to_string(), + ))?; + let cipher_info = ThresholdMetaCipher::from_cbor_bytes(cipher_info_bytes)?; + + let meta_key = meta_key + .ok_or(ThresholdError::MissingMetaKey)?; + let key = extract_meta_key(meta_key)?; + + let meta = decrypt_threshold_meta(encrypted, &cipher_info, &key)?; + let t = meta + .threshold + .ok_or(ThresholdError::MetaEncryption( + "threshold not in encrypted metadata".to_string(), + ))? as usize; + Ok((t, n)) + } + ThresholdDisclosure::FullConfidentialial => { + let encrypted = mk + .attributes + .get(&AttrId::EncryptedThresholdMeta) + .ok_or(ThresholdError::MetaEncryption( + "missing EncryptedThresholdMeta".to_string(), + ))?; + let cipher_info_bytes = mk + .attributes + .get(&AttrId::ThresholdMetaCipher) + .ok_or(ThresholdError::MetaEncryption( + "missing ThresholdMetaCipher".to_string(), + ))?; + let cipher_info = ThresholdMetaCipher::from_cbor_bytes(cipher_info_bytes)?; + + let meta_key = meta_key.ok_or(ThresholdError::MissingMetaKey)?; + let key = extract_meta_key(meta_key)?; + + let meta = decrypt_threshold_meta(encrypted, &cipher_info, &key)?; + let t = meta + .threshold + .ok_or(ThresholdError::MetaEncryption( + "threshold not in encrypted metadata".to_string(), + ))? as usize; + let n = meta + .limit + .ok_or(ThresholdError::MetaEncryption( + "limit not in encrypted metadata".to_string(), + ))? as usize; + Ok((t, n)) + } + } +} + +/// Stamp disclosure attributes onto a Multikey's attribute map. +/// +/// This is the single place where the attribute-stamping logic lives, shared +/// between `Builder::with_disclosure()` and `to_disclosure()`. +pub fn stamp_disclosure_attrs( + attributes: &mut crate::mk::Attributes, + mode: ThresholdDisclosure, + threshold: usize, + limit: usize, + meta_key: Option<&Multikey>, +) -> Result<(), Error> { + use crate::error::ThresholdError; + + // remove old disclosure-related attributes + attributes.remove(&AttrId::Threshold); + attributes.remove(&AttrId::Limit); + attributes.remove(&AttrId::EncryptedThresholdMeta); + attributes.remove(&AttrId::ThresholdMetaCipher); + attributes.remove(&AttrId::ThresholdDisclosure); + + match mode { + ThresholdDisclosure::Full => { + let t_bytes: Vec = Varuint(threshold).into(); + let n_bytes: Vec = Varuint(limit).into(); + attributes.insert(AttrId::Threshold, t_bytes.into()); + attributes.insert(AttrId::Limit, n_bytes.into()); + attributes.insert( + AttrId::ThresholdDisclosure, + Zeroizing::new(mode.encode_into()), + ); + } + ThresholdDisclosure::Partial => { + let meta_key = meta_key.ok_or(ThresholdError::MissingMetaKey)?; + let key = extract_meta_key(meta_key)?; + + // plaintext limit + let n_bytes: Vec = Varuint(limit).into(); + attributes.insert(AttrId::Limit, n_bytes.into()); + + // encrypted threshold only + let meta = ThresholdMetadata::threshold_only(threshold as u16); + let (ciphertext, cipher_info) = encrypt_threshold_meta(&meta, &key)?; + attributes.insert(AttrId::EncryptedThresholdMeta, ciphertext.into()); + attributes.insert( + AttrId::ThresholdMetaCipher, + cipher_info.to_cbor_bytes()?.into(), + ); + attributes.insert( + AttrId::ThresholdDisclosure, + Zeroizing::new(mode.encode_into()), + ); + } + ThresholdDisclosure::FullConfidentialial => { + let meta_key = meta_key.ok_or(ThresholdError::MissingMetaKey)?; + let key = extract_meta_key(meta_key)?; + + // encrypted both t and n + let meta = ThresholdMetadata::new(threshold as u16, limit as u16); + let (ciphertext, cipher_info) = encrypt_threshold_meta(&meta, &key)?; + attributes.insert(AttrId::EncryptedThresholdMeta, ciphertext.into()); + attributes.insert( + AttrId::ThresholdMetaCipher, + cipher_info.to_cbor_bytes()?.into(), + ); + attributes.insert( + AttrId::ThresholdDisclosure, + Zeroizing::new(mode.encode_into()), + ); + } + } + Ok(()) +} + +/// The `ThresholdDisclosureView` trait for mode conversion and reading. +pub trait ThresholdDisclosureView { + /// Get the current disclosure mode. Returns Full if no mode attribute is present. + fn disclosure_mode(&self) -> Result; + + /// Read t and n, decrypting if necessary. Requires `meta_key` for encrypted modes. + fn read_threshold_params( + &self, + meta_key: Option<&Multikey>, + ) -> Result<(usize, usize), Error>; + + /// Convert to a target disclosure mode. + fn to_disclosure( + &self, + target: ThresholdDisclosure, + meta_key: Option<&Multikey>, + current_meta_key: Option<&Multikey>, + ) -> Result; +} + +/// Concrete view implementation for any Multikey. +pub struct DisclosureView<'a> { + mk: &'a Multikey, +} + +impl<'a> DisclosureView<'a> { + /// Create a disclosure view over a Multikey. + pub fn new(mk: &'a Multikey) -> Self { + Self { mk } + } +} + +impl<'a> TryFrom<&'a Multikey> for DisclosureView<'a> { + type Error = Error; + + fn try_from(mk: &'a Multikey) -> Result { + Ok(Self { mk }) + } +} + +impl<'a> ThresholdDisclosureView for DisclosureView<'a> { + fn disclosure_mode(&self) -> Result { + disclosure_mode(self.mk) + } + + fn read_threshold_params( + &self, + meta_key: Option<&Multikey>, + ) -> Result<(usize, usize), Error> { + read_threshold_params(self.mk, meta_key) + } + + fn to_disclosure( + &self, + target: ThresholdDisclosure, + meta_key: Option<&Multikey>, + current_meta_key: Option<&Multikey>, + ) -> Result { + // read current t/n (decrypting if needed) + let (t, n) = read_threshold_params(self.mk, current_meta_key)?; + + // clone and stamp new attrs + let mut new_mk = self.mk.clone(); + stamp_disclosure_attrs( + &mut new_mk.attributes, + target, + t, + n, + meta_key, + )?; + Ok(new_mk) + } +} + +use crate::error::{AttributesError, ThresholdError}; + +#[cfg(test)] +mod tests { + use super::*; + use crate::Builder; + use multi_codec::Codec; + + fn make_meta_key() -> Multikey { + let key = generate_meta_key(); + Builder::new(Codec::Chacha20Poly1305) + .with_key_bytes(&key.as_slice()) + .try_build() + .unwrap() + } + + fn make_share(t: usize, n: usize) -> Multikey { + Builder::new(Codec::Bls12381G1PrivShare) + .with_threshold(t) + .with_limit(n) + .with_key_bytes(&vec![0u8; 32]) + .try_build() + .unwrap() + } + + #[test] + fn test_disclosure_default() { + assert_eq!(ThresholdDisclosure::default(), ThresholdDisclosure::Full); + } + + #[test] + fn test_disclosure_from_u8() { + assert_eq!( + ThresholdDisclosure::try_from(0u8).unwrap(), + ThresholdDisclosure::Full + ); + assert_eq!( + ThresholdDisclosure::try_from(1u8).unwrap(), + ThresholdDisclosure::Partial + ); + assert_eq!( + ThresholdDisclosure::try_from(2u8).unwrap(), + ThresholdDisclosure::FullConfidentialial + ); + assert!(ThresholdDisclosure::try_from(3u8).is_err()); + } + + #[test] + fn test_disclosure_encode_decode_roundtrip() { + for mode in [ + ThresholdDisclosure::Full, + ThresholdDisclosure::Partial, + ThresholdDisclosure::FullConfidentialial, + ] { + let encoded = mode.encode_into(); + let (decoded, rest) = + ThresholdDisclosure::try_decode_from(&encoded).unwrap(); + assert_eq!(mode, decoded); + assert!(rest.is_empty()); + } + } + + #[test] + fn test_metadata_cbor_roundtrip() { + let meta = ThresholdMetadata::new(3, 5); + let bytes = meta.to_cbor_bytes().unwrap(); + let decoded = ThresholdMetadata::from_cbor_bytes(&bytes).unwrap(); + assert_eq!(meta, decoded); + } + + #[test] + fn test_encrypt_decrypt_roundtrip() { + let key = generate_meta_key(); + let meta = ThresholdMetadata::new(3, 5); + let (ct, info) = encrypt_threshold_meta(&meta, &key).unwrap(); + let decrypted = decrypt_threshold_meta(&ct, &info, &key).unwrap(); + assert_eq!(meta, decrypted); + } + + #[test] + fn test_encrypt_decrypt_wrong_key() { + let key1 = generate_meta_key(); + let key2 = generate_meta_key(); + let meta = ThresholdMetadata::new(3, 5); + let (ct, info) = encrypt_threshold_meta(&meta, &key1).unwrap(); + assert!(decrypt_threshold_meta(&ct, &info, &key2).is_err()); + } + + #[test] + fn test_encrypt_decrypt_tampered() { + let key = generate_meta_key(); + let meta = ThresholdMetadata::new(3, 5); + let (mut ct, info) = encrypt_threshold_meta(&meta, &key).unwrap(); + ct[0] ^= 0xFF; + assert!(decrypt_threshold_meta(&ct, &info, &key).is_err()); + } + + #[test] + fn test_read_full_mode() { + let share = make_share(3, 5); + let (t, n) = read_threshold_params(&share, None).unwrap(); + assert_eq!(t, 3); + assert_eq!(n, 5); + } + + #[test] + fn test_convert_full_to_partial_and_back() { + let share = make_share(3, 5); + let meta_key = make_meta_key(); + + // convert to Partial + let partial = share + .disclosure_view() + .unwrap() + .to_disclosure(ThresholdDisclosure::Partial, Some(&meta_key), None) + .unwrap(); + assert_eq!( + partial.disclosure_view().unwrap().disclosure_mode().unwrap(), + ThresholdDisclosure::Partial + ); + + // read t/n from partial + let (t, n) = read_threshold_params(&partial, Some(&meta_key)).unwrap(); + assert_eq!(t, 3); + assert_eq!(n, 5); + + // convert back to Full + let full = partial + .disclosure_view() + .unwrap() + .to_disclosure(ThresholdDisclosure::Full, None, Some(&meta_key)) + .unwrap(); + assert_eq!( + full.disclosure_view().unwrap().disclosure_mode().unwrap(), + ThresholdDisclosure::Full + ); + let (t, n) = read_threshold_params(&full, None).unwrap(); + assert_eq!(t, 3); + assert_eq!(n, 5); + } + + #[test] + fn test_convert_full_to_full_confidentialial_and_back() { + let share = make_share(3, 5); + let meta_key = make_meta_key(); + + // convert to FullConfidentialial + let encrypted = share + .disclosure_view() + .unwrap() + .to_disclosure( + ThresholdDisclosure::FullConfidentialial, + Some(&meta_key), + None, + ) + .unwrap(); + assert_eq!( + encrypted + .disclosure_view() + .unwrap() + .disclosure_mode() + .unwrap(), + ThresholdDisclosure::FullConfidentialial + ); + + // read t/n + let (t, n) = read_threshold_params(&encrypted, Some(&meta_key)).unwrap(); + assert_eq!(t, 3); + assert_eq!(n, 5); + + // convert back to Full + let full = encrypted + .disclosure_view() + .unwrap() + .to_disclosure(ThresholdDisclosure::Full, None, Some(&meta_key)) + .unwrap(); + let (t, n) = read_threshold_params(&full, None).unwrap(); + assert_eq!(t, 3); + assert_eq!(n, 5); + } + + #[test] + fn test_read_encrypted_without_meta_key() { + let share = make_share(3, 5); + let meta_key = make_meta_key(); + let encrypted = share + .disclosure_view() + .unwrap() + .to_disclosure( + ThresholdDisclosure::FullConfidentialial, + Some(&meta_key), + None, + ) + .unwrap(); + assert!(read_threshold_params(&encrypted, None).is_err()); + } + + #[test] + fn test_convert_to_partial_without_meta_key() { + let share = make_share(3, 5); + let result = share + .disclosure_view() + .unwrap() + .to_disclosure(ThresholdDisclosure::Partial, None, None); + assert!(result.is_err()); + } + + #[test] + fn test_generate_meta_key_is_32_bytes() { + let key = generate_meta_key(); + assert_eq!(key.len(), 32); + } +} \ No newline at end of file From e2ef6006cbb8fccdeba4123b77d3a0e11523fafb Mon Sep 17 00:00:00 2001 From: Dave Grantham Date: Tue, 14 Jul 2026 14:46:05 -0600 Subject: [PATCH 2/2] bump version Signed-off-by: Dave Grantham --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 6274ccc..5d0b51b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "multi-key" -version = "1.0.2" +version = "1.0.3" edition = "2021" authors = ["Dave Grantham "] description = "Multikey self-describing cryptographic key data"