ms-cli 0.7.0

Companion CLI for ms-codec — engrave-friendly BIP-39 entropy backups (the ms1 format).
//! `ms combine` — recombine K-of-N codex32 shares into the original secret.
//!
//! Realizes SPEC_ms_v0_2_kofn §3 (`ms combine`). Takes K (or more) distributed
//! share strings; `ms_codec::combine_shares` recovers the secret-at-S and the
//! payload kind (entr / mnem). Emits per `--to`: a BIP-39 phrase (default;
//! mnem → on-wire language, entr → English), raw entropy hex, or a single ms1
//! string. The recovered secret is PrivateKeyMaterial (advisory) and
//! Zeroizing-wrapped.

use bip39::{Language, Mnemonic};
use clap::{Args, ValueEnum};
use ms_codec::{Payload, Tag};
use serde_json::to_string;
use zeroize::Zeroizing;

use crate::advisory::{OutputClass, emit_output_class_advisory};
use crate::error::{CliError, Result};
use crate::format::CombineJson;
use crate::language::CliLanguage;

/// Output form for the recovered secret.
#[derive(Copy, Clone, Debug, PartialEq, Eq, ValueEnum)]
#[clap(rename_all = "kebab-case")]
pub enum CombineTo {
    /// BIP-39 mnemonic phrase (mnem → wire language; entr → English).
    Phrase,
    /// Raw entropy bytes as hex.
    Entropy,
    /// A single unshared ms1 string (re-encodes the recovered secret).
    Ms1,
}

/// `ms combine` arguments.
#[derive(Args, Debug)]
pub struct CombineArgs {
    /// The distributed share strings to recombine (K or more, distinct indices).
    #[arg(required = true)]
    pub shares: Vec<String>,

    /// Output form for the recovered secret. Default `phrase`.
    #[arg(long, value_enum, default_value = "phrase")]
    pub to: CombineTo,

    /// Emit a single JSON object on stdout instead of text.
    #[arg(long)]
    pub json: bool,
}

/// Run `ms combine`. Writes the recovered secret to stdout per the `--to` form.
pub fn run(mut args: CombineArgs) -> Result<u8> {
    // The share strings themselves are secret-equivalent — wrap them.
    let shares: Zeroizing<Vec<String>> = Zeroizing::new(std::mem::take(&mut args.shares));

    // Recover the secret. combine_shares surfaces the §2 error taxonomy
    // (SecretShareSuppliedToCombine / Codex32(ThresholdNotPassed/Mismatched*/
    // RepeatedIndex)) which the From<ms_codec::Error> mapping + codex32_friendly
    // render into clean messages.
    let (_tag, payload): (Tag, Payload) = ms_codec::combine_shares(&shares)?;

    // Extract the entropy + language for rendering. The on-wire language byte is
    // authoritative for mnem; entr renders as English.
    let (entropy, language, kind): (Zeroizing<Vec<u8>>, CliLanguage, &'static str) = match &payload
    {
        Payload::Entr(b) => (Zeroizing::new(b.clone()), CliLanguage::English, "entr"),
        Payload::Mnem {
            language: wire_code,
            entropy,
        } => {
            let lang = CliLanguage::from_code(*wire_code).unwrap_or(CliLanguage::English);
            (Zeroizing::new(entropy.clone()), lang, "mnem")
        }
        // ms_codec::Payload is #[non_exhaustive]; guard against future variants.
        _ => unreachable!("combine_shares returned an unknown Payload variant"),
    };

    match args.to {
        CombineTo::Phrase => emit_phrase(&entropy, language, kind, args.json)?,
        CombineTo::Entropy => emit_entropy(&entropy, kind, args.json)?,
        CombineTo::Ms1 => emit_ms1(&payload, &entropy, kind, args.json)?,
    }

    emit_output_class_advisory(OutputClass::PrivateKeyMaterial, &mut std::io::stderr().lock());
    Ok(0)
}

/// Render the recovered secret as a BIP-39 phrase in its language.
fn emit_phrase(
    entropy: &[u8],
    language: CliLanguage,
    kind: &'static str,
    json: bool,
) -> Result<()> {
    let lang: Language = language.into();
    // SAFETY: third-party-blocked — `bip39::Mnemonic` has no Drop+Zeroize;
    // FOLLOWUP `rust-bip39-mnemonic-zeroize-upstream`.
    let mnemonic = Mnemonic::from_entropy_in(lang, entropy)
        .expect("combine_shares validates entropy length; from_entropy_in cannot fail");
    let phrase: Zeroizing<String> = Zeroizing::new(mnemonic.to_string());
    let word_count = phrase.split_whitespace().count();

    if json {
        let json = CombineJson {
            schema_version: "1",
            kind: kind.to_string(),
            ms1: None,
            entropy_hex: hex::encode(entropy),
            phrase: Some(phrase.to_string()),
            language: Some(language.as_str()),
            word_count: Some(word_count),
        };
        let s =
            to_string(&json).map_err(|e| CliError::BadInput(format!("json serialization: {e}")))?;
        println!("{s}");
    } else {
        println!("entropy: {}", hex::encode(entropy));
        println!("phrase: {}", *phrase);
        println!("language: {} ({} words)", language.as_str(), word_count);
        println!("kind: {kind}");
    }
    Ok(())
}

/// Render just the raw entropy hex.
fn emit_entropy(entropy: &[u8], kind: &'static str, json: bool) -> Result<()> {
    if json {
        let json = CombineJson {
            schema_version: "1",
            kind: kind.to_string(),
            ms1: None,
            entropy_hex: hex::encode(entropy),
            phrase: None,
            language: None,
            word_count: None,
        };
        let s =
            to_string(&json).map_err(|e| CliError::BadInput(format!("json serialization: {e}")))?;
        println!("{s}");
    } else {
        println!("{}", hex::encode(entropy));
    }
    Ok(())
}

/// Re-encode the recovered secret as a single (unshared) ms1 string.
fn emit_ms1(payload: &Payload, entropy: &[u8], kind: &'static str, json: bool) -> Result<()> {
    let ms1 = ms_codec::encode(Tag::ENTR, payload)?;
    if json {
        let json = CombineJson {
            schema_version: "1",
            kind: kind.to_string(),
            ms1: Some(ms1.clone()),
            entropy_hex: hex::encode(entropy),
            phrase: None,
            language: None,
            word_count: None,
        };
        let s =
            to_string(&json).map_err(|e| CliError::BadInput(format!("json serialization: {e}")))?;
        println!("{s}");
    } else {
        println!("{ms1}");
    }
    Ok(())
}