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: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,6 @@ Cargo.lock

/target
/Cargo.lock

plan.md
session.json
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,22 @@ All notable changes to this project are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [2.1.0] - 2026-10-07

### Added

- `ViewBuilder` (`views::builder`): builder-pattern view creation with a fluent kind selector and a `.build()` terminal, re-exported at the crate root and in the prelude. Standard codecs dispatch to the built-in views. Custom protocol keys (codec `Codec::Identity` with `AlgorithmName`/`KeyType` attributes) dispatch to caller-supplied local-codec factories on fallthrough, per cryptidtech/multi-key#6.
- `Builder::with_algorithm_name(name)` and `Builder::with_key_type(key_type)` stamp the `AlgorithmName` (27) and `KeyType` (28) attributes for custom keys, following the byte conventions from cryptidtech/provenance-specifications#4.

### Changed

- The `cipher_view`, `kdf_view`, `cipher_attr_view`, and `kdf_attr_view` fallthrough errors now report the codec that actually lacks support (the second key's codec or the attribute-derived codec) instead of the multikey's own codec. Error variants are unchanged.
- Added an exact `pkcs1 = "=0.8.0-rc.4"` dependency pin. `sad-rsa 0.2.3` does not compile against `pkcs1 0.8.0-rc.5` (published 2026-10-05), so a fresh dependency resolve fails. The pin forces rc.4 until sad-rsa ships a compatible release.

### Deprecated

- The `Views` extension trait and `impl Views for Multikey`. Source-compatible in this release. Use `ViewBuilder` instead. Both will be removed in the next major release. Note: `multi_sig::Views` is a different, unrelated trait.

## [2.0.0] - 2026-09-14

### Added
Expand Down Expand Up @@ -243,6 +259,7 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
- Major dependency updates: ed25519-dalek 3, blsful 4, elliptic-curve 0.14, vsss-rs 6, ssh-key 0.7.
- Initial published release on crates.io as `multi-key`.

[2.1.0]: https://github.com/cryptidtech/multi-key/compare/v2.0.0...v2.1.0
[1.1.1]: https://github.com/cryptidtech/multi-key/compare/v1.1.0...v1.1.1
[1.2.2]: https://github.com/cryptidtech/multi-key/compare/v1.2.1...v1.2.2
[1.2.1]: https://github.com/cryptidtech/multi-key/compare/v1.2.0...v1.2.1
Expand Down
9 changes: 8 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "multi-key"
version = "2.0.0"
version = "2.1.0"
edition = "2024"
rust-version = "1.96"
authors = ["Dave Grantham <dwg@linuxprogrammer.org>"]
Expand Down Expand Up @@ -60,6 +60,13 @@ multi-util = "1.1"
p256 = { version = "0.14", features = ["ecdsa", "ecdh"] }
p384 = { version = "0.14", features = ["ecdsa", "ecdh"] }
p521 = { version = "0.14", features = ["ecdsa", "ecdh"] }
# Compatibility pin. sad-rsa 0.2.3 (the newest release of its line) cannot
# compile against pkcs1 0.8.0-rc.5, which changed `RsaPrivateKey` and
# `RsaPublicKey` from lifetime parameters to generic parameters. sad-rsa
# permits `pkcs1 ^0.8.0-rc.4`, so a fresh resolve picks rc.5 and fails. Pin
# the exact rc.4 dependency to force it; remove this entry once sad-rsa
# publishes a rc.5-compatible release.
pkcs1 = "=0.8.0-rc.4"
poly1305 = "0.9"
pq-mayo = "0.6.0"
rand = "0.10"
Expand Down
113 changes: 103 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,98 @@ Two additional modules provide threshold functionality outside the view traits:

Operations that seem to mutate the Multi-Key (for example, encrypt, decrypt, convert) in fact do a copy-on-write (CoW) operation. They return a new Multi-Key with the mutation applied.

### Creating Views

Views are created through the builder-pattern `multi_key::ViewBuilder`
(re-exported at the crate root and in the prelude). Select a view kind
fluently, then build:

```rust
use multi_key::{Builder, ViewBuilder};
use multi_codec::Codec;

let mut rng = rand::rng();
let mk = Builder::new_from_random_bytes(Codec::Ed25519Priv, &mut rng)
.unwrap()
.try_build()
.unwrap();

// sign a message and verify it
let signer = ViewBuilder::new(&mk).sign().build().unwrap();
let sig = signer.sign(b"message", false, None).unwrap();
let verifier = ViewBuilder::new(&mk).verify().build().unwrap();
verifier.verify(&sig, Some(b"message")).unwrap();
```

The 15 kinds that need no second key all follow this shape: `attr`,
`cipher_attr`, `data`, `kdf_attr`, `threshold_attr`, `threshold_key`,
`conv`, `fingerprint`, `sign`, `verify`, `seal`, `open`, `threshold`,
`disclosure`, and `merkle_state`. The `cipher` and `kdf` kinds attach the
second key their dispatch reads. A view borrows its Multikey, so keep the
Multikey alive while the view is in use:

```rust
// encrypt/decrypt the Multi-Key at rest
let encrypter = ViewBuilder::new(&mk).cipher(&cipher_key).build()?;
let encrypted = encrypter.encrypt()?;

// derive a cipher key from a passphrase
let kdf = ViewBuilder::new(&mk).kdf(&kdf_key).build()?;
let derived = kdf.derive_key(b"passphrase")?;
```

The original `multi_key::Views` extension trait is deprecated since 2.1.0
in favor of `ViewBuilder`. It stays as a source-compatible delegating shim
with the same `*_view()` methods, marked as deprecated at compile time.
Note that `multi_sig::Views` is a different, unrelated trait.

### Custom Protocol Keys

A custom protocol key (sigil-0) carries the `Codec::Identity` codec with the `AlgorithmName` attribute (code 27, the protocol name) and the `KeyType` attribute (code 28, one raw byte) from [provenance-specifications][PROVENANCE]. The `KeyType` byte convention is absent or 0 for public keys and 1 for secret keys. Custom keys have no built-in views, so `ViewBuilder` dispatches them to caller-supplied local-codec factories registered with `with_local_codec`. Different custom protocols share the `Codec::Identity` codec, so a factory identifies the key's protocol through the `AlgorithmName` attribute (not through the codec):

```rust
use multi_key::{AttrId, Builder, Error, SignView, ViewBuilder};
use multi_codec::Codec;
use multi_sig::Multisig;

let mk = Builder::new(Codec::Identity)
.with_key_bytes(b"my-custom-key-material".as_slice())
.with_algorithm_name("my-protocol")
.with_key_type(1) // 1 = secret key
.try_build()
.unwrap();

struct MyProtocolSign {
algorithm: String,
}

impl SignView for MyProtocolSign {
fn sign(&self, _: &[u8], _: bool, _: Option<u8>) -> Result<Multisig, Error> {
Err(Error::UnsupportedAlgorithm(format!("{} sign", self.algorithm)))
}
}

// the factory builds a MyProtocolSign for the key it is handed. a real
// factory for several protocols sharing Identity reads the AlgorithmName
// attribute from the passed-in key and branches on it, serving only its
// own protocol.
let signer = ViewBuilder::new(&mk)
.sign()
.with_local_codec(Codec::Identity, |key| {
let name = key
.attributes
.get(&AttrId::AlgorithmName)
.ok_or_else(|| Error::UnsupportedAlgorithm("AlgorithmName missing".into()))?;
Ok(Box::new(MyProtocolSign {
algorithm: String::from_utf8_lossy(name).into_owned(),
}))
})
.build()
.unwrap();
```

Built-in views always win: a factory registered for a standard codec whose built-in view exists is never consulted. A repeat `with_local_codec` call for the same kind and codec replaces the earlier factory. Factory errors propagate unchanged. The `disclosure` kind is codec-independent (its view applies to every key), so a factory registered for that kind is never consulted.

## SSH Key Conversions

This crate converts to and from the SSH key format with the [`ssh-key`][SSHKEY] crate. Standard SSH algorithms are handled natively. Non-standard algorithms use the [RFC 4251][RFC4251] "additional algorithms" mechanism with an `ssh_key::Algorithm::Other` opaque key and an algorithm name ending in the literal `@multikey` suffix. This is a wire-format identifier, distinct from the crate name.
Expand Down Expand Up @@ -263,16 +355,17 @@ 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};
use multi_key::{Builder, ViewBuilder, 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))?;
let shares = ViewBuilder::new(&mk).threshold().build()?
.split_with_disclosure(3, 5,
ThresholdDisclosure::FullConfidentialial, Some(&meta_mk))?;
```

**2. Builder construction:**
Expand All @@ -288,7 +381,7 @@ let share = Builder::new(Codec::Bls12381G2PrivShare)
**3. Convert an existing share:**

```rust
let encrypted = share.disclosure_view()?
let encrypted = ViewBuilder::new(&share).disclosure().build()?
.to_disclosure(ThresholdDisclosure::FullConfidentialial, Some(&meta_mk), None)?;
```

Expand All @@ -297,14 +390,14 @@ let encrypted = share.disclosure_view()?
Use `read_threshold_params()` with the `meta_key` to decrypt `t` and `n`:

```rust
let (t, n) = encrypted.disclosure_view()?
let (t, n) = ViewBuilder::new(&encrypted).disclosure().build()?
.read_threshold_params(Some(&meta_mk))?;
```

### Combining Encrypted Shares

```rust
let combined = mk.threshold_view()?
let combined = ViewBuilder::new(&mk).threshold().build()?
.combine_with_meta(Some(&meta_mk))?;
```

Expand All @@ -324,15 +417,15 @@ The `to_disclosure()` method converts between any pair of modes. It reads the cu

```rust
// Full → Partial
let partial = full.disclosure_view()?
let partial = ViewBuilder::new(&full).disclosure().build()?
.to_disclosure(ThresholdDisclosure::Partial, Some(&meta_mk), None)?;

