灵符lingfu

ling-fu

↓ 0 downloads

灵符 — The Ling project manager. lingfu new/build/run/normalize and more.

lingfu install ling-fu
LicenseApache-2.0 OR MIT
AuthorsLing Lin <taellinglin@gmail.com>, Sanny Lin <SannyLing53@gmail.com>
READMECode / FilesVersionsDependency GraphDependentsSecurityBuildsSettings

Code / Files (28)

src/seed.rs
//! `lingfu seed` — a reconstructable, encrypted integrity fingerprint of the
//! project: every git-tracked file's own BLAKE3 checksum, rolled up into a
//! Merkle root (a `log2(n)`-depth tree — the "logarithmic symmetry"), the
//! whole manifest encrypted with a key deterministically derived (HKDF-SHA3,
//! no salt — the same keyfile always regenerates the same key, nothing else
//! is ever stored) from a keyfile, and packaged as a playable/inspectable
//! `.wav` file rather than an opaque blob.
//!
//! `--check` recomputes the manifest and diffs it against a saved seed to
//! catch drift — including silent, small modifications — while another
//! process runs, if invoked from a hook/watcher.
//!
//! A keyfile-derived, unsalted key means the keyfile *is* the entire root of
//! trust: lose it and there is no way to ever regenerate the same key again.
//! `--recover-init` deliberately trades a little of that starkness for
//! recoverability by also splitting the derived key into Shamir shares
//! (2-of-3): any two reconstruct it without the keyfile at all.

use anyhow::{Context, Result, bail};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};

const INFO_LABEL: &[u8] = b"lingfu-seed-v1";
const DEFAULT_SEED_FILE: &str = "seed.wav";
const WAV_SAMPLE_RATE: u32 = 22_050;

/// relative path (forward-slashed) -> BLAKE3 hash, kept sorted so the
/// manifest and its Merkle root are identical across runs/machines.
type Manifest = BTreeMap<String, [u8; 32]>;

// ─── file discovery ─────────────────────────────────────────────────────────

/// Git-tracked files only: already respects `.gitignore`, needs no
/// hand-maintained include/exclude list, and is exactly what "essential"
/// means for a versioned project — build artefacts and scratch files were
/// never tracked in the first place. `ling.ignore` (see `crate::ignore`) is
/// applied on top, for anything tracked-but-still-shouldn't-be-hashed —
/// e.g. a `seed` keyfile or recovery share that later got `git add`ed by
/// mistake, which would otherwise end up hashed into the very manifest it
/// derives the encryption key for.
fn essential_files(root: &Path) -> Result<Vec<PathBuf>> {
    let out = std::process::Command::new("git")
        .arg("ls-files")
        .current_dir(root)
        .output()
        .context("running `git ls-files` (is this a git repository?)")?;
    if !out.status.success() {
        bail!(
            "git ls-files failed: {}",
            String::from_utf8_lossy(&out.stderr)
        );
    }
    let ignore_patterns = crate::ignore::load(root);
    Ok(String::from_utf8_lossy(&out.stdout)
        .lines()
        .filter(|l| !l.is_empty())
        .filter(|l| !crate::ignore::is_ignored(&ignore_patterns, l))
        .map(PathBuf::from)
        .collect())
}

fn build_manifest(root: &Path, files: &[PathBuf]) -> Result<Manifest> {
    let mut m = Manifest::new();
    for rel in files {
        let full = root.join(rel);
        let bytes = match std::fs::read(&full) {
            Ok(b) => b,
            // A tracked path that's a submodule/symlink-to-nowhere/etc.
            // shouldn't abort the whole manifest.
            Err(_) => continue,
        };
        let hash = ling_crypto::hash::Blake3::hash(&bytes);
        m.insert(rel.to_string_lossy().replace('\\', "/"), hash);
    }
    Ok(m)
}

// ─── Merkle root ────────────────────────────────────────────────────────────

