1use 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#[derive(Debug, thiserror::Error)]
30#[non_exhaustive]
31pub enum MultiSigError {
32 #[error("I/O error: {0}")]
34 Io(#[from] std::io::Error),
35
36 #[error("Serialization error: {0}")]
38 Serialization(String),
39
40 #[error("Keychain error: {0}")]
42 Keychain(String),
43
44 #[error("Signing failed: {0}")]
46 Signing(String),
47
48 #[error(
50 "Threshold not met: verified indices {verified:?} do not satisfy expected threshold (key_count={key_count})"
51 )]
52 ThresholdNotMet {
53 verified: Vec<u32>,
55 key_count: usize,
57 },
58
59 #[error("Signer index {index} out of range (key_count={key_count})")]
61 IndexOutOfRange {
62 index: u32,
64 key_count: usize,
66 },
67
68 #[error("Validation error: {0}")]
70 Validation(String),
71
72 #[error("bundle canonical bytes do not match its event (tampered bundle)")]
75 BundleMismatch,
76}
77
78#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct UnsignedEventBundle {
82 pub event: Event,
84 pub signer_aliases: Vec<String>,
86 #[serde(with = "hex::serde")]
90 pub canonical_bytes: Vec<u8>,
91 pub said: String,
93}
94
95pub 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
118fn 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
142pub 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 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 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
207pub 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 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 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
247pub 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
259pub 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 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(), 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 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}