Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "multi-key"
version = "1.0.2"
version = "1.0.3"
edition = "2021"
authors = ["Dave Grantham <dwg@linuxprogrammer.org>"]
description = "Multikey self-describing cryptographic key data"
Expand All @@ -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"] }
Expand Down
142 changes: 142 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 18 additions & 0 deletions src/attrid.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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",
}
}
}
Expand Down Expand Up @@ -143,6 +155,9 @@ impl TryFrom<u8> 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()),
}
}
Expand Down Expand Up @@ -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()),
}
}
Expand Down
17 changes: 17 additions & 0 deletions src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 7 additions & 2 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
80 changes: 75 additions & 5 deletions src/mk.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::{
Expand All @@ -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::{
Expand Down Expand Up @@ -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<Box<dyn ThresholdDisclosureView + 'a>, Error> {
Ok(Box::new(threshold_meta::DisclosureView::new(self)))
}
}

/// Multikey builder constructs private keys only. If you need a public key you
Expand Down Expand Up @@ -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<u8>,
cipher_info: threshold_meta::ThresholdMetaCipher,
plaintext_limit: Option<usize>,
) -> 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<u8> = 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();
Expand Down
Loading
Loading