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]>
156 lines
5.8 KiB
Rust
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)
|
|
}
|