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    /// The bundle's stored canonical bytes do not match its event — a tampered bundle that would
73    /// turn a signer into a blind-signing oracle. Refuse rather than sign attacker-chosen bytes.
74    #[error("bundle canonical bytes do not match its event (tampered bundle)")]
75    BundleMismatch,
76}
77
78/// Serialized bundle written by [`begin_multi_sig_event`] and read by
79/// [`sign_partial`] / [`combine`].
80#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct UnsignedEventBundle {
82    /// Finalized event (with computed SAID, empty `x` field).
83    pub event: Event,
84    /// Aliases of the signers expected to contribute partials, in slot order.
85    pub signer_aliases: Vec<String>,
86    /// Canonical bytes — what each signer actually signs. Same bytes
87    /// [`combine`] will re-verify against. Held here so offline signers
88    /// don't need to reconstruct canonicalization independently.
89    #[serde(with = "hex::serde")]
90    pub canonical_bytes: Vec<u8>,
91    /// The SAID of the event, exposed for display / log correlation.
92    pub said: String,
93}
94
95/// Begin a multi-sig event by writing the canonical bytes + signer metadata
96/// to `output_path`. Each device will read this bundle before signing.
97pub fn begin_multi_sig_event(
98    event: &SignedEvent,
99    signers: &[KeyAlias],
100    output_path: &Path,
101) -> Result<UnsignedEventBundle, MultiSigError> {
102    let canonical = serialize_for_signing(&event.event)
103        .map_err(|e| MultiSigError::Serialization(e.to_string()))?;
104
105    let bundle = UnsignedEventBundle {
106        event: event.event.clone(),
107        signer_aliases: signers.iter().map(|a| a.to_string()).collect(),
108        canonical_bytes: canonical,
109        said: event.event.said().as_str().to_string(),
110    };
111
112    let json = serde_json::to_vec_pretty(&bundle)
113        .map_err(|e| MultiSigError::Serialization(e.to_string()))?;
114    fs::write(output_path, &json)?;
115    Ok(bundle)
116}
117
118/// Recompute the canonical signing bytes from a bundle's event — the source of truth — and reject a
119/// bundle whose stored `canonical_bytes` disagree with it.
120///
121/// A signer must never sign coordinator-provided bytes blindly: a tampered bundle (a benign `event`
122/// shown to the operator, but `canonical_bytes` belonging to a malicious event) would otherwise harvest
123/// a valid signature over an event the signer never approved. `combine` already re-derives the canonical
124/// bytes from the event when it verifies; this keeps `sign_partial` consistent so the two never diverge.
125///
126/// Args:
127/// * `bundle`: The unsigned-event bundle read from disk.
128///
129/// Usage:
130/// ```ignore
131/// let canonical = bundle_canonical(&bundle)?; // BundleMismatch if the stored bytes were tampered
132/// ```
133fn bundle_canonical(bundle: &UnsignedEventBundle) -> Result<Vec<u8>, MultiSigError> {
134    let canonical = serialize_for_signing(&bundle.event)
135        .map_err(|e| MultiSigError::Serialization(e.to_string()))?;
136    if canonical != bundle.canonical_bytes {
137        return Err(MultiSigError::BundleMismatch);
138    }
139    Ok(canonical)
140}
141
142/// Produce one indexed signature from `key_alias` at `signer_index`.
143///
144/// Loads the keypair, decrypts it with the passphrase from
145/// `passphrase_provider`, signs the bundle's canonical bytes, and returns
146/// an `IndexedSignature` ready to hand off to [`combine`].
147pub fn sign_partial(
148    unsigned_bundle_path: &Path,
149    key_alias: &KeyAlias,
150    signer_index: u32,
151    passphrase_provider: &dyn PassphraseProvider,
152    keychain: &(dyn KeyStorage + Send + Sync),
153) -> Result<IndexedSignature, MultiSigError> {
154    let raw = fs::read(unsigned_bundle_path)?;
155    let bundle: UnsignedEventBundle =
156        serde_json::from_slice(&raw).map_err(|e| MultiSigError::Serialization(e.to_string()))?;
157
158    // The event is the source of truth; never sign the bundle's stored bytes blindly. A tampered
159    // bundle is refused here, before any key is loaded (blind-signing defense).
160    let canonical = bundle_canonical(&bundle)?;
161
162    let keys = match &bundle.event {
163        Event::Icp(icp) => &icp.k,
164        Event::Rot(rot) => &rot.k,
165        Event::Dip(dip) => &dip.k,
166        Event::Drt(drt) => &drt.k,
167        Event::Ixn(_) => {
168            return Err(MultiSigError::Validation(
169                "ixn events do not carry their own key list; multi-sig on interaction events uses the controller KEL state".to_string(),
170            ));
171        }
172    };
173    if (signer_index as usize) >= keys.len() {
174        return Err(MultiSigError::IndexOutOfRange {
175            index: signer_index,
176            key_count: keys.len(),
177        });
178    }
179
180    let (_did, _role, encrypted) = keychain
181        .load_key(key_alias)
182        .map_err(|e| MultiSigError::Keychain(e.to_string()))?;
183    let passphrase = passphrase_provider
184        .get_passphrase(&format!(
185            "Enter passphrase for multi-sig key '{}':",
186            key_alias
187        ))
188        .map_err(|e| MultiSigError::Keychain(e.to_string()))?;
189    let decrypted = decrypt_keypair(&encrypted, &passphrase)
190        .map_err(|e| MultiSigError::Keychain(e.to_string()))?;
191
192    // Parse as Ed25519; the codebase's single-curve signing path. P-256 support
193    // flows through the typed signer in a follow-up.
194    use ring::signature::{Ed25519KeyPair, KeyPair};
195    let keypair = Ed25519KeyPair::from_pkcs8(&decrypted)
196        .map_err(|e| MultiSigError::Signing(format!("Ed25519 load: {e}")))?;
197    let _pub_bytes = keypair.public_key().as_ref().to_vec();
198    let sig = keypair.sign(&canonical);
199
200    Ok(IndexedSignature {
201        index: signer_index,
202        prior_index: None,
203        sig: sig.as_ref().to_vec(),
204    })
205}
206
207/// Combine partials into a [`SignedEvent`], verifying the expected threshold
208/// is satisfied. Returns `MultiSigError::ThresholdNotMet` if the submitted
209/// partials don't cross the threshold.
210pub fn combine(
211    unsigned_bundle_path: &Path,
212    partials: Vec<IndexedSignature>,
213    expected_kt: &Threshold,
214) -> Result<SignedEvent, MultiSigError> {
215    let raw = fs::read(unsigned_bundle_path)?;
216    let bundle: UnsignedEventBundle =
217        serde_json::from_slice(&raw).map_err(|e| MultiSigError::Serialization(e.to_string()))?;
218
219    let keys_len = match &bundle.event {
220        Event::Icp(icp) => icp.k.len(),
221        Event::Rot(rot) => rot.k.len(),
222        Event::Dip(dip) => dip.k.len(),
223        Event::Drt(drt) => drt.k.len(),
224        Event::Ixn(_) => 0,
225    };
226
227    // Validate each partial verifies over the canonical bytes first. We
228    // reuse `validate_signed_event` for the full pipeline at the end, but
229    // check eagerly here to give clearer errors on mis-signed partials.
230    let signed = SignedEvent::new(bundle.event.clone(), partials.clone());
231    validate_signed_event(&signed, None).map_err(|e| MultiSigError::Validation(e.to_string()))?;
232
233    // Also verify threshold directly against the stated expected_kt (the
234    // validator checks the event's own `kt`; this catches callers who pass
235    // a looser expectation).
236    let verified: Vec<u32> = partials.iter().map(|p| p.index).collect();
237    if !expected_kt.is_satisfied(&verified, keys_len) {
238        return Err(MultiSigError::ThresholdNotMet {
239            verified,
240            key_count: keys_len,
241        });
242    }
243
244    Ok(signed)
245}
246
247/// Write a partial signature to disk at `output_path` for hand-off between
248/// devices.
249pub fn write_partial(
250    partial: &IndexedSignature,
251    output_path: &Path,
252) -> Result<PathBuf, MultiSigError> {
253    let json = serde_json::to_vec_pretty(partial)
254        .map_err(|e| MultiSigError::Serialization(e.to_string()))?;
255    fs::write(output_path, &json)?;
256    Ok(output_path.to_path_buf())
257}
258
259/// Read a partial signature from disk.
260pub fn read_partial(path: &Path) -> Result<IndexedSignature, MultiSigError> {
261    let raw = fs::read(path)?;
262    serde_json::from_slice(&raw).map_err(|e| MultiSigError::Serialization(e.to_string()))
263}
264
265#[cfg(test)]
266#[allow(clippy::unwrap_used, clippy::expect_used)]
267mod tests {
268    use super::*;
269    use auths_keri::{
270        CesrKey, Fraction, IcpEvent, KeriSequence, Prefix, Said, VersionString, finalize_icp_event,
271    };
272
273    use ring::rand::SystemRandom;
274    use ring::signature::{Ed25519KeyPair, KeyPair};
275    use tempfile::tempdir;
276
277    fn gen_keypair() -> Ed25519KeyPair {
278        let rng = SystemRandom::new();
279        let pkcs8 = Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
280        Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap()
281    }
282
283    fn cesr_pub(kp: &Ed25519KeyPair) -> String {
284        auths_keri::KeriPublicKey::ed25519(kp.public_key().as_ref())
285            .unwrap()
286            .to_qb64()
287            .unwrap()
288    }
289
290    fn half() -> Fraction {
291        Fraction {
292            numerator: 1,
293            denominator: 2,
294        }
295    }
296
297    fn make_three_key_icp() -> (IcpEvent, [Ed25519KeyPair; 3]) {
298        let kps = [gen_keypair(), gen_keypair(), gen_keypair()];
299        let k = vec![
300            CesrKey::new_unchecked(cesr_pub(&kps[0])),
301            CesrKey::new_unchecked(cesr_pub(&kps[1])),
302            CesrKey::new_unchecked(cesr_pub(&kps[2])),
303        ];
304        let n = vec![
305            Said::new_unchecked("EFakeNext0000000000000000000000000000000000".to_string()),
306            Said::new_unchecked("EFakeNext0000000000000000000000000000000001".to_string()),
307            Said::new_unchecked("EFakeNext0000000000000000000000000000000002".to_string()),
308        ];
309        let kt = Threshold::Weighted(vec![vec![half(), half(), half()]]);
310
311        let icp = IcpEvent {
312            v: VersionString::placeholder(),
313            d: Said::default(),
314            i: Prefix::default(),
315            s: KeriSequence::new(0),
316            kt,
317            k,
318            nt: Threshold::Weighted(vec![vec![half(), half(), half()]]),
319            n,
320            bt: Threshold::Simple(0),
321            b: vec![],
322            c: vec![],
323            a: vec![],
324        };
325        (finalize_icp_event(icp).unwrap(), kps)
326    }
327
328    #[test]
329    fn combine_threshold_not_met_with_one_partial() {
330        let (icp, kps) = make_three_key_icp();
331        let event = Event::Icp(icp.clone());
332        let unsigned = SignedEvent::new(event.clone(), vec![]);
333
334        let dir = tempdir().unwrap();
335        let bundle_path = dir.path().join("unsigned.json");
336        begin_multi_sig_event(
337            &unsigned,
338            &[
339                KeyAlias::new_unchecked("dev-a"),
340                KeyAlias::new_unchecked("dev-b"),
341                KeyAlias::new_unchecked("dev-c"),
342            ],
343            &bundle_path,
344        )
345        .unwrap();
346
347        let canonical = serialize_for_signing(&event).unwrap();
348        let partial0 = IndexedSignature {
349            index: 0,
350            prior_index: None,
351            sig: kps[0].sign(&canonical).as_ref().to_vec(),
352        };
353
354        let kt = Threshold::Weighted(vec![vec![half(), half(), half()]]);
355        let err = combine(&bundle_path, vec![partial0], &kt).unwrap_err();
356        match err {
357            MultiSigError::ThresholdNotMet { .. } | MultiSigError::Validation(_) => {}
358            other => panic!("expected ThresholdNotMet/Validation, got {other:?}"),
359        }
360    }
361
362    #[test]
363    fn combine_two_of_three_weighted_accepts() {
364        let (icp, kps) = make_three_key_icp();
365        let event = Event::Icp(icp.clone());
366        let unsigned = SignedEvent::new(event.clone(), vec![]);
367
368        let dir = tempdir().unwrap();
369        let bundle_path = dir.path().join("unsigned.json");
370        begin_multi_sig_event(
371            &unsigned,
372            &[
373                KeyAlias::new_unchecked("dev-a"),
374                KeyAlias::new_unchecked("dev-b"),
375                KeyAlias::new_unchecked("dev-c"),
376            ],
377            &bundle_path,
378        )
379        .unwrap();
380
381        let canonical = serialize_for_signing(&event).unwrap();
382        let partials = vec![
383            IndexedSignature {
384                index: 0,
385                prior_index: None,
386                sig: kps[0].sign(&canonical).as_ref().to_vec(),
387            },
388            IndexedSignature {
389                index: 2,
390                prior_index: None,
391                sig: kps[2].sign(&canonical).as_ref().to_vec(),
392            },
393        ];
394
395        let kt = Threshold::Weighted(vec![vec![half(), half(), half()]]);
396        let signed = combine(&bundle_path, partials, &kt).unwrap();
397        assert_eq!(signed.signatures.len(), 2);
398    }
399
400    #[test]
401    fn partial_roundtrip_via_disk() {
402        let sig = IndexedSignature {
403            index: 1,
404            prior_index: None,
405            sig: vec![7u8; 64],
406        };
407        let dir = tempdir().unwrap();
408        let path = dir.path().join("partial.sig");
409        write_partial(&sig, &path).unwrap();
410        let back = read_partial(&path).unwrap();
411        assert_eq!(back.index, 1);
412        assert_eq!(back.sig.len(), 64);
413        assert_eq!(back.sig, sig.sig);
414    }
415
416    #[test]
417    fn bundle_canonical_rejects_a_tampered_bundle() {
418        // A bundle whose stored canonical_bytes belong to a DIFFERENT event must be refused — signing
419        // them would harvest a valid signature over an event the signer never approved. Before this
420        // fix, sign_partial signed `bundle.canonical_bytes` directly, with no such check (RED).
421        let (shown_icp, _) = make_three_key_icp();
422        let (attacker_icp, _) = make_three_key_icp();
423        let shown = Event::Icp(shown_icp);
424        let attacker = Event::Icp(attacker_icp);
425
426        let tampered = UnsignedEventBundle {
427            event: shown.clone(),
428            signer_aliases: vec!["dev-a".to_string()],
429            canonical_bytes: serialize_for_signing(&attacker).unwrap(), // bytes of a different event
430            said: shown.said().as_str().to_string(),
431        };
432
433        let err = bundle_canonical(&tampered).unwrap_err();
434        assert!(
435            matches!(err, MultiSigError::BundleMismatch),
436            "a tampered bundle must be rejected (blind-signing defense), got {err:?}"
437        );
438    }
439
440    #[test]
441    fn bundle_canonical_accepts_a_consistent_bundle() {
442        // The legitimate bundle (canonical_bytes == serialize_for_signing(event)) is accepted, and the
443        // returned bytes are exactly the event's own canonicalization — what a signer must sign.
444        let (icp, _) = make_three_key_icp();
445        let event = Event::Icp(icp);
446        let consistent = UnsignedEventBundle {
447            event: event.clone(),
448            signer_aliases: vec![],
449            canonical_bytes: serialize_for_signing(&event).unwrap(),
450            said: event.said().as_str().to_string(),
451        };
452
453        let canonical = bundle_canonical(&consistent).expect("consistent bundle accepted");
454        assert_eq!(canonical, serialize_for_signing(&event).unwrap());
455    }
456}