From e5933f3863a6071ba0aa1e52075861511e5de983 Mon Sep 17 00:00:00 2001 From: Dave Grantham Date: Tue, 14 Jul 2026 14:51:46 -0600 Subject: [PATCH] threshold hardening Signed-off-by: Dave Grantham --- Cargo.toml | 7 +- README.md | 563 +++++++++++++++++++++++++++++------- src/attrid.rs | 15 + src/error.rs | 22 +- src/lib.rs | 5 +- src/ms.rs | 32 +- src/views.rs | 41 +++ src/views/bls12381.rs | 209 +++++++++++++ src/views/threshold_meta.rs | 226 +++++++++++++++ 9 files changed, 1019 insertions(+), 101 deletions(-) create mode 100644 src/views/threshold_meta.rs diff --git a/Cargo.toml b/Cargo.toml index ba2afe6..a3c8d61 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "multi-sig" -version = "1.0.2" +version = "1.0.3" edition = "2021" authors = ["Dave Grantham "] description = "Multisig self-describing multicodec implementation for digital signatures" @@ -14,16 +14,21 @@ categories = ["cryptography", "encoding"] default = ["serde"] [dependencies] +ciborium = "0.2" +chacha20poly1305 = "0.10" elliptic-curve = "0.14" +getrandom = { version = "0.2" } # blsful configured per-target below (blst for native, rust for wasm) multi-base = "1.0" multi-codec = "1.0" +multi-key = { version = "1.0", path = "../multi-key" } multi-trait = "1.0" multi-util = "1.0" serde = { version = "1.0", default-features = false, features = ["alloc", "derive"], optional = true } ssh-encoding = "0.3" thiserror = { version = "2.0" } unsigned-varint = { version = "0.8", features = ["std"] } +zeroize = "1" [target.'cfg(target_arch = "wasm32")'.dependencies] blsful = { version = "4.0.0-rc1", default-features = false, features = ["rust"] } diff --git a/README.md b/README.md index 457ab0b..4efd556 100644 --- a/README.md +++ b/README.md @@ -5,130 +5,502 @@ # Multisig -A Rust implementation of the [multiformats][MULTIFORMATS] [multisig specification][MULTISIG]. +A Rust implementation of the [multiformats][MULTIFORMATS] [multisig specification][MULTISIG]. The +published crate is **`multi-sig`** (depend on it as `multi-sig = "1.0"` in `Cargo.toml` and +import it as `multi_sig` in Rust, e.g. `use multi_sig::Builder;`). -## Current Status +## Current Status -It currently supports the following digital signature protocols. +This crate provides self-describing digital signature containers (`Multisig`) for 35 signature +codecs spanning classical, post-quantum, and hybrid schemes. It supports BLS12-381 threshold +signatures with share accumulation and combination, and SSH signature interoperability for all +classical schemes plus BLS12-381 combined and share signatures. -* EdDSA (ed25519) -* Es256K (secp256k1) -* BLS12-381 G1/G2 +**Supported signature families:** -The BLS curve implementation also supports threshold signatures. +- **Classical:** Ed25519, secp256k1 (ECDSA), NIST P-256/P-384/P-521 (ECDSA), RSA-SHA256, BLS12-381 G1/G2 +- **Post-quantum:** ML-DSA (65/87), FN-DSA (512/1024), MAYO (1/2/3/5), SLH-DSA (all 12 parameter sets) +- **Hybrid:** Ed25519+MAYO-2, Ed25519+ML-DSA-65, Ed25519+FN-DSA-512, BLS12-381-G1+ML-DSA-65, BLS12-381-G1+FN-DSA-512, BLS12-381-G1+MAYO-1, BLS12-381-G1+MAYO-2 +- **Threshold:** BLS12-381 G1/G2 combined and share signatures with threshold disclosure modes -This crate also supports converting to/from SSH format digital signatures using -the [`ssh-key`][SSHKEY] crate. This gives full OpenSSH compatibility for -reading in OpenSSH serialized signatures and converting them to Multisig -format. This even includes non-standard SSH key protocols such as Es256K and -BBLS12-381 G1/G2 signatures through the use of [RFC 4251][RFC4251] standard for -"additional algorithms" names using the "@multisig" domain suffix. For -instance, using this crate, an Es256K Multisig converted to an SSH format -signature has the algorithm name "secp256k1@multisig". A BLS12-381 G1 signature -share converted to SSH format has the algorithm name -"bls12_381-g1-share@multsig". +**SSH interoperability:** Ed25519, secp256k1, NIST P-256/P-384/P-521, RSA-SHA256, and BLS12-381 +G1/G2 (combined and share signatures) convert to/from OpenSSH format using the +[`ssh-key`][SSHKEY] crate. Non-standard algorithms use [RFC 4251][RFC4251] "additional algorithms" +names with the `@multisig` domain suffix (e.g. `secp256k1@multisig`, `bls12_381-g1-share@multisig`). ## Introduction -This is a Rust implementation of a multicodec format for digital signatures. -The design of the format is intentionally abstract to support any kind of -digital signature data for any protocol. This format should best be thought of -as a container of signature data with abstract, protocol-specific views with a -generic and self-describing data storage format. +This is a Rust implementation of a multicodec container format for digital signatures. The +design is intentionally abstract to support any kind of digital signature data for any +protocol. The format is best thought of as a container of signature data with abstract, +protocol-specific views backed by a generic, self-describing data storage format. -Every piece of data in a serialized Multisig object either has a known-fixed -size or a self-describing variable size such that software processing these -objects do not need to support all digital signature protocols to be able to -accurately calculate the size of the serialized object and skip over it if -needed. +Every piece of data in a serialized Multisig object either has a known fixed size or a +self-describing variable size (via `Varuint`/`Varbytes`), so software processing these objects +does not need to support all digital signature protocols to accurately calculate the size of the +serialized object and skip over it if needed. -The only operations that can be executed on a Multisig object are those that -return the attribute data and the threshold signature operations for -accumulating and combining signature shares. Any operation that involves a -cryptographic key (e.g. signing, verifying) is found in the -[`Multikey`][MULTIKEY] companion crate. +The only operations that can be executed on a Multisig object are those that return attribute +data and the threshold signature operations for accumulating and combining signature shares. Any +operation that involves a cryptographic key (e.g. signing, verifying) is found in the +companion [`Multi-Key`][MULTIKEY] crate. -## Views on the Multisig Data +## Wire Format -To provide an abstract interface to digital signatures of all schemes and -formats, this Multisig crate provides "views" on the Multisig data. These are -read-only abstract interfaces to the Multisig that have implementations for the -different supporting signature protocols. - -Currently the set of views provide generic access to the "payload encoding" -codec (`multisig::AttrView`), the signature data (`multisig::SigDataView`), the -threshold signing attributes if the protocol supports it -(`multisig::ThresholdAttrView`) and the interface for doing threshold signature -operations such as accessing and adding shares as well as combining shares -(`multisig::ThresholdView`). - -It is important to note that the functions in the various views that seem to -mutate the Multisig in fact do a copy-on-write (CoW) operation and return a new -Multisig with the mutation applied. This is most important when trying to -reconstruct a threshold signature from its shares. The best example of this is -in the `multisig::Builder::try_build()` method. You'll see that it loops over -the shares adding each one and replacing it's mutable multisig variable with -the new one containing the updated shares. +A Multisig is serialized as: ``` -let mut multisig = Multisig { .. }; +SIGIL (0x1239) | signature_codec | Varbytes(message) | Varuint(num_attributes) | + [ AttrId | Varbytes(attribute_value) ] * num_attributes +``` + +- **SIGIL** — the multicodec `0x1239` (`Multisig`) distinguishes this format from the older + Varsig (`0x34`). +- **signature_codec** — a varuint-encoded multicodec tag identifying the signature algorithm. +- **message** — `Varbytes` (length-prefixed). If non-empty, the signature is **combined** + (carries the signed message in-band). If empty, the signature is **detached** (the message + must be supplied out-of-band for verification). +- **attributes** — a counted list of `(AttrId, Varbytes)` pairs. Attribute IDs are u8 + enum values. Duplicate IDs are rejected at decode time. Attributes are emitted in `BTreeMap` + order (sorted by ID) for deterministic encoding. + +The preferred base encoding for Multisig strings is `Base16Lower` (lowercase hex). + +## Supported Signature Formats + +### Classical Signatures + +| Codec | Multicodec name | SSH algorithm | Threshold | Notes | +|---|---|---|---|---| +| `EddsaMsig` | `eddsa-msig` | `ssh-ed25519` | no | Ed25519 signatures | +| `Es256KMsig` | `es256k-msig` | `secp256k1@multisig` | no | ECDSA over secp256k1 | +| `Es256Msig` | `es256-msig` | `ecdsa-sha2-nistp256@multisig` | no | ECDSA over NIST P-256 | +| `Es384Msig` | `es384-msig` | `ecdsa-sha2-nistp384@multisig` | no | ECDSA over NIST P-384 | +| `Es521Msig` | `es521-msig` | `ecdsa-sha2-nistp521@multisig` | no | ECDSA over NIST P-521 | +| `Rs256Msig` | `rs256-msig` | `rsa-sha256@multisig` | no | RSA-SHA256 signatures | +| `Bls12381G1Msig` | `bls12_381-g1-msig` | `bls12_381-g1@multisig` | **yes** | BLS signatures on G1 (48-byte sig) | +| `Bls12381G2Msig` | `bls12_381-g2-msig` | `bls12_381-g2@multisig` | **yes** | BLS signatures on G2 (96-byte sig) | + +### Post-Quantum Signatures + +| Codec | Multicodec name | SSH | Notes | +|---|---|---|---| +| `Mldsa65Msig` | `mldsa-65-msig` | no | ML-DSA (Dilithium) security level 65; FIPS 204 | +| `Mldsa87Msig` | `mldsa-87-msig` | no | ML-DSA security level 87; FIPS 204 | +| `FnDsa512Msig` | `fn-dsa-512-msig` | no | FN-DSA (Falcon) 512; FIPS 206 (draft) | +| `FnDsa1024Msig` | `fn-dsa-1024-msig` | no | FN-DSA (Falcon) 1024; FIPS 206 (draft) | +| `Mayo1Msig` | `mayo-1-msig` | no | MAYO-1 | +| `Mayo2Msig` | `mayo-2-msig` | no | MAYO-2 | +| `Mayo3Msig` | `mayo-3-msig` | no | MAYO-3 | +| `Mayo5Msig` | `mayo-5-msig` | no | MAYO-5 | +| `SlhdsaSha2128FMsig` | `slhdsa-sha2-128f-msig` | no | SLH-DSA (SPHINCS+) SHA-2 128f; FIPS 205 | +| `SlhdsaSha2128SMsig` | `slhdsa-sha2-128s-msig` | no | SLH-DSA SHA-2 128s | +| `SlhdsaSha2192FMsig` | `slhdsa-sha2-192f-msig` | no | SLH-DSA SHA-2 192f | +| `SlhdsaSha2192SMsig` | `slhdsa-sha2-192s-msig` | no | SLH-DSA SHA-2 192s | +| `SlhdsaSha2256FMsig` | `slhdsa-sha2-256f-msig` | no | SLH-DSA SHA-2 256f | +| `SlhdsaSha2256SMsig` | `slhdsa-sha2-256s-msig` | no | SLH-DSA SHA-2 256s | +| `SlhdsaShake128FMsig` | `slhdsa-shake-128f-msig` | no | SLH-DSA SHAKE 128f | +| `SlhdsaShake128SMsig` | `slhdsa-shake-128s-msig` | no | SLH-DSA SHAKE 128s | +| `SlhdsaShake192FMsig` | `slhdsa-shake-192f-msig` | no | SLH-DSA SHAKE 192f | +| `SlhdsaShake192SMsig` | `slhdsa-shake-192s-msig` | no | SLH-DSA SHAKE 192s | +| `SlhdsaShake256FMsig` | `slhdsa-shake-256f-msig` | no | SLH-DSA SHAKE 256f | +| `SlhdsaShake256SMsig` | `slhdsa-shake-256s-msig` | no | SLH-DSA SHAKE 256s | + +### Hybrid Signatures (Classical + Post-Quantum) + +Hybrid signatures use a nested combiner construction: the classical component signs the +message, then the PQ component signs `message || classical_signature`. Verification requires +both components to pass. + +| Codec | Multicodec name | Components | SSH | +|---|---|---|---| +| `Ed25519Mayo2Msig` | `ed25519-mayo2-msig` | Ed25519 + MAYO-2 | no | +| `Ed25519Mldsa65Msig` | `ed25519-mldsa65-msig` | Ed25519 + ML-DSA-65 | no | +| `Ed25519Fndsa512Msig` | `ed25519-fndsa512-msig` | Ed25519 + FN-DSA-512 | no | +| `Bls12381G1Mldsa65Msig` | `bls12381-g1-mldsa65-msig` | BLS12-381 G1 + ML-DSA-65 | no | +| `Bls12381G1Fndsa512Msig` | `bls12381-g1-fndsa512-msig` | BLS12-381 G1 + FN-DSA-512 | no | +| `Bls12381G1Mayo1Msig` | `bls12381-g1-mayo1-msig` | BLS12-381 G1 + MAYO-1 | no | +| `Bls12381G1Mayo2Msig` | `bls12381-g1-mayo2-msig` | BLS12-381 G1 + MAYO-2 | no | + +### Threshold Signature Shares (BLS12-381) + +| Codec | Multicodec name | SSH algorithm | Notes | +|---|---|---|---| +| `Bls12381G1ShareMsig` | `bls12_381-g1-share-msig` | `bls12_381-g1-share@multisig` | A BLS G1 partial signature from a threshold share | +| `Bls12381G2ShareMsig` | `bls12_381-g2-share-msig` | `bls12_381-g2-share@multisig` | A BLS G2 partial signature from a threshold share | + +## Attribute IDs + +Each Multisig carries a set of attributes identified by a `u8` code: + +| Code | Name | Used by | Description | +|---|---|---|---| +| 0 | `sig-data` | all | The raw signature bytes | +| 1 | `payload-encoding` | all (optional) | The multicodec encoding of the signed payload | +| 2 | `scheme` | BLS | BLS scheme type: 0=Basic, 1=MessageAugmentation, 2=ProofOfPossession | +| 3 | `threshold` | BLS shares | The threshold `t` (plaintext, Full disclosure mode) | +| 4 | `limit` | BLS shares | The share count `n` (plaintext, Full/Partial disclosure modes) | +| 5 | `share-identifier` | BLS shares | 32-byte BLS scalar identifier for this share | +| 6 | `threshold-data` | BLS combined | Serialized `ThresholdData` — the accumulated share map | +| 7 | `threshold-disclosure` | BLS (optional) | Disclosure mode: 0=Full, 1=Partial, 2=FullConfidentialial | +| 8 | `encrypted-threshold-meta` | BLS (optional) | AEAD-encrypted CBOR blob containing t and/or n | +| 9 | `threshold-meta-cipher` | BLS (optional) | CBOR-encoded cipher info (codec + nonce) for decrypting #8 | + +## Views on the Multisig Data + +To provide an abstract interface to digital signatures of all schemes, this crate provides +"views" on the Multisig data. These are read-only (or copy-on-write) abstract interfaces with +implementations for different supporting signature protocols. + +### View Traits + +| Trait | Methods | Purpose | +|---|---|---| +| `AttrView` | `payload_encoding()`, `scheme()` | Access the payload encoding codec and signing scheme | +| `DataView` | `sig_bytes()` | Access the raw signature bytes | +| `ConvView` | `to_ssh_signature()` | Convert to an OpenSSH `ssh_key::Signature` | +| `ThresholdAttrView` | `threshold()`, `limit()`, `identifier()`, `threshold_data()` | Read threshold parameters (BLS only) | +| `ThresholdView` | `shares()`, `shares_with_disclosure()`, `add_share()`, `add_share_with_meta()`, `combine()`, `combine_with_meta()` | Accumulate and combine threshold signature shares (BLS only) | +| `ThresholdDisclosureView` | `disclosure_mode()`, `read_threshold_params()`, `to_disclosure()` | Read/convert the threshold disclosure mode (all codecs) | +| `Views` | `attr_view()`, `data_view()`, `conv_view()`, `threshold_attr_view()`, `threshold_view()`, `disclosure_view()` | Dispatcher trait — obtain any view from a `Multisig` | + +### View Dispatch by Codec Family + +| Codec family | `AttrView` | `DataView` | `ConvView` | `ThresholdAttrView` | `ThresholdView` | +|---|---|---|---|---|---| +| BLS G1/G2 (combined + share) | `bls12381::View` | `bls12381::View` | `bls12381::View` | `bls12381::View` | `bls12381::View` (combined only) | +| Ed25519 | `ed25519::View` | `ed25519::View` | `ed25519::View` | — | — | +| secp256k1 | `secp256k1::View` | `secp256k1::View` | `secp256k1::View` | — | — | +| NIST P-256/384/521 | `nist_p::View` | `nist_p::View` | `nist_p::View` | — | — | +| RSA | `rsa::View` | `rsa::View` | `rsa::View` | — | — | +| ML-DSA 65/87 | `ml_dsa::View` | `ml_dsa::View` | `ml_dsa::View` | — | — | +| FN-DSA 512/1024 | `fn_dsa::View` | `fn_dsa::View` | `fn_dsa::View` | — | — | +| MAYO 1/2/3/5 | `mayo::View` | `mayo::View` | `mayo::View` | — | — | +| SLH-DSA (all 12) | `slh_dsa::View` | `slh_dsa::View` | `slh_dsa::View` | — | — | +| Ed25519-MAYO2 | `ed25519_mayo2::View` | `ed25519_mayo2::View` | `ed25519_mayo2::View` | — | — | +| Other hybrids | `ed25519_hybrid::View` | `ed25519_hybrid::View` | `ed25519_hybrid::View` | — | — | + +The `disclosure_view()` method is codec-agnostic and available on all codecs. + +### Copy-on-Write Semantics + +Operations that appear to mutate the Multisig (`add_share`, `combine`, `to_disclosure`) in fact +perform a copy-on-write (CoW) operation and return a **new** `Multisig`. The original is +unchanged. This is most visible in `Builder::try_build()`: + +```rust +let mut ms = Builder::new(Codec::Bls12381G2Msig).try_build()?; for share in &shares { - multisig = { - let tv = multisig.threshold_view()?; - // this is a CoW operation returning a mutated Multisig + ms = { + let tv = ms.threshold_view()?; + // CoW — returns a new Multisig with the share added tv.add_share(share)? }; } ``` -### What about Varsig? +## Builder API + +The `Builder` constructs `Multisig` objects: + +| Method | Description | +|---|---| +| `Builder::new(codec)` | Create a builder for the given signature codec | +| `Builder::new_from_ssh_signature(&sig)` | Construct from an OpenSSH `ssh_key::Signature` | +| `Builder::new_from_bls_signature(&sig)` | Construct from a `blsful::Signature` (infers G1/G2 by byte length) | +| `Builder::new_from_bls_signature_share(t, n, &share)` | Construct from a `blsful::SignatureShare` | +| `.with_message_bytes(&msg)` | Set the message payload (makes a combined signature) | +| `.with_signature_bytes(&data)` | Set the raw signature bytes (`AttrId::SigData`) | +| `.with_payload_encoding(codec)` | Set the payload encoding codec | +| `.with_scheme(scheme_u8)` | Set the BLS scheme type (0/1/2) | +| `.with_threshold(t)` | Set the threshold value (plaintext) | +| `.with_limit(n)` | Set the limit value (plaintext) | +| `.with_identifier(&id)` | Set the share identifier (32-byte BLS scalar) | +| `.with_threshold_data(&data)` | Set the accumulated threshold data blob | +| `.with_disclosure(mode, meta_key, t, n)` | Set t/n with a specific disclosure mode (see [Threshold Confidentiality](#threshold-confidentiality)) | +| `.add_signature_share(&share)` | Accumulate a share for `try_build()` to fold in | +| `.try_build()` | Build the `Multisig` (folds in accumulated shares) | +| `.try_build_encoded()` | Build and wrap in `EncodedMultisig` (base-encoded string) | + +## Generating and Verifying Signatures + +Signature generation and verification are performed in the companion [`Multi-Key`][MULTIKEY] +crate using the `SignView` and `VerifyView` traits on a `Multikey`. The `Multikey::sign_view()` +method produces a `Multisig`, and `Multikey::verify_view()` verifies a `Multisig` against an +optional message. + +### Generating a Signature + +```rust +use multi_key::{Builder, Views}; +use multi_codec::Codec; + +// Generate an Ed25519 key and sign a message +let mk = Builder::new_from_random_bytes(Codec::Ed25519Priv, &mut rand::rng())? + .try_build()?; + +// Combined signature (carries the message in-band) +let multisig = mk.sign_view()?.sign(b"hello world", true, None)?; + +// Detached signature (message supplied out-of-band for verification) +let detached = mk.sign_view()?.sign(b"hello world", false, None)?; +``` + +### Verifying a Signature + +```rust +use multi_key::Views; + +// Verify a combined signature (message is carried in the Multisig) +mk.verify_view()?.verify(&multisig, None)?; + +// Verify a detached signature (message supplied separately) +mk.verify_view()?.verify(&detached, Some(b"hello world"))?; +``` + +### Combined vs Detached Signatures + +A Multisig is **combined** if the `message` field is non-empty — the signed message is carried +in-band and no external message is needed for verification. A Multisig is **detached** if the +`message` field is empty — the verifier must supply the original message out-of-band. + +The `combined` parameter on `SignView::sign(msg, combined, scheme)` controls this: +- `combined = true` → the message is stored in the `Multisig` (combined signature) +- `combined = false` → the message is not stored (detached signature) + +For verification, `VerifyView::verify(sig, msg)`: +- `msg = None` → uses the message stored in the Multisig (combined) +- `msg = Some(bytes)` → uses the externally supplied message (detached) + +## Threshold Signatures (BLS12-381) + +BLS12-381 is the only signature family that supports threshold signatures in this crate. A +threshold BLS signature is produced by multiple parties each signing with their key share, then +combining the partial signatures into a single combined signature that verifies against the +group public key. + +### BLS Signature Schemes + +BLS12-381 supports three signature schemes, stored as `AttrId::Scheme`: + +| Scheme | Code | Description | +|---|---|---| +| `Basic` | 0 | Raw BLS; vulnerable to rogue-key attacks without PoP checking | +| `MessageAugmentation` | 1 | Prepends a domain tag to the message before signing | +| `ProofOfPossession` | 2 | Requires a separate PoP signature over the public key; **default**; strongest rogue-key defence | + +### How Threshold Signatures Work + +1. A BLS secret key is split into `n` shares with threshold `t` using the `Multi-Key` crate's + `ThresholdView::split(t, n)` or `split_with_disclosure(t, n, mode, meta_key)`. +2. Each shareholder signs the message with their key share, producing a partial signature + (`Bls12381G1ShareMsig` or `Bls12381G2ShareMsig`). +3. The partial signatures are accumulated into a combined `Multisig` using + `ThresholdView::add_share()` (CoW) or `add_share_with_meta()`. +4. Once at least `t` shares are accumulated, `ThresholdView::combine()` (or + `combine_with_meta()`) reconstructs the combined BLS signature via Lagrange interpolation + in the group. + +### Accumulating and Combining Shares + +```rust +use multi_key::{Builder, Views}; +use multi_codec::Codec; + +// Split a BLS G2 key into 3-of-5 shares +let mk = Builder::new_from_random_bytes(Codec::Bls12381G2Priv, &mut rand::rng())? + .try_build()?; +let shares = mk.threshold_view()?.split(3, 5)?; + +// Each share signs the message (done by the shareholder) +let partial_sigs: Vec<_> = shares.iter() + .map(|s| s.sign_view()?.sign(b"message", true, Some(2)))?) // scheme 2 = PoP + .collect(); + +// Accumulate shares into a combined Multisig +let mut ms = partial_sigs[0].clone(); +for ps in &partial_sigs[1..] { + ms = ms.threshold_view()?.add_share(ps)?; +} + +// Combine into the final signature +let combined = ms.threshold_view()?.combine()?; +``` + +### SSH Round-Trip for BLS Share Signatures + +BLS share signatures can be converted to/from SSH format. The SSH algorithm names are +`bls12_381-g1-share@multisig` and `bls12_381-g2-share@multisig`. The share identifier, +threshold, and limit are carried inside the SSH signature blob. + +## Threshold Confidentiality + +By default, threshold `t` and share count `n` are stored as **plaintext** attributes on every +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`: + +### 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 `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 share 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. + +### 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()?; + +let shares = mk.threshold_view()?.split_with_disclosure(3, 5, + ThresholdDisclosure::FullConfidentialial, Some(&meta_mk))?; +``` + +**2. Builder construction:** -There already exists a multicodec signature format called Varsig but it has -some serious deficiencies in design. Here is the Varsig ["spec"][VARSIG]. The -greatest failing of Varsig is that it fails to meet [the -requirements][WHATAREMULTIFORMATS] for all Multicodec data types: +```rust +let share = Builder::new(Codec::Bls12381G2ShareMsig) + .with_disclosure(ThresholdDisclosure::Partial, Some(&meta_mk), 3, 5) + .with_identifier(&identifier) + .with_signature_bytes(&sig_bytes) + .try_build()?; +``` -* They MUST be in-band (with the value); not out-of-band (in context). -* They MUST avoid lock-in and promote extensibility. -* They MUST be compact and have a binary-packed representation. -* They MUST have a human-readable representation. +**3. Convert an existing share:** -The design of Varsig relies on out-of-band context to make sense of the -signature-specific values (see below). +```rust +let encrypted = share.disclosure_view()? + .to_disclosure(ThresholdDisclosure::FullConfidentialial, Some(&meta_mk), None)?; +``` -This new Multisig implementation uses a new multicodec sigil `0x1239` instead of -the Varsig `0x34` to distinguish the two formats. +### Reading Threshold Parameters from Encrypted Shares -The good news is that converting from Varsig to Multisig should be straight -forward if you already have code to understand a specific Varsig format. Just -pull the relevant bits of data out of the Varsig and then use the -`multisig::Builder` to construct a Multisig from the relevant parts. +Use `read_threshold_params()` with the `meta_key` to decrypt `t` and `n`: -Here's is the Varsig format as I understand it from the specification. +```rust +let (t, n) = encrypted.disclosure_view()? + .read_threshold_params(Some(&meta_mk))?; +``` -#### Varsig Format (may differ from the spec by the time you read this) +### Combining Encrypted Shares +```rust +let combined = ms.threshold_view()? + .combine_with_meta(Some(&meta_mk))?; ``` - payload encoding - key codec codec - | | - v v -0x34 N() N(OCTET) -^ ^ ^ -| | | -varsig variable number of variable number -sigil signature specific of signature data - values octets + +### 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))?; ``` -The Varsig format unfortunately has a variable number of signature-specific -values immediately following the key codec and before the encoding codec. This -makes it impossible for a tool to decode the encoding codec when it doesn't -recognize the key codec. Since there are no counts or lengths encoded in the -Varsig data, it is impossible to know the full length of any Varsig without -having complete support for every key codec. Multisig format seeks to fix that -by adding counts for the variable number of varuints and a length to the -variable number of octets (i.e. [`Varbytes`][VARBYTES]). +## Serde Serialization + +With the `serde` feature (default), `Multisig` supports dual-form serialization: + +- **Human-readable** (JSON, etc.): a struct `{ "codec": "...", "message": "...", "attributes": [...] }` + where `codec` is the multicodec name, `message` is a base-encoded `Varbytes`, and `attributes` + is a list of `(name, base-encoded-value)` tuples. +- **Compact** (binary formats): the raw wire-format bytes via `serialize_bytes`. + +`EncodedMultisig` serializes as a single base-encoded string in readable form and as raw bytes +in compact form. `AttrId` round-trips as either a name string or a `u8`. + +## Type-Safe Wrappers + +The `types` module provides newtypes for type safety: + +- `SignatureBytes(Vec)` — wraps raw signature bytes with `Display` (hex), `AsRef<[u8]>`, and + safe conversions. +- `SignatureScheme(Codec)` — wraps a `Codec` as a signature scheme identifier, `Copy`, with + `name()` and `code()` accessors. + +## What about Varsig? + +There already exists a multicodec signature format called Varsig (`0x34`) but it has serious +design deficiencies: it relies on out-of-band context for signature-specific values, making it +impossible to decode without supporting every key codec. Multisig uses a new multicodec sigil +`0x1239` to distinguish the two formats. Converting from Varsig to Multisig is straightforward: +pull the relevant data out of the Varsig and use the `Builder` to construct a Multisig. + +## Cargo Features + +| Feature | Default | Description | +|---|---|---| +| `serde` | yes | Serde serialization for `Multisig` and `AttrId` | + +## Links + +- [Cryptid Technologies][CRYPTID] +- [Provenance Specifications][PROVENANCE] +- [Multiformats][MULTIFORMATS] +- [Multisig Specification][MULTISIG] +- [Multi-Key crate][MULTIKEY] +- [`ssh-key` crate][SSHKEY] +- [RFC 4251][RFC4251] [CRYPTID]: https://cryptid.tech [PROVENANCE]: https://github.com/cryptidtech/provenance-specifications/ @@ -136,7 +508,4 @@ variable number of octets (i.e. [`Varbytes`][VARBYTES]). [MULTISIG]: https://github.com/cryptidtech/provenance-specifications/blob/main/specifications/multisig.md [SSHKEY]: https://crates.io/crates/ssh-key [RFC4251]: https://www.rfc-editor.org/rfc/rfc4251.html#page-11 -[MULTIKEY]: https://github.com/cryptidtech/multikey.git -[VARSIG]: https://github.com/ChainAgnostic/varsig -[WHATAREMULTIFORMATS]: https://multiformats.io/#what-are-multiformats -[VARBYTES]: https://github.com/cryptidtech/multiutil/blob/main/src/varbytes.rs +[MULTIKEY]: https://github.com/cryptidtech/multi-key.git \ No newline at end of file diff --git a/src/attrid.rs b/src/attrid.rs index c8d2f6c..f80de11 100644 --- a/src/attrid.rs +++ b/src/attrid.rs @@ -23,6 +23,12 @@ pub enum AttrId { ShareIdentifier, /// codec-specific threshold signature data ThresholdData, + /// Threshold disclosure mode (varuint u8): 0=Full, 1=Partial, 2=FullConfidentialial. + ThresholdDisclosure, + /// AEAD-encrypted threshold metadata CBOR blob. + EncryptedThresholdMeta, + /// CBOR-encoded cipher info (codec + nonce) for decrypting EncryptedThresholdMeta. + ThresholdMetaCipher, } impl AttrId { @@ -41,6 +47,9 @@ impl AttrId { Self::Limit => "limit", Self::ShareIdentifier => "share-identifier", Self::ThresholdData => "threshold-data", + Self::ThresholdDisclosure => "threshold-disclosure", + Self::EncryptedThresholdMeta => "encrypted-threshold-meta", + Self::ThresholdMetaCipher => "threshold-meta-cipher", } } } @@ -63,6 +72,9 @@ impl TryFrom for AttrId { 4 => Ok(Self::Limit), 5 => Ok(Self::ShareIdentifier), 6 => Ok(Self::ThresholdData), + 7 => Ok(Self::ThresholdDisclosure), + 8 => Ok(Self::EncryptedThresholdMeta), + 9 => Ok(Self::ThresholdMetaCipher), _ => Err(AttributesError::InvalidAttributeValue(c).into()), } } @@ -104,6 +116,9 @@ impl TryFrom<&str> for AttrId { "limit" => Ok(Self::Limit), "share-identifier" => Ok(Self::ShareIdentifier), "threshold-data" => Ok(Self::ThresholdData), + "threshold-disclosure" => Ok(Self::ThresholdDisclosure), + "encrypted-threshold-meta" => Ok(Self::EncryptedThresholdMeta), + "threshold-meta-cipher" => Ok(Self::ThresholdMetaCipher), _ => Err(AttributesError::InvalidAttributeName(s.to_string()).into()), } } diff --git a/src/error.rs b/src/error.rs index 5edfe2a..a4dd854 100644 --- a/src/error.rs +++ b/src/error.rs @@ -71,7 +71,7 @@ pub enum AttributesError { #[error("Signature missing threshold")] MissingThreshold, /// No limit attribute - #[error("Signature missing limi")] + #[error("Signature missing limit")] MissingLimit, /// No identifier attribute #[error("Signature missing identifier")] @@ -121,6 +121,26 @@ pub enum SharesError { /// Not enough shares to reconstruct the siganture #[error("Not enough shares to reconstruct the signature")] NotEnoughShares, + /// 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 + #[error("Duplicate share identifier")] + DuplicateShare, + /// Invalid or corrupted threshold data + #[error("Invalid threshold data: {0}")] + InvalidThresholdData(String), } /// Conversion errors diff --git a/src/lib.rs b/src/lib.rs index d8471f0..303e821 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -85,7 +85,10 @@ pub use types::{SignatureBytes, SignatureScheme}; /// Views on the multisig pub mod views; -pub use views::{AttrView, ConvView, DataView, ThresholdAttrView, ThresholdView, Views}; +pub use views::{ + AttrView, ConvView, DataView, ThresholdAttrView, ThresholdDisclosureView, ThresholdView, + Views, +}; /// Serde serialization #[cfg(feature = "serde")] diff --git a/src/ms.rs b/src/ms.rs index 9afb25a..5a41123 100644 --- a/src/ms.rs +++ b/src/ms.rs @@ -4,7 +4,7 @@ use crate::{ views::{ bls12381::{self, SchemeTypeId}, ed25519, ed25519_hybrid, ed25519_mayo2, fn_dsa, mayo, ml_dsa, nist_p, rsa, secp256k1, - slh_dsa, + slh_dsa, threshold_meta, DisclosureView, ThresholdDisclosureView, }, AttrId, AttrView, ConvView, DataView, Error, ThresholdAttrView, ThresholdView, Views, }; @@ -353,6 +353,11 @@ impl Views for Multisig { _ => Err(AttributesError::UnsupportedCodec(self.codec).into()), } } + + /// Provide an interface for threshold disclosure mode operations + fn disclosure_view<'a>(&'a self) -> Result, Error> { + Ok(Box::new(DisclosureView::new(self))) + } } /// Builder for Multisigs @@ -564,6 +569,31 @@ impl Builder { self.with_attribute(AttrId::ThresholdData, &tdata.as_ref().to_vec()) } + /// Set the disclosure mode for a threshold sig share being built. + /// + /// In Full mode, t and n are plaintext. In Partial/FullConfidentialial, + /// `meta_key` is required. + pub fn with_disclosure( + self, + mode: multi_key::ThresholdDisclosure, + meta_key: Option<&multi_key::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 + } + } + /// add a signature share pub fn add_signature_share(mut self, share: &Multisig) -> Self { let mut shares = self.shares.unwrap_or_default(); diff --git a/src/views.rs b/src/views.rs index c3be6c2..9c81b46 100644 --- a/src/views.rs +++ b/src/views.rs @@ -1,6 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 use crate::{Error, Multisig}; use multi_codec::Codec; +use multi_key::ThresholdDisclosure; /// BLS12 381 G1/G2 signature implementation pub mod bls12381; @@ -24,6 +25,9 @@ pub mod rsa; pub mod secp256k1; /// SLH-DSA post-quantum signature implementation; FIPS 205 pub mod slh_dsa; +/// Threshold disclosure modes and encrypted metadata helpers. +pub mod threshold_meta; +pub use threshold_meta::{DisclosureView, disclosure_mode, read_threshold_params, stamp_disclosure_attrs}; /// /// Attributes views let you inquire about the Multisig and retrieve data @@ -65,10 +69,45 @@ pub trait ThresholdAttrView { pub trait ThresholdView { /// get the signature shares from this multisig fn shares(&self) -> Result, Error>; + /// get the signature shares with a specific disclosure mode applied + fn shares_with_disclosure( + &self, + mode: ThresholdDisclosure, + meta_key: Option<&multi_key::Multikey>, + ) -> Result, Error>; /// add a new share and return the Multisig with the share added fn add_share(&self, share: &Multisig) -> Result; + /// add a share with a meta_key for decrypting threshold params + fn add_share_with_meta( + &self, + share: &Multisig, + meta_key: Option<&multi_key::Multikey>, + ) -> Result; /// reconstruct the signature from the shares fn combine(&self) -> Result; + /// combine with a meta_key for decrypting threshold params + fn combine_with_meta( + &self, + meta_key: Option<&multi_key::Multikey>, + ) -> Result; +} + +/// trait for threshold disclosure mode operations on a Multisig +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<&multi_key::Multikey>, + ) -> Result<(usize, usize), Error>; + /// Convert to a target disclosure mode. + fn to_disclosure( + &self, + target: ThresholdDisclosure, + meta_key: Option<&multi_key::Multikey>, + current_meta_key: Option<&multi_key::Multikey>, + ) -> Result; } /// trait for getting the other views @@ -83,4 +122,6 @@ pub trait Views { fn threshold_attr_view<'a>(&'a self) -> Result, Error>; /// Provide the view for adding a share to a multisig fn threshold_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/bls12381.rs b/src/views/bls12381.rs index a827f48..393ea34 100644 --- a/src/views/bls12381.rs +++ b/src/views/bls12381.rs @@ -1,9 +1,11 @@ // SPDX-License-Identifier: Apache-2.0 use crate::{ error::{AttributesError, ConversionsError, SharesError}, + views::threshold_meta, AttrId, AttrView, Builder, ConvView, DataView, Error, Multisig, ThresholdAttrView, ThresholdView, Views, }; +use multi_key::ThresholdDisclosure; use blsful::{ inner_types::{G1Projective, G2Projective, Scalar}, vsss_rs::{IdentifierPrimeField, Share, ValueGroup}, @@ -797,4 +799,211 @@ impl<'a> ThresholdView for View<'a> { _ => Err(Error::UnsupportedAlgorithm(self.ms.codec.to_string())), } } + + /// Get shares with a specific disclosure mode applied. + fn shares_with_disclosure( + &self, + mode: ThresholdDisclosure, + meta_key: Option<&multi_key::Multikey>, + ) -> Result, Error> { + let shares = self.shares()?; + 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: &Multisig, + meta_key: Option<&multi_key::Multikey>, + ) -> Result { + let (share_t, share_n) = + threshold_meta::read_threshold_params(share, meta_key)?; + + let (sdata, identifier, encoding) = { + let av = share.attr_view()?; + let scheme_type = SchemeTypeId::try_from(av.scheme()?)?; + let tav = share.threshold_attr_view()?; + let identifier_bytes = tav.identifier()?; + if identifier_bytes.len() != 32 { + return Err(Error::FailedConversion( + "Insufficient number of identifier bytes".to_string(), + )); + } + let identifier_array = <[u8; 32]>::try_from(identifier_bytes) + .map_err(|_| Error::FailedConversion("Incorrect identifier bytes".to_string()))?; + let identifier = IdentifierPrimeField( + Option::::from(Scalar::from_be_bytes(&identifier_array)).ok_or( + Error::FailedConversion("Incorrect identifier bytes".to_string()), + )?, + ); + let dv = share.data_view()?; + let sig_bytes = dv.sig_bytes()?; + let encoding = { + let av = self.ms.attr_view()?; + av.payload_encoding().ok() + }; + ( + SigShare(identifier, share_t, share_n, scheme_type, sig_bytes), + identifier, + encoding, + ) + }; + + let threshold_data: Vec = { + let av = self.ms.threshold_attr_view()?; + let mut tdata = match av.threshold_data() { + Ok(b) => ThresholdData::try_from(b) + .map_err(|e| SharesError::InvalidThresholdData(e.to_string()))?, + Err(_) => ThresholdData::default(), + }; + if tdata.0.contains_key(&identifier) { + return Err(SharesError::DuplicateShare.into()); + } + tdata.0.insert(identifier, sdata); + tdata.into() + }; + + let encoding = { + let av = self.ms.attr_view()?; + match av.payload_encoding() { + Ok(encoding) => Some(encoding), + Err(_) => encoding, + } + }; + + let builder = Builder::new(self.ms.codec) + .with_message_bytes(&self.ms.message.as_slice()) + .with_threshold(share_t) + .with_limit(share_n) + .with_threshold_data(&threshold_data); + + if let Some(encoding) = encoding { + builder.with_payload_encoding(encoding).try_build() + } else { + builder.try_build() + } + } + + /// Combine with a meta_key for decrypting threshold params. + fn combine_with_meta( + &self, + meta_key: Option<&multi_key::Multikey>, + ) -> Result { + let (threshold, _limit) = + threshold_meta::read_threshold_params(self.ms, meta_key)?; + + let threshold_data = { + let av = self.ms.threshold_attr_view()?; + match av.threshold_data() { + Ok(b) => ThresholdData::try_from(b) + .map_err(|e| SharesError::InvalidThresholdData(e.to_string()))?, + Err(_) => ThresholdData::default(), + } + }; + + let num_shares = threshold_data.0.len(); + if num_shares < threshold { + return Err(SharesError::NotEnoughShares.into()); + } + + match self.ms.codec { + Codec::Bls12381G1Msig => { + let mut share_type_id: Option = None; + let mut shares = Vec::default(); + threshold_data + .0 + .iter() + .try_for_each(|(id, share)| -> Result<(), Error> { + let bytes: [u8; 48] = share.4.as_slice().try_into().map_err(|_| { + Error::FailedConversion("Invalid signature share bytes".to_string()) + })?; + let inner = Option::from(G1Projective::from_compressed(&bytes)).ok_or( + Error::FailedConversion("Invalid signature share bytes".to_string()), + )?; + let vsss = Share::with_identifier_and_value(*id, ValueGroup(inner)); + if let Some(sti) = share_type_id { + if sti != share.3 { + return Err(SharesError::ShareTypeMismatch.into()); + } + } else { + share_type_id = Some(share.3); + } + let s = match share.3 { + SchemeTypeId::Basic => SignatureShare::::Basic(vsss), + SchemeTypeId::MessageAugmentation => { + SignatureShare::::MessageAugmentation(vsss) + } + SchemeTypeId::ProofOfPossession => { + SignatureShare::::ProofOfPossession(vsss) + } + }; + shares.push(s); + Ok(()) + })?; + + let sig = Signature::from_shares(shares.as_slice()) + .map_err(|e| SharesError::ShareCombineFailed(e.to_string()))?; + let encoding = { + let av = self.ms.attr_view()?; + av.payload_encoding()? + }; + Builder::new_from_bls_signature(&sig)? + .with_message_bytes(&self.ms.message.as_slice()) + .with_payload_encoding(encoding) + .try_build() + } + Codec::Bls12381G2Msig => { + let mut share_type_id: Option = None; + let mut shares = Vec::default(); + threshold_data + .0 + .iter() + .try_for_each(|(id, share)| -> Result<(), Error> { + let bytes: [u8; 96] = share.4.as_slice().try_into().map_err(|_| { + Error::FailedConversion("Invalid signature share bytes".to_string()) + })?; + let inner = Option::from(G2Projective::from_compressed(&bytes)).ok_or( + Error::FailedConversion("Invalid signature share bytes".to_string()), + )?; + let vsss = Share::with_identifier_and_value(*id, ValueGroup(inner)); + if let Some(sti) = share_type_id { + if sti != share.3 { + return Err(SharesError::ShareTypeMismatch.into()); + } + } else { + share_type_id = Some(share.3); + } + let s = match share.3 { + SchemeTypeId::Basic => SignatureShare::::Basic(vsss), + SchemeTypeId::MessageAugmentation => { + SignatureShare::::MessageAugmentation(vsss) + } + SchemeTypeId::ProofOfPossession => { + SignatureShare::::ProofOfPossession(vsss) + } + }; + shares.push(s); + Ok(()) + })?; + + let sig = Signature::from_shares(shares.as_slice()) + .map_err(|e| SharesError::ShareCombineFailed(e.to_string()))?; + let encoding = { + let av = self.ms.attr_view()?; + av.payload_encoding()? + }; + Builder::new_from_bls_signature(&sig)? + .with_message_bytes(&self.ms.message.as_slice()) + .with_payload_encoding(encoding) + .try_build() + } + _ => Err(Error::UnsupportedAlgorithm(self.ms.codec.to_string())), + } + } } diff --git a/src/views/threshold_meta.rs b/src/views/threshold_meta.rs new file mode 100644 index 0000000..b9a965d --- /dev/null +++ b/src/views/threshold_meta.rs @@ -0,0 +1,226 @@ +// SPDX-License-Identifier: Apache-2.0 +//! Threshold disclosure helpers for Multisig. +//! +//! Re-exports the disclosure types from `multi_key` and provides +//! Multisig-specific helpers for reading/converting disclosure modes. + +use crate::{AttrId, Error, Multisig}; +use multi_key::{self, ThresholdDisclosure, ThresholdMetaCipher, ThresholdMetadata, + decrypt_threshold_meta, encrypt_threshold_meta, Views as MultikeyViews}; +use multi_trait::{EncodeInto, TryDecodeFrom}; +use multi_util::Varuint; +use zeroize::Zeroizing; + +/// Read the disclosure mode from a Multisig. Returns Full if no attribute is present. +pub fn disclosure_mode(ms: &Multisig) -> Result { + match ms.attributes.get(&AttrId::ThresholdDisclosure) { + Some(v) => { + let (mode, _) = ThresholdDisclosure::try_decode_from(v.as_slice()) + .map_err(|e| Error::Shares(crate::error::SharesError::MetaEncryption(e.to_string())))?; + Ok(mode) + } + None => Ok(ThresholdDisclosure::Full), + } +} + +/// Read t and n from a Multisig, decrypting if necessary. +pub fn read_threshold_params( + ms: &Multisig, + meta_key: Option<&multi_key::Multikey>, +) -> Result<(usize, usize), Error> { + let mode = disclosure_mode(ms)?; + match mode { + ThresholdDisclosure::Full => { + let t = ms + .attributes + .get(&AttrId::Threshold) + .ok_or(crate::error::AttributesError::MissingThreshold)?; + let n = ms + .attributes + .get(&AttrId::Limit) + .ok_or(crate::error::AttributesError::MissingLimit)?; + let t = Varuint::::try_from(t.as_slice()) + .map_err(Error::Multiutil)? + .to_inner(); + let n = Varuint::::try_from(n.as_slice()) + .map_err(Error::Multiutil)? + .to_inner(); + Ok((t, n)) + } + ThresholdDisclosure::Partial => { + let n = ms + .attributes + .get(&AttrId::Limit) + .ok_or(crate::error::AttributesError::MissingLimit)?; + let n = Varuint::::try_from(n.as_slice()) + .map_err(Error::Multiutil)? + .to_inner(); + + let encrypted = ms + .attributes + .get(&AttrId::EncryptedThresholdMeta) + .ok_or(crate::error::SharesError::MetaEncryption( + "missing EncryptedThresholdMeta".to_string(), + ))?; + let cipher_info_bytes = ms + .attributes + .get(&AttrId::ThresholdMetaCipher) + .ok_or(crate::error::SharesError::MetaEncryption( + "missing ThresholdMetaCipher".to_string(), + ))?; + let cipher_info = ThresholdMetaCipher::from_cbor_bytes(cipher_info_bytes) + .map_err(|e| Error::Shares(crate::error::SharesError::MetaEncryption(e.to_string())))?; + + let meta_key = meta_key.ok_or(crate::error::SharesError::MissingMetaKey)?; + let key = extract_meta_key(meta_key)?; + + let meta = decrypt_threshold_meta(encrypted, &cipher_info, &key) + .map_err(|e| Error::Shares(crate::error::SharesError::MetaEncryption(e.to_string())))?; + let t = meta.threshold.ok_or(crate::error::SharesError::MetaEncryption( + "threshold not in encrypted metadata".to_string(), + ))? as usize; + Ok((t, n)) + } + ThresholdDisclosure::FullConfidentialial => { + let encrypted = ms + .attributes + .get(&AttrId::EncryptedThresholdMeta) + .ok_or(crate::error::SharesError::MetaEncryption( + "missing EncryptedThresholdMeta".to_string(), + ))?; + let cipher_info_bytes = ms + .attributes + .get(&AttrId::ThresholdMetaCipher) + .ok_or(crate::error::SharesError::MetaEncryption( + "missing ThresholdMetaCipher".to_string(), + ))?; + let cipher_info = ThresholdMetaCipher::from_cbor_bytes(cipher_info_bytes) + .map_err(|e| Error::Shares(crate::error::SharesError::MetaEncryption(e.to_string())))?; + + let meta_key = meta_key.ok_or(crate::error::SharesError::MissingMetaKey)?; + let key = extract_meta_key(meta_key)?; + + let meta = decrypt_threshold_meta(encrypted, &cipher_info, &key) + .map_err(|e| Error::Shares(crate::error::SharesError::MetaEncryption(e.to_string())))?; + let t = meta.threshold.ok_or(crate::error::SharesError::MetaEncryption( + "threshold not in encrypted metadata".to_string(), + ))? as usize; + let n = meta.limit.ok_or(crate::error::SharesError::MetaEncryption( + "limit not in encrypted metadata".to_string(), + ))? as usize; + Ok((t, n)) + } + _ => Err(Error::Shares(crate::error::SharesError::MetaEncryption( + format!("unsupported disclosure mode: {mode}"), + ))), + } +} + +/// Extract a 32-byte key from a Multikey containing a symmetric cipher key. +fn extract_meta_key(meta_key: &multi_key::Multikey) -> Result>, Error> { + let dv = meta_key.data_view() + .map_err(|e| Error::Shares(crate::error::SharesError::MetaEncryption(e.to_string())))?; + let key = dv.key_bytes() + .map_err(|e| Error::Shares(crate::error::SharesError::MetaEncryption(e.to_string())))?; + if key.len() != 32 { + return Err(Error::Shares(crate::error::SharesError::MetaEncryption( + format!("meta key must be 32 bytes, got {}", key.len()), + ))); + } + Ok(key) +} + +/// Stamp disclosure attributes onto a Multisig's attribute map. +pub fn stamp_disclosure_attrs( + attributes: &mut std::collections::BTreeMap>, + mode: ThresholdDisclosure, + threshold: usize, + limit: usize, + meta_key: Option<&multi_key::Multikey>, +) -> Result<(), Error> { + use crate::error::SharesError; + + 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); + attributes.insert(AttrId::Limit, n_bytes); + attributes.insert(AttrId::ThresholdDisclosure, mode.encode_into()); + } + ThresholdDisclosure::Partial => { + let meta_key = meta_key.ok_or(SharesError::MissingMetaKey)?; + let key = extract_meta_key(meta_key)?; + + let n_bytes: Vec = Varuint(limit).into(); + attributes.insert(AttrId::Limit, n_bytes); + + let meta = ThresholdMetadata::threshold_only(threshold as u16); + let (ciphertext, cipher_info) = encrypt_threshold_meta(&meta, &key) + .map_err(|e| Error::Shares(SharesError::MetaEncryption(e.to_string())))?; + attributes.insert(AttrId::EncryptedThresholdMeta, ciphertext); + attributes.insert(AttrId::ThresholdMetaCipher, cipher_info.to_cbor_bytes() + .map_err(|e| Error::Shares(SharesError::MetaEncryption(e.to_string())))?); + attributes.insert(AttrId::ThresholdDisclosure, mode.encode_into()); + } + ThresholdDisclosure::FullConfidentialial => { + let meta_key = meta_key.ok_or(SharesError::MissingMetaKey)?; + let key = extract_meta_key(meta_key)?; + + let meta = ThresholdMetadata::new(threshold as u16, limit as u16); + let (ciphertext, cipher_info) = encrypt_threshold_meta(&meta, &key) + .map_err(|e| Error::Shares(SharesError::MetaEncryption(e.to_string())))?; + attributes.insert(AttrId::EncryptedThresholdMeta, ciphertext); + attributes.insert(AttrId::ThresholdMetaCipher, cipher_info.to_cbor_bytes() + .map_err(|e| Error::Shares(SharesError::MetaEncryption(e.to_string())))?); + attributes.insert(AttrId::ThresholdDisclosure, mode.encode_into()); + } + _ => return Err(Error::Shares(SharesError::MetaEncryption( + format!("unsupported disclosure mode: {mode}"), + ))), + } + Ok(()) +} + +/// The `ThresholdDisclosureView` implementation for BLS Multisig. +pub struct DisclosureView<'a> { + ms: &'a Multisig, +} + +impl<'a> DisclosureView<'a> { + /// Create a disclosure view over a Multisig. + pub fn new(ms: &'a Multisig) -> Self { + Self { ms } + } +} + +impl<'a> crate::views::ThresholdDisclosureView for DisclosureView<'a> { + fn disclosure_mode(&self) -> Result { + disclosure_mode(self.ms) + } + + fn read_threshold_params( + &self, + meta_key: Option<&multi_key::Multikey>, + ) -> Result<(usize, usize), Error> { + read_threshold_params(self.ms, meta_key) + } + + fn to_disclosure( + &self, + target: ThresholdDisclosure, + meta_key: Option<&multi_key::Multikey>, + current_meta_key: Option<&multi_key::Multikey>, + ) -> Result { + let (t, n) = read_threshold_params(self.ms, current_meta_key)?; + let mut new_ms = self.ms.clone(); + stamp_disclosure_attrs(&mut new_ms.attributes, target, t, n, meta_key)?; + Ok(new_ms) + } +} \ No newline at end of file