Skip to main content

auths_sdk/workflows/
multi_sig.rs

1//! Multi-signature event assembly for multi-device KEL events.
2//!
3//! File-based aggregation flow:
4//!
5//! 1. `begin_multi_sig_event` — serialize the finalized event body + signer
6//!    metadata to an [`UnsignedEventBundle`] on disk. Every signer device
7//!    reads from this file.
8//! 2. `sign_partial` — each device signs the canonical bytes with its own
9//!    keychain-stored private key, producing an [`IndexedSignature`].
10//! 3. `combine` — collect partials, verify threshold satisfaction using the
11//!    event's declared `kt`, and emit a [`SignedEvent`] ready to append to
12//!    the KEL.
13//!
14//! Cross-device pairing-protocol integration is deferred; this module covers
15//! the offline happy-path (export → each signer signs → combine).
16
17use std::fs;
18use std::path::{Path, PathBuf};
19
20use auths_core::crypto::signer::decrypt_keypair;
21use auths_core::signing::PassphraseProvider;
22use auths_core::storage::keychain::{KeyAlias, KeyStorage};
23use auths_keri::{
24    Event, IndexedSignature, SignedEvent, Threshold, serialize_for_signing, validate_signed_event,
25};
26use serde::{Deserialize, Serialize};
27
28/// Error type for multi-sig workflows.
29#[derive(Debug, thiserror::Error)]
30#[non_exhaustive]
31pub enum MultiSigError {
32    /// Underlying file I/O failure.
33    #[error("I/O error: {0}")]
34    Io(#[from] std::io::Error),
35
36    /// JSON or canonical-bytes serialization failure.
37    #[error("Serialization error: {0}")]
38    Serialization(String),
39
40    /// Keychain load / decrypt failure.
41    #[error("Keychain error: {0}")]
42    Keychain(String),
43
44    /// Failure while producing or validating a signature.
45    #[error("Signing failed: {0}")]
46    Signing(String),
47
48    /// Not enough partials to satisfy the expected threshold.
49    #[error(
50        "Threshold not met: verified indices {verified:?} do not satisfy expected threshold (key_count={key_count})"
51    )]
52    ThresholdNotMet {
53        /// Indices that contributed a valid signature.
54        verified: Vec<u32>,
55        /// Total number of keys in the event's `k` list.
56        key_count: usize,
57    },
58
59    /// Signer index exceeds the event's key count.
60    #[error("Signer index {index} out of range (key_count={key_count})")]
61    IndexOutOfRange {
62        /// Offending signer index.
63        index: u32,
64        /// Event's key count.
65        key_count: usize,
66    },
67
68    /// Underlying KEL validator rejected the combined event.
69    #[error("Validation error: {0}")]
70    Validation(String),
71}
72
73/// Serialized bundle written by [`begin_multi_sig_event`] and read by
74/// [`sign_partial`] / [`combine`].
75#[derive(Debug, Clone, Serialize, Deserialize)]
76pub struct UnsignedEventBundle {
77    /// Finalized event (with computed SAID, empty `x` field).
78    pub event: Event,
79    /// Aliases of the signers expected to contribute partials, in slot order.
80    pub signer_aliases: Vec<String>,
81    /// Canonical bytes — what each signer actually signs. Same bytes
82    /// [`combine`] will re-verify against. Held here so offline signers
83    /// don't need to reconstruct canonicalization independently.
84    #[serde(with = "hex::serde")]
85    pub canonical_bytes: Vec<u8>,
86    /// The SAID of the event, exposed for display / log correlation.
87    pub said: String,
88}
89
90/// Begin a multi-sig event by writing the canonical bytes + signer metadata
91/// to `output_path`. Each device will read this bundle before signing.
92pub fn begin_multi_sig_event(
93    event: &SignedEvent,
94    signers: &[KeyAlias],
95    output_path: &Path,
96) -> Result<UnsignedEventBundle, MultiSigError> {
97    let canonical = serialize_for_signing(&event.event)
98        .map_err(|e| MultiSigError::Serialization(e.to_string()))?;
99
100    let bundle = UnsignedEventBundle {
101        event: event.event.clone(),
102        signer_aliases: signers.iter().map(|a| a.to_string()).collect(),
103        canonical_bytes: canonical,
104        said: event.event.said().as_str().to_string(),
105    };
106
107    let json = serde_json::to_vec_pretty(&bundle)
108        .map_err(|e| MultiSigError::Serialization(e.to_string()))?;
109    fs::write(output_path, &json)?;
110    Ok(bundle)
111}
112
113/// Produce one indexed signature from `key_alias` at `signer_index`.
114///
115/// Loads the keypair, decrypts it with the passphrase from
116/// `passphrase_provider`, signs the bundle's canonical bytes, and returns
117/// an `IndexedSignature` ready to hand off to [`combine`].
118pub fn sign_partial(
119    unsigned_bundle_path: &Path,
120    key_alias: &KeyAlias,
121    signer_index: u32,
122    passphrase_provider: &dyn PassphraseProvider,
123    keychain: &(dyn KeyStorage + Send + Sync),
124) -> Result<IndexedSignature, MultiSigError> {
125    let raw = fs::read(unsigned_bundle_path)?;
126    let bundle: UnsignedEventBundle =
127        serde_json::from_slice(&raw).map_err(|e| MultiSigError::Serialization(e.to_string()))?;
128
129    let keys = match &bundle.event {
130        Event::Icp(icp) => &icp.k,
131        Event::Rot(rot) => &rot.k,
132        Event::Dip(dip) => &dip.k,
133        Event::Drt(drt) => &drt.k,
134        Event::Ixn(_) => {
135            return Err(MultiSigError::Validation(
136                "ixn events do not carry their own key list; multi-sig on interaction events uses the controller KEL state".to_string(),
137            ));
138        }
139    };
140    if (signer_index as usize) >= keys.len() {
141        return Err(MultiSigError::IndexOutOfRange {
142            index: signer_index,
143            key_count: keys.len(),
144        });
145    }
146
147    let (_did, _role, encrypted) = keychain
148        .load_key(key_alias)
149        .map_err(|e| MultiSigError::Keychain(e.to_string()))?;
150    let passphrase = passphrase_provider
151        .get_passphrase(&format!(
152            "Enter passphrase for multi-sig key '{}':",
153            key_alias
154        ))
155        .map_err(|e| MultiSigError::Keychain(e.to_string()))?;
156    let decrypted = decrypt_keypair(&encrypted, &passphrase)
157        .map_err(|e| MultiSigError::Keychain(e.to_string()))?;
158
159    // Parse as Ed25519; the codebase's single-curve signing path. P-256 support
160    // flows through the typed signer in a follow-up.
161    use ring::signature::{Ed25519KeyPair, KeyPair};
162    let keypair = Ed25519KeyPair::from_pkcs8(&decrypted)
163        .map_err(|e| MultiSigError::Signing(format!("Ed25519 load: {e}")))?;
164    let _pub_bytes = keypair.public_key().as_ref().to_vec();
165    let sig = keypair.sign(&bundle.canonical_bytes);
166
167    Ok(IndexedSignature {
168        index: signer_index,
169        prior_index: None,
170        sig: sig.as_ref().to_vec(),
171    })
172}
173
174/// Combine partials into a [`SignedEvent`], verifying the expected threshold
175/// is satisfied. Returns `MultiSigError::ThresholdNotMet` if the submitted
176/// partials don't cross the threshold.
177pub fn combine(
178    unsigned_bundle_path: &Path,
179    partials: Vec<IndexedSignature>,
180    expected_kt: &Threshold,
181) -> Result<SignedEvent, MultiSigError> {
182    let raw = fs::read(unsigned_bundle_path)?;
183    let bundle: UnsignedEventBundle =
184        serde_json::from_slice(&raw).map_err(|e| MultiSigError::Serialization(e.to_string()))?;
185
186    let keys_len = match &bundle.event {
187        Event::Icp(icp) => icp.k.len(),
188        Event::Rot(rot) => rot.k.len(),
189        Event::Dip(dip) => dip.k.len(),
190        Event::Drt(drt) => drt.k.len(),
191        Event::Ixn(_) => 0,
192    };
193
194    // Validate each partial verifies over the canonical bytes first. We
195    // reuse `validate_signed_event` for the full pipeline at the end, but
196    // check eagerly here to give clearer errors on mis-signed partials.
197    let signed = SignedEvent::new(bundle.event.clone(), partials.clone());
198    validate_signed_event(&signed, None).map_err(|e| MultiSigError::Validation(e.to_string()))?;
199
200    // Also verify threshold directly against the stated expected_kt (the
201    // validator checks the event's own `kt`; this catches callers who pass
202    // a looser expectation).
203    let verified: Vec<u32> = partials.iter().map(|p| p.index).collect();
204    if !expected_kt.is_satisfied(&verified, keys_len) {
205        return Err(MultiSigError::ThresholdNotMet {
206            verified,
207            key_count: keys_len,
208        });
209    }
210
211    Ok(signed)
212}
213
214/// Write a partial signature to disk at `output_path` for hand-off between
215/// devices.
216pub fn write_partial(
217    partial: &IndexedSignature,
218    output_path: &Path,
219) -> Result<PathBuf, MultiSigError> {
220    let json = serde_json::to_vec_pretty(partial)
221        .map_err(|e| MultiSigError::Serialization(e.to_string()))?;
222    fs::write(output_path, &json)?;
223    Ok(output_path.to_path_buf())
224}
225
226/// Read a partial signature from disk.
227pub fn read_partial(path: &Path) -> Result<IndexedSignature, MultiSigError> {
228    let raw = fs::read(path)?;
229    serde_json::from_slice(&raw).map_err(|e| MultiSigError::Serialization(e.to_string()))
230}
231
232#[cfg(test)]
233#[allow(clippy::unwrap_used, clippy::expect_used)]
234mod tests {
235    use super::*;
236    use auths_keri::{
237        CesrKey, Fraction, IcpEvent, KeriSequence, Prefix, Said, VersionString, finalize_icp_event,
238    };
239
240    use ring::rand::SystemRandom;
241    use ring::signature::{Ed25519KeyPair, KeyPair};
242    use tempfile::tempdir;
243
244    fn gen_keypair() -> Ed25519KeyPair {
245        let rng = SystemRandom::new();
246        let pkcs8 = Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
247        Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap()
248    }
249
250    fn cesr_pub(kp: &Ed25519KeyPair) -> String {
251        auths_keri::KeriPublicKey::ed25519(kp.public_key().as_ref())
252            .unwrap()
253            .to_qb64()
254            .unwrap()
255    }
256
257    fn half() -> Fraction {
258        Fraction {
259            numerator: 1,
260            denominator: 2,
261        }
262    }
263
264    fn make_three_key_icp() -> (IcpEvent, [Ed25519KeyPair; 3]) {
265        let kps = [gen_keypair(), gen_keypair(), gen_keypair()];
266        let k = vec![
267            CesrKey::new_unchecked(cesr_pub(&kps[0])),
268            CesrKey::new_unchecked(cesr_pub(&kps[1])),
269            CesrKey::new_unchecked(cesr_pub(&kps[2])),
270        ];
271        let n = vec![
272            Said::new_unchecked("EFakeNext0000000000000000000000000000000000".to_string()),
273            Said::new_unchecked("EFakeNext0000000000000000000000000000000001".to_string()),
274            Said::new_unchecked("EFakeNext0000000000000000000000000000000002".to_string()),
275        ];
276        let kt = Threshold::Weighted(vec![vec![half(), half(), half()]]);
277
278        let icp = IcpEvent {
279            v: VersionString::placeholder(),
280            d: Said::default(),
281            i: Prefix::default(),
282            s: KeriSequence::new(0),
283            kt,
284            k,
285            nt: Threshold::Weighted(vec![vec![half(), half(), half()]]),
286            n,
287            bt: Threshold::Simple(0),
288            b: vec![],
289            c: vec![],
290            a: vec![],
291        };
292        (finalize_icp_event(icp).unwrap(), kps)
293    }
294
295    #[test]
296    fn combine_threshold_not_met_with_one_partial() {
297        let (icp, kps) = make_three_key_icp();
298        let event = Event::Icp(icp.clone());
299        let unsigned = SignedEvent::new(event.clone(), vec![]);
300
301        let dir = tempdir().unwrap();
302        let bundle_path = dir.path().join("unsigned.json");
303        begin_multi_sig_event(
304            &unsigned,
305            &[
306                KeyAlias::new_unchecked("dev-a"),
307                KeyAlias::new_unchecked("dev-b"),
308                KeyAlias::new_unchecked("dev-c"),
309            ],
310            &bundle_path,
311        )
312        .unwrap();
313
314        let canonical = serialize_for_signing(&event).unwrap();
315        let partial0 = IndexedSignature {
316            index: 0,
317            prior_index: None,
318            sig: kps[0].sign(&canonical).as_ref().to_vec(),
319        };
320
321        let kt = Threshold::Weighted(vec![vec![half(), half(), half()]]);
322        let err = combine(&bundle_path, vec![partial0], &kt).unwrap_err();
323        match err {
324            MultiSigError::ThresholdNotMet { .. } | MultiSigError::Validation(_) => {}
325            other => panic!("expected ThresholdNotMet/Validation, got {other:?}"),
326        }
327    }
328
329    #[test]
330    fn combine_two_of_three_weighted_accepts() {
331        let (icp, kps) = make_three_key_icp();
332        let event = Event::Icp(icp.clone());
333        let unsigned = SignedEvent::new(event.clone(), vec![]);
334
335        let dir = tempdir().unwrap();
336        let bundle_path = dir.path().join("unsigned.json");
337        begin_multi_sig_event(
338            &unsigned,
339            &[
340                KeyAlias::new_unchecked("dev-a"),
341                KeyAlias::new_unchecked("dev-b"),
342                KeyAlias::new_unchecked("dev-c"),
343            ],
344            &bundle_path,
345        )
346        .unwrap();
347
348        let canonical = serialize_for_signing(&event).unwrap();
349        let partials = vec![
350            IndexedSignature {
351                index: 0,
352                prior_index: None,
353                sig: kps[0].sign(&canonical).as_ref().to_vec(),
354            },
355            IndexedSignature {
356                index: 2,
357                prior_index: None,
358                sig: kps[2].sign(&canonical).as_ref().to_vec(),
359            },
360        ];
361
362        let kt = Threshold::Weighted(vec![vec![half(), half(), half()]]);
363        let signed = combine(&bundle_path, partials, &kt).unwrap();
364        assert_eq!(signed.signatures.len(), 2);
365    }
366
367    #[test]
368    fn partial_roundtrip_via_disk() {
369        let sig = IndexedSignature {
370            index: 1,
371            prior_index: None,
372            sig: vec![7u8; 64],
373        };
374        let dir = tempdir().unwrap();
375        let path = dir.path().join("partial.sig");
376        write_partial(&sig, &path).unwrap();
377        let back = read_partial(&path).unwrap();
378        assert_eq!(back.index, 1);
379        assert_eq!(back.sig.len(), 64);
380        assert_eq!(back.sig, sig.sig);
381    }
382}