/// Pairwise-BLAKE3 binary Merkle tree over `manifest`'s hashes in sorted
/// (path) order; an odd node out is carried up unchanged rather than
/// duplicated, so a manifest of one file's root is just that file's hash.
fn merkle_root(manifest: &Manifest) -> [u8; 32] {
    let mut layer: Vec<[u8; 32]> = manifest.values().copied().collect();
    if layer.is_empty() {
        return [0u8; 32];
    }
    while layer.len() > 1 {
        let mut next = Vec::with_capacity(layer.len().div_ceil(2));
        for pair in layer.chunks(2) {
            let h = if pair.len() == 2 {
                let mut buf = [0u8; 64];
                buf[..32].copy_from_slice(&pair[0]);
                buf[32..].copy_from_slice(&pair[1]);
                ling_crypto::hash::Blake3::hash(&buf)
            } else {
                pair[0]
            };
            next.push(h);
        }
        layer = next;
    }
    layer[0]
}

// ─── manifest <-> text (the thing that actually gets encrypted) ───────────

/// Every line is `<hex hash>  <name>`, hash first, so parsing doesn't need
/// to special-case the root line's shape — only its name, the literal
/// `ROOT`, which can't collide with a real (git-tracked, forward-slashed)
/// path.
fn manifest_to_text(manifest: &Manifest, root: &[u8; 32]) -> String {
    let mut out = String::new();
    for (path, hash) in manifest {
        out.push_str(&hex::encode(hash));
        out.push_str("  ");
        out.push_str(path);
        out.push('\n');
    }
    out.push_str(&hex::encode(root));
    out.push_str("  ROOT\n");
    out
}

fn text_to_manifest(text: &str) -> Result<(Manifest, [u8; 32])> {
    let mut m = Manifest::new();
    let mut root = None;
    for line in text.lines() {
        let Some((hash_hex, name)) = line.split_once("  ") else { continue };
        let hash = hex::decode(hash_hex).context("corrupt manifest: bad hash hex")?;
        let hash: [u8; 32] = hash
            .try_into()
            .map_err(|_| anyhow::anyhow!("corrupt manifest: hash not 32 bytes"))?;
        if name == "ROOT" {
            root = Some(hash);
        } else {
            m.insert(name.to_string(), hash);
        }
    }
    let root = root.context("corrupt manifest: no ROOT line")?;
    Ok((m, root))
}

// ─── tiny hex + WAV helpers (no extra dependencies) ────────────────────────

mod hex {
    pub fn encode(bytes: &[u8]) -> String {
        const T: &[u8; 16] = b"0123456789abcdef";
        let mut s = String::with_capacity(bytes.len() * 2);
        for &b in bytes {
            s.push(T[(b >> 4) as usize] as char);
            s.push(T[(b & 0xf) as usize] as char);
        }
        s
    }

    pub fn decode(s: &str) -> anyhow::Result<Vec<u8>> {
        if s.len() % 2 != 0 {
            anyhow::bail!("odd-length hex string");
        }
        (0..s.len())
            .step_by(2)
            .map(|i| u8::from_str_radix(&s[i..i + 2], 16).map_err(|e| anyhow::anyhow!(e)))
            .collect()
    }
}

/// Wrap arbitrary bytes as 8-bit unsigned mono PCM — a real, playable
/// (if noisy) `.wav` file, and byte-for-byte reversible: sample N *is*
/// input byte N, no bit-packing or resampling to undo.
fn write_wav(path: &Path, data: &[u8]) -> Result<()> {
    let mut out = Vec::with_capacity(44 + data.len());
    let data_len = data.len() as u32;
    let byte_rate = WAV_SAMPLE_RATE; // 1 channel * 1 byte/sample * sample_rate
    out.extend_from_slice(b"RIFF");
    out.extend_from_slice(&(36 + data_len).to_le_bytes());
    out.extend_from_slice(b"WAVE");
    out.extend_from_slice(b"fmt ");
    out.extend_from_slice(&16u32.to_le_bytes()); // fmt chunk size
    out.extend_from_slice(&1u16.to_le_bytes()); // PCM
    out.extend_from_slice(&1u16.to_le_bytes()); // mono
    out.extend_from_slice(&WAV_SAMPLE_RATE.to_le_bytes());
    out.extend_from_slice(&byte_rate.to_le_bytes());
    out.extend_from_slice(&1u16.to_le_bytes()); // block align (1 byte/sample)
    out.extend_from_slice(&8u16.to_le_bytes()); // bits per sample
    out.extend_from_slice(b"data");
    out.extend_from_slice(&data_len.to_le_bytes());
    out.extend_from_slice(data);
    std::fs::write(path, out).with_context(|| format!("writing {}", path.display()))
}

