Files
clawmates/tools/bundler/src/lib.rs
T
Omar SobhandClaude Fable 5 add4f79fed Rebrand: TeamClaw -> Clawmates (clawmates.work)
Full-depth rename per the approved plan; the 'claw' product vocabulary
(claws, /claws routes, clawId, Claw Chat) stays — it is now the brand.

- Display brand: Clawmates (manifest, titles, hero, login/rail logo
  'clawmates'); default host app.clawmates.work; registry
  ghcr.io/clawmates
- Crates tc-* -> cm-* (16 crates + all imports); binaries
  clawmates-server/broker/bundler; images clawmates/*; env prefix
  CLAWMATES_* (+ CM_TEST_DATABASE_URL / CM_LIVE_LLM); config
  clawmates.toml; helm chart deploy/helm/clawmates with clawmates-*
  resources; db names clawmates*; sockets /run/clawmates; cookie
  cm_session; kind cluster clawmates-test; seccomp node profile
  clawmates-agent-profile.json
- All 9 Playwright brand assertions updated in lockstep; historical
  spec document left untouched as the only remaining 'TeamClaw'
- Local env migrated: dev pg clawmates-dev-pg/clawmates_dev, shared
  test server clawmates-test-pg, kind cluster recreated with image +
  profile, compose images rebuilt under clawmates/*

Verified end to end: 161 Rust + 68 frontend tests, 29 Playwright
journeys, 4 live kind tests, helm/install/LOC/placeholder gates, and
the clean-room install rehearsal serving the clawmates login page from
a signed bundle of the rebuilt images.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-06-10 12:31:25 -05:00

156 lines
5.8 KiB
Rust

//! Air-gapped bundle assembly and offline verification.
//!
//! A bundle is a directory of artifacts (image tarballs, compose files,
//! migrations, models) plus `manifest.json`, `checksums.txt` (sha256 per
//! artifact), and `checksums.sig` — a detached ed25519 signature over the
//! checksum file. Verification needs only the public key: no network, no
//! transparency log (keyless signing is internet-dependent and therefore
//! disqualified for this target).
use std::fs;
use std::path::{Path, PathBuf};
use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
use sha2::{Digest, Sha256};
#[derive(Debug, thiserror::Error)]
pub enum BundleError {
#[error("io: {0}")]
Io(String),
#[error("bad key material: {0}")]
Key(String),
#[error("signature verification failed")]
BadSignature,
#[error("checksum mismatch for {0}")]
ChecksumMismatch(String),
#[error("bundle is missing {0}")]
Missing(String),
}
fn io_err(e: std::io::Error) -> BundleError {
BundleError::Io(e.to_string())
}
/// Generates a signing keypair; the private key stays with the release
/// pipeline, the public key ships to customers out of band.
pub fn generate_keypair(private_path: &Path, public_path: &Path) -> Result<(), BundleError> {
let signing = SigningKey::generate(&mut rand_core::OsRng);
fs::write(private_path, hex::encode(signing.to_bytes())).map_err(io_err)?;
fs::write(public_path, hex::encode(signing.verifying_key().to_bytes())).map_err(io_err)?;
Ok(())
}
/// Re-derives the public key from a signing key (release pipelines store
/// only the private half in secrets).
pub fn derive_public_key(private_path: &Path, public_path: &Path) -> Result<(), BundleError> {
let signing = load_signing_key(private_path)?;
fs::write(public_path, hex::encode(signing.verifying_key().to_bytes())).map_err(io_err)?;
Ok(())
}
fn load_signing_key(path: &Path) -> Result<SigningKey, BundleError> {
let bytes: [u8; 32] = hex::decode(fs::read_to_string(path).map_err(io_err)?.trim())
.map_err(|e| BundleError::Key(e.to_string()))?
.try_into()
.map_err(|_| BundleError::Key("private key must be 32 bytes".into()))?;
Ok(SigningKey::from_bytes(&bytes))
}
fn load_verifying_key(path: &Path) -> Result<VerifyingKey, BundleError> {
let bytes: [u8; 32] = hex::decode(fs::read_to_string(path).map_err(io_err)?.trim())
.map_err(|e| BundleError::Key(e.to_string()))?
.try_into()
.map_err(|_| BundleError::Key("public key must be 32 bytes".into()))?;
VerifyingKey::from_bytes(&bytes).map_err(|e| BundleError::Key(e.to_string()))
}
fn sha256_file(path: &Path) -> Result<String, BundleError> {
let bytes = fs::read(path).map_err(io_err)?;
Ok(hex::encode(Sha256::digest(&bytes)))
}
/// Copies artifacts into `out`, writes the manifest, checksums every file,
/// and signs the checksum list. `artifacts` are (source, bundle-relative
/// destination) pairs.
pub fn assemble(
out: &Path,
version: &str,
artifacts: &[(PathBuf, String)],
private_key: &Path,
) -> Result<(), BundleError> {
fs::create_dir_all(out).map_err(io_err)?;
let mut entries: Vec<(String, String)> = Vec::new();
for (source, destination) in artifacts {
let target = out.join(destination);
if let Some(parent) = target.parent() {
fs::create_dir_all(parent).map_err(io_err)?;
}
fs::copy(source, &target).map_err(io_err)?;
entries.push((destination.clone(), sha256_file(&target)?));
}
let manifest = serde_json::json!({
"name": "clawmates",
"version": version,
"artifacts": entries.iter().map(|(p, h)| {
serde_json::json!({"path": p, "sha256": h})
}).collect::<Vec<_>>(),
});
let manifest_path = out.join("manifest.json");
fs::write(
&manifest_path,
serde_json::to_vec_pretty(&manifest).unwrap(),
)
.map_err(io_err)?;
entries.push(("manifest.json".into(), sha256_file(&manifest_path)?));
let checksums: String = entries
.iter()
.map(|(path, hash)| format!("{hash} {path}\n"))
.collect();
fs::write(out.join("checksums.txt"), &checksums).map_err(io_err)?;
let key = load_signing_key(private_key)?;
let signature: Signature = key.sign(checksums.as_bytes());
fs::write(out.join("checksums.sig"), hex::encode(signature.to_bytes())).map_err(io_err)?;
Ok(())
}
/// Fully offline verification: the signature over `checksums.txt`, then
/// every listed artifact hash. This is what `install.sh` runs before any
/// `docker load`.
pub fn verify(bundle: &Path, public_key: &Path) -> Result<usize, BundleError> {
let checksums_path = bundle.join("checksums.txt");
if !checksums_path.is_file() {
return Err(BundleError::Missing("checksums.txt".into()));
}
let checksums = fs::read_to_string(&checksums_path).map_err(io_err)?;
let signature_hex = fs::read_to_string(bundle.join("checksums.sig"))
.map_err(|_| BundleError::Missing("checksums.sig".into()))?;
let signature_bytes: [u8; 64] = hex::decode(signature_hex.trim())
.map_err(|e| BundleError::Key(e.to_string()))?
.try_into()
.map_err(|_| BundleError::Key("signature must be 64 bytes".into()))?;
let key = load_verifying_key(public_key)?;
key.verify(
checksums.as_bytes(),
&Signature::from_bytes(&signature_bytes),
)
.map_err(|_| BundleError::BadSignature)?;
let mut verified = 0;
for line in checksums.lines() {
let Some((expected, path)) = line.split_once(" ") else {
continue;
};
let actual =
sha256_file(&bundle.join(path)).map_err(|_| BundleError::Missing(path.to_owned()))?;
if actual != expected {
return Err(BundleError::ChecksumMismatch(path.to_owned()));
}
verified += 1;
}
Ok(verified)
}