tsafe-collab 0.1.0

Collaboration service integration for tsafe — membership directory, DEK delivery, and recovery-share transport.
Documentation
//! Local Shamir Secret Sharing helpers for recovery-key split/reconstruct.
//!
//! These functions operate entirely locally — no network I/O, no service call.
//! The transport layer (delivering encrypted shares to custodians) is handled
//! separately by `CollabRemote::deliver_recovery_share` /
//! `CollabRemote::fetch_recovery_share`.
//!
//! # Wire format
//!
//! `split_recovery_key` serializes each share as:
//!
//! ```text
//! [ threshold: u8 ][ sharks_share_bytes: x || y[0..31] ]
//! ```
//!
//! The leading `threshold` byte lets `reconstruct_recovery_key` create the
//! correct `Sharks(threshold)` instance without requiring the caller to track it
//! separately.  `sharks` does not embed the threshold in its `Share` serialization.
//!
//! # Crate selection
//!
//! `sharks 0.5` was chosen over `vsss-rs` for its simplicity, pure-Rust
//! posture, and no_std compatibility.  It supports shares indexed as `u8`
//! (threshold ≤ 255, share count ≤ 255) which is sufficient for all expected
//! team sizes.
//!
//! See [docs/decisions/shamir-crate-selection.md](../../../docs/decisions/shamir-crate-selection.md)
//! for the formal crate-selection decision record (ADR-032).

use sharks::{Share, Sharks};

use crate::error::CollabError;

/// Split a 32-byte recovery key into `shares` Shamir shares, of which any
/// `threshold` are sufficient to reconstruct the original.
///
/// Returns a `Vec` of raw share bytes (one entry per share).  Each entry has a
/// 1-byte threshold prefix so that `reconstruct_recovery_key` can operate
/// without the caller tracking the threshold separately.  Each entry must be
/// age-encrypted to the corresponding custodian's public key before delivery.
///
/// # Panics
///
/// Panics if `threshold == 0` or `threshold > shares` — these are programming
/// errors, not runtime errors.
pub fn split_recovery_key(key: &[u8; 32], threshold: u8, shares: u8) -> Vec<Vec<u8>> {
    assert!(threshold > 0, "threshold must be at least 1");
    assert!(threshold <= shares, "threshold must be ≤ shares");

    let sharks = Sharks(threshold);
    let dealer = sharks.dealer(key);
    dealer
        .take(shares as usize)
        .map(|share| {
            // Prepend the threshold so reconstruct_recovery_key can use it.
            let mut bytes = Vec::with_capacity(1 + 1 + 32);
            bytes.push(threshold);
            bytes.extend(Vec::from(&share));
            bytes
        })
        .collect()
}

/// Reconstruct a 32-byte recovery key from at least `threshold` raw share bytes.
///
/// `shares` must be the serialized output of `split_recovery_key` — each
/// element begins with the 1-byte threshold prefix followed by the `sharks`
/// share bytes (`x || y[0..31]`).
///
/// Returns `CollabError::RecoveryFailed` if reconstruction fails (too few
/// shares, corrupted bytes, or wrong share set).
pub fn reconstruct_recovery_key(shares: &[Vec<u8>]) -> Result<[u8; 32], CollabError> {
    if shares.is_empty() {
        return Err(CollabError::RecoveryFailed("no shares provided".to_owned()));
    }

    // Decode the threshold from the first byte of the first share.
    let threshold = shares[0]
        .first()
        .copied()
        .ok_or_else(|| CollabError::RecoveryFailed("share is empty".to_owned()))?;

    // Parse each share: strip the threshold prefix byte then hand the rest to
    // sharks.
    let parsed: Result<Vec<Share>, _> = shares
        .iter()
        .map(|raw| {
            raw.get(1..)
                .ok_or("share too short")
                .and_then(Share::try_from)
        })
        .collect();

    let parsed = parsed.map_err(|e| {
        CollabError::RecoveryFailed(format!("one or more shares could not be parsed: {e}"))
    })?;

    let sharks = Sharks(threshold);
    let secret = sharks
        .recover(&parsed)
        .map_err(|e| CollabError::RecoveryFailed(format!("Shamir reconstruction error: {e}")))?;

    secret.as_slice().try_into().map_err(|_| {
        CollabError::RecoveryFailed(format!(
            "reconstructed secret is {} bytes, expected 32",
            secret.len()
        ))
    })
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn round_trip_2_of_3() {
        let key: [u8; 32] = [0xAB; 32];
        let all_shares = split_recovery_key(&key, 2, 3);
        assert_eq!(all_shares.len(), 3);

        // Any 2 of 3 should reconstruct.
        let recovered = reconstruct_recovery_key(&all_shares[..2]).unwrap();
        assert_eq!(recovered, key);
    }

    #[test]
    fn round_trip_3_of_5() {
        let key: [u8; 32] = [0x42; 32];
        let all_shares = split_recovery_key(&key, 3, 5);
        assert_eq!(all_shares.len(), 5);

        // Use shares 0, 2, 4 (non-consecutive to exercise the polynomial).
        let subset = vec![
            all_shares[0].clone(),
            all_shares[2].clone(),
            all_shares[4].clone(),
        ];
        let recovered = reconstruct_recovery_key(&subset).unwrap();
        assert_eq!(recovered, key);
    }

    #[test]
    fn all_5_shares_reconstruct() {
        let key: [u8; 32] = [0xDE; 32];
        let all_shares = split_recovery_key(&key, 3, 5);
        let recovered = reconstruct_recovery_key(&all_shares).unwrap();
        assert_eq!(recovered, key);
    }

    #[test]
    fn empty_shares_returns_error() {
        let result = reconstruct_recovery_key(&[]);
        assert!(matches!(result, Err(CollabError::RecoveryFailed(_))));
    }

    #[test]
    fn too_few_shares_returns_error() {
        let key: [u8; 32] = [0x11; 32];
        let all_shares = split_recovery_key(&key, 3, 5);
        // Only provide 2 shares when threshold is 3.
        let result = reconstruct_recovery_key(&all_shares[..2]);
        assert!(
            matches!(result, Err(CollabError::RecoveryFailed(_))),
            "expected RecoveryFailed with too few shares, got: {result:?}"
        );
    }

    #[test]
    fn corrupted_share_prefix_returns_error() {
        let key: [u8; 32] = [0x01; 32];
        let mut shares = split_recovery_key(&key, 2, 3);
        // Wipe the sharks share bytes (keep threshold byte to still parse).
        if let Some(first) = shares.first_mut() {
            for b in first.iter_mut().skip(1) {
                *b ^= 0xFF;
            }
        }
        // Either parse fails or the reconstructed key is wrong — both are
        // acceptable failure modes for a corrupted share.
        let result = reconstruct_recovery_key(&shares[..2]);
        assert!(result.is_err() || result.unwrap() != key);
    }
}