fn read_wav(path: &Path) -> Result<Vec<u8>> {
    let bytes = std::fs::read(path).with_context(|| format!("reading {}", path.display()))?;
    if bytes.len() < 44 || &bytes[0..4] != b"RIFF" || &bytes[8..12] != b"WAVE" {
        bail!("{}: not a WAV file this tool wrote", path.display());
    }
    // Walk chunks rather than assume `fmt ` is exactly 16 bytes / `data`
    // starts at byte 44 — still simple, just not fragile to a stray chunk.
    let mut i = 12usize;
    while i + 8 <= bytes.len() {
        let id = &bytes[i..i + 4];
        let len = u32::from_le_bytes(bytes[i + 4..i + 8].try_into().unwrap()) as usize;
        let start = i + 8;
        if id == b"data" {
            let end = (start + len).min(bytes.len());
            return Ok(bytes[start..end].to_vec());
        }
        i = start + len + (len % 2); // chunks are word-aligned
    }
    bail!("{}: no data chunk found", path.display());
}

// ─── key derivation + AEAD ──────────────────────────────────────────────────

fn derive_key(keyfile: &Path) -> Result<[u8; 32]> {
    let ikm =
        std::fs::read(keyfile).with_context(|| format!("reading keyfile {}", keyfile.display()))?;
    let derived = ling_crypto::kdf::hkdf_sha3(&ikm, &[], INFO_LABEL, 32)
        .map_err(|e| anyhow::anyhow!("hkdf: {e}"))?;
    let mut key = [0u8; 32];
    key.copy_from_slice(&derived);
    Ok(key)
}

fn encrypt(key: [u8; 32], plaintext: &[u8]) -> Result<Vec<u8>> {
    ling_crypto::symmetric::XChaCha20::new(key)
        .encrypt(plaintext)
        .map_err(|e| anyhow::anyhow!("encrypt: {e}"))
}

fn decrypt(key: [u8; 32], nonce_and_ct: &[u8]) -> Result<Vec<u8>> {
    ling_crypto::symmetric::XChaCha20::new(key)
        .decrypt(nonce_and_ct)
        .map_err(|e| {
            anyhow::anyhow!("decrypt (wrong keyfile/shares, or the seed was tampered with): {e}")
        })
}

// ─── Shamir recovery ────────────────────────────────────────────────────────

fn recover_init(key: [u8; 32], out_dir: &Path) -> Result<()> {
    std::fs::create_dir_all(out_dir)?;
    let shares = ling_crypto::shamir::split_secret(&key, 2, 3);
    for share in &shares {
        let path = out_dir.join(format!("seed-recovery-{}.share", share.x));
        let content = format!("{}\n{}\n", share.x, hex::encode(&share.y));
        std::fs::write(&path, content).with_context(|| format!("writing {}", path.display()))?;
        println!("  wrote {}", path.display());
    }
    println!(
        "Any 2 of these 3 files reconstruct the seed key without the keyfile. Store them apart from each other and from the keyfile."
    );
    Ok(())
}

fn recover_key(share_paths: &[String]) -> Result<[u8; 32]> {
    if share_paths.len() < 2 {
        bail!("--recover needs at least 2 share files");
    }
    let mut shares = Vec::new();
    for p in share_paths {
        let text = std::fs::read_to_string(p).with_context(|| format!("reading share {p}"))?;
        let mut lines = text.lines();
        let x: u8 = lines
            .next()
            .context("empty share file")?
            .trim()
            .parse()
            .context("share file: bad x-coordinate")?;
        let y = hex::decode(lines.next().context("share file: missing y")?.trim())?;
        shares.push(ling_crypto::shamir::Share { x, y });
    }
    let secret = ling_crypto::shamir::reconstruct_secret(&shares);
    let key: [u8; 32] = secret.try_into().map_err(|_| {
        anyhow::anyhow!("reconstructed secret is not 32 bytes — wrong/mismatched shares")
    })?;
    Ok(key)
}

// ─── diff report ────────────────────────────────────────────────────────────

fn report_diff(old: &Manifest, new: &Manifest) -> bool {
    let mut clean = true;
    for (path, new_hash) in new {
        match old.get(path) {
            None => {
                println!("  + added     {path}");
                clean = false;
            },
            Some(old_hash) if old_hash != new_hash => {
                println!("  ~ modified  {path}");
                clean = false;
            },
            _ => {},
        }
    }
    for path in old.keys() {
        if !new.contains_key(path) {
            println!("  - removed   {path}");
            clean = false;
        }
    }
    clean
}

// ─── CLI ────────────────────────────────────────────────────────────────────

fn flag_value(args: &[String], name: &str) -> Option<String> {
    args.iter()
        .position(|a| a == name)
        .and_then(|i| args.get(i + 1))
        .cloned()
}

pub fn run(args: &[String]) -> Result<()> {
    let root = flag_value(args, "--path")
        .map(PathBuf::from)
        .unwrap_or_else(|| PathBuf::from("."));
    let check = args.iter().any(|a| a == "--check");
    let recover_init_flag = args.iter().any(|a| a == "--recover-init");
    let recover_shares: Vec<String> = {
        let mut v = Vec::new();
        let mut it = args.iter();
        while let Some(a) = it.next() {
            if a == "--recover" {
                for s in it.by_ref() {
                    if s.starts_with("--") {
                        break;
                    }
                    v.push(s.clone());
                }
                break;
            }
        }
        v
    };
    let seed_path = flag_value(args, "--in")
        .or_else(|| flag_value(args, "--out"))
        .map(PathBuf::from)
        .unwrap_or_else(|| PathBuf::from(DEFAULT_SEED_FILE));
    let keyfile = flag_value(args, "--keyfile").map(PathBuf::from);

    let key = if !recover_shares.is_empty() {
        recover_key(&recover_shares)?
    } else {
        let keyfile = keyfile.context("need --keyfile <path> (or --recover <share> <share>)")?;
        derive_key(&keyfile)?
    };

    if recover_init_flag {
        let out_dir = flag_value(args, "--out-dir")
            .map(PathBuf::from)
            .unwrap_or_else(|| PathBuf::from("."));
        return recover_init(key, &out_dir);
    }

    let files = essential_files(&root)?;
    println!(
        "lingfu seed: {} tracked files under {}",
        files.len(),
        root.display()
    );
    let manifest = build_manifest(&root, &files)?;
    let root_hash = merkle_root(&manifest);
    println!("  root: {}", hex::encode(&root_hash));

    if check {
        let wav_bytes = read_wav(&seed_path)?;
        let plaintext = decrypt(key, &wav_bytes)?;
        let text = String::from_utf8(plaintext).context("decrypted seed is not valid UTF-8")?;
        let (old_manifest, old_root) = text_to_manifest(&text)?;
        let clean = report_diff(&old_manifest, &manifest);
        if clean && old_root == root_hash {
            println!("clean: matches {}", seed_path.display());
            Ok(())
        } else {
            bail!("drift detected against {}", seed_path.display());
        }
    } else {
        let text = manifest_to_text(&manifest, &root_hash);
        let ciphertext = encrypt(key, text.as_bytes())?;
        write_wav(&seed_path, &ciphertext)?;
        println!(
            "wrote {} ({} files, {} bytes encrypted)",
            seed_path.display(),
            manifest.len(),
            ciphertext.len()
        );
        Ok(())
    }
}

← Back to index