// Partial → FullConfidentialial
let confidential = partial.disclosure_view()?
let confidential = ViewBuilder::new(&partial).disclosure().build()?
.to_disclosure(ThresholdDisclosure::FullConfidentialial, Some(&meta_mk), Some(&meta_mk))?;

// FullConfidentialial → Full
let full_again = confidential.disclosure_view()?
let full_again = ViewBuilder::new(&confidential).disclosure().build()?
.to_disclosure(ThresholdDisclosure::Full, None, Some(&meta_mk))?;
```

Expand Down Expand Up @@ -366,7 +459,7 @@ Individual KEM views may restrict the allowed AEAD codec. For example, X25519-ML

- Private keys are wrapped in `Zeroizing` buffers and automatically zeroized on drop.
- `Debug` output for private key material is redacted.
- All views are thread-safe (`Send` + `Sync`) for concurrent operations.
- The `ViewBuilder` is `Send` + `Sync` (with and without registered factories). The view trait objects it returns carry no `Send`/`Sync` supertrait, so they do not carry that bound at the type level.
- Mutation operations use copy-on-write semantics. They return a new `Multi-Key` rather than mutating in place.

## Links
Expand Down
14 changes: 7 additions & 7 deletions src/cipher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ impl Builder {
#[cfg(test)]
mod tests {
use super::*;
use crate::{Views, kdf, mk};
use crate::{ViewBuilder, kdf, mk};

#[test]
fn test_chacha20() {
Expand All @@ -104,7 +104,7 @@ mod tests {
.unwrap();

// get the kdf view on the kdf multikey
let kdf = ciphermk.kdf_view(&kdfmk).unwrap();
let kdf = ViewBuilder::new(&ciphermk).kdf(&kdfmk).build().unwrap();

// derive a key for the cipher multikey to use
let ciphermk = kdf
Expand All @@ -120,24 +120,24 @@ mod tests {
.unwrap();

// get the cipher view on the multikey
let cipher = mk.cipher_view(&ciphermk).unwrap();
let cipher = ViewBuilder::new(&mk).cipher(&ciphermk).build().unwrap();

// encrypt the secret key
let mk = cipher.encrypt().unwrap();

// make sure all of the attributes are right
let attr = mk.attr_view().unwrap();
let attr = ViewBuilder::new(&mk).attr().build().unwrap();
assert!(attr.is_encrypted());
assert!(!attr.is_public_key());
assert!(attr.is_secret_key());
let kd = mk.data_view().unwrap();
let kd = ViewBuilder::new(&mk).data().build().unwrap();
assert!(kd.key_bytes().is_ok());
assert!(kd.secret_bytes().is_err());
let cattr = mk.cipher_attr_view().unwrap();
let cattr = ViewBuilder::new(&mk).cipher_attr().build().unwrap();
assert_eq!(Codec::Chacha20Poly1305, cattr.cipher_codec().unwrap());
assert!(cattr.nonce_bytes().is_ok());
assert_eq!(32, cattr.key_length().unwrap());
let kattr = mk.kdf_attr_view().unwrap();
let kattr = ViewBuilder::new(&mk).kdf_attr().build().unwrap();
assert_eq!(Codec::BcryptPbkdf, kattr.kdf_codec().unwrap());
}
}
8 changes: 4 additions & 4 deletions src/kdf.rs
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ impl Builder {
#[cfg(test)]
mod tests {
use super::*;
use crate::{Views, cipher};
use crate::{ViewBuilder, cipher};

#[test]
fn test_bcrypt() {
Expand All @@ -109,18 +109,18 @@ mod tests {
.unwrap();

// get the kdf view
let kdf = ciphermk.kdf_view(&kdfmk).unwrap();
let kdf = ViewBuilder::new(&ciphermk).kdf(&kdfmk).build().unwrap();
// derive a key from the passphrase and add it to the cipher multikey
let ciphermk = kdf
.derive_key(b"for great justice, move every zig!")
.unwrap();

let kattr = ciphermk.kdf_attr_view().unwrap();
let kattr = ViewBuilder::new(&ciphermk).kdf_attr().build().unwrap();
assert_eq!(Codec::BcryptPbkdf, kattr.kdf_codec().unwrap());
assert_eq!(salt, kattr.salt_bytes().unwrap().to_vec());
assert_eq!(10, kattr.rounds().unwrap());

let kd = ciphermk.data_view().unwrap();
let kd = ViewBuilder::new(&ciphermk).data().build().unwrap();
assert_eq!(
vec![
119, 109, 13, 221, 140, 26, 88, 179, 135, 17, 119, 25, 176, 99, 5, 2, 203, 25, 82,
Expand Down
18 changes: 13 additions & 5 deletions src/keysplit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
//! normal Multikey encoders.

use crate::mk::Attributes;
use crate::{AttrId, Builder, Error, Multikey, Views};
use crate::{AttrId, Builder, Error, Multikey};
use blsful::inner_types::{G1Projective, G2Projective, Scalar as BlsScalar};
use curve25519_dalek::{ristretto::RistrettoPoint, scalar::Scalar as DalekScalar};
use elliptic_curve::ff::PrimeField;
Expand Down Expand Up @@ -440,7 +440,7 @@ pub fn split(
return Err(err("need 2 <= threshold <= limit <= 255"));
}
let codec = mk.codec();
let secret = mk.data_view()?.secret_bytes()?;
let secret = crate::views::dispatch::dispatch_data_view(mk)?.secret_bytes()?;
let payloads = build_payloads(codec, &secret, threshold, limit, rng)?;
payloads.iter().map(|p| wrap_share(mk, p)).collect()
}
Expand Down Expand Up @@ -526,6 +526,7 @@ pub fn combine(shares: &[Multikey]) -> Result<Multikey, Error> {
#[cfg(test)]
mod tests {
use super::*;
use crate::ViewBuilder;
use crate::mk;

fn gen_key(codec: Codec) -> Multikey {
Expand All @@ -536,7 +537,13 @@ mod tests {
}

fn secret(mk: &Multikey) -> Vec<u8> {
mk.data_view().unwrap().secret_bytes().unwrap().to_vec()
ViewBuilder::new(mk)
.data()
.build()
.unwrap()
.secret_bytes()
.unwrap()
.to_vec()
}

/// Split → verify every share → combine a non-contiguous subset → assert the
Expand Down Expand Up @@ -750,8 +757,9 @@ mod tests {
"mixed codecs"
);
assert!(combine(&[]).is_err(), "empty set");
let pk = gen_key(Codec::P256Priv)
.conv_view()
let pk = ViewBuilder::new(&gen_key(Codec::P256Priv))
.conv()
.build()
.unwrap()
.to_public_key()
.unwrap();
Expand Down
Loading
